Skip to content

Open-source UI libraries for Flutter

Build your Flutter design system from three libraries.

Mix is a styling system: a style is a Dart value with interaction states, breakpoints, motion and tokens in one chain. Naked UI supplies behavior and accessibility with nothing drawn. Remix joins the two into components you install as source, compose, and edit as your own.

Show
Open file
lib/demos/upgrade_button/style.dartcomplete file · 21 lines
// dart format offimport 'package:mix/mix.dart';
import '../../colors.dart';
final button = BoxStyler()    .color(purple)    .padding(.symmetric(horizontal: 24, vertical: 15))    .borderRadius(.circular(12))    .onHovered(.color(purpleLight))    .onPressed(.scale(0.96))    .onFocusVisible(.shadow(      .color(white)      .spreadRadius(2),    ))    .animate(.easeOut(160.ms));
final label = TextStyler()    .fontSize(14)    .fontWeight(.w600)    .color(white);
Live Flutter

Hover it, press it, or Tab to it.Press it. The line that handles each state lights up.

This is Flutter running in your browser. Every example on this page is shown with the source it was compiled from, written with Dart’s dot shorthand: .circular(12) is short for BorderRadiusGeometryMix.circular(12). Color names such as purple are constants in lib/colors.dart. Style files start with // dart format off, so each call keeps its own line; the formatter would join short chains.

How they fit

Three layers. Use one, or all of them.

Mix and Naked UI do not depend on each other. Remix depends on both, and re-exports Mix.

Styling is scattered through your build methods
Start with Mix
You need a custom control that works with a keyboard and a screen reader
Start with Naked UI
You want working components now, in your own visual language
Start with Remix

Mix · Styling

A style is a value.

You build a Mix style with a chain of calls, keep it in a variable, and hand it to a widget. Because it is an ordinary value, you can share it between widgets, derive new styles from it, and merge two together.

Mix · 1 of 5

Declare a style once, then derive the rest.

card is the base. featured starts from it, merges in a shared lifted fragment, and restates only the border. The widget picks one of the two values and calls it.

Show
Open file
lib/demos/plan_card/style.dartcomplete file · 45 lines
// dart format offimport 'package:mix/mix.dart';
import '../../colors.dart';
// A style is a value. This one is the box and its column.final card = FlexBoxStyler()    .column()    .crossAxisAlignment(.start)    .mainAxisSize(.min)    .spacing(6)    .padding(.all(16))    .color(charcoal)    .borderRadius(.circular(12))    .border(      .color(slate)      .width(1.5),    )    .animate(.easeOut(220.ms));
// A fragment holds a few properties that several styles can share.final lifted = FlexBoxStyler()    .translate(0, -6)    .shadow(      .color(shadow)      .blurRadius(28)      .offset(x: 0, y: 14),    );
// Derive a style by chaining onto another. Only what differs is restated.final featured = card    .merge(lifted)    .border(.color(purple));
final planName = TextStyler()    .fontSize(14)    .fontWeight(.w600)    .color(grey);final planPrice = TextStyler()    .fontSize(26)    .fontWeight(.w700)    .color(white);final planNote = TextStyler()    .fontSize(14)    .color(grey);
Featured plan

The highlighted lines are everything featured adds.

  • Calling a style builds its widget: card(children: …) is a FlexBox, a column and its Container in one, and planName('Pro') is a StyledText.
  • Later calls win, field by field. featured changes the border’s color and keeps its width, along with the padding, fill and radius from card.
  • merge combines two styles. Use it for fragments, like lifted, that several components share.
Composition in the styling guide

Mix · 2 of 5

Interaction states belong to the style.

onHovered, onPressed, onFocusVisible and onDisabled each take the changes for one state. .pressable() adds the gestures, focus and button semantics.

Show
Open file
lib/demos/action_button/style.dartcomplete file · 23 lines
// dart format offimport 'package:mix/mix.dart';
import '../../colors.dart';import '../upgrade_button/style.dart' show label;
// Each state names only what changes while it is active.final action = BoxStyler()    .padding(.symmetric(horizontal: 22, vertical: 13))    .color(charcoal)    .borderRadius(.circular(12))    .border(      .color(slate)      .width(1.5),    )    .onHovered(.color(graphite))    .onPressed(.scale(0.95))    .onFocusVisible(.border(.color(purple)))    .onDisabled(.color(night))    .animate(.easeOut(140.ms));
// Text below a pressable reads the same states.final actionLabel = label.onDisabled(.color(grey));

Use the button, or hold a state on. A lit dot is a state Flutter reports right now.

  • Hover and press work on any Box. Focus and disabled need a pressable ancestor, which .pressable() creates.
  • onFocusVisible stops applying after a click or tap, until the next key press, so a pointer does not leave a focus ring behind. onFocused applies to every kind of focus.
  • actionLabel is the label style from the first example with one line added. Pass a WidgetStatesController to read states or set them; the toggles above use one.
Interaction variants

Mix · 3 of 5

Breakpoints go in the same chain.

onBreakpoint swaps values while the screen is 520 logical pixels wide or less. Here the plan cards from the first Mix example sit in a grid that goes from three columns to one.

Show
Open file
lib/demos/plan_list/style.dartcomplete file · 14 lines
// dart format offimport 'package:mix/mix.dart';
const compact = Breakpoint.maxWidth(520);
// Three columns on a wide screen, one on a compact one.final planGrid = GridBoxStyler()    .equalColumns(3)    .gap(16)    .onBreakpoint(      compact,      .equalColumns(1)      .gap(8),    );
600 px · wide

The onBreakpoint calls light up while they are in effect. Wider screens are drawn smaller to fit.

  • onBreakpoint reads the screen size from MediaQuery. The preview sets that size to the width you choose, so the styles respond as they would on a screen that wide.
  • onBreakpoint works on any style, not only a grid: padding, type size and alignment can change the same way. onMobile, onTablet and onDesktop are built in. By default they cover up to 767, 768 to 1023, and 1024 up.
  • A grid can also follow the width its parent offers instead of the screen’s: onConstraints(.maxWidth(520), …).
Breakpoint variants

Mix · 4 of 5

Motion is one more call.

A style can animate in three ways. animate eases between values whenever they change. A phase animation steps through named phases. A keyframe animation runs several tracks on one timeline.

Show
Kind of animation
Open file
lib/demos/toggle/style.dartcomplete file · 27 lines
// dart format offimport 'package:mix/mix.dart';
import '../../colors.dart';
// The values change with `on`. animate() decides how they get there.BoxStyler track({  required bool on,  required AnimationConfig motion,}) {  return BoxStyler()      .size(76, 44)      .padding(.all(5))      .borderRadius(.circular(22))      .color(on ? purple : slate)      .alignment(on ? .centerRight : .centerLeft)      .animate(motion);}
final thumb = BoxStyler()    .size(34, 34)    .borderRadius(.circular(17))    .color(white);
final ease = AnimationConfig.easeOut(260.ms);final spring = AnimationConfig.spring(520.ms, bounce: 0.35);final bouncy = AnimationConfig.spring(700.ms, bounce: 0.6);

Tap the switch. Then change the config and tap again.

Animation config
  • The style never says “animate the thumb”. It states the alignment and color for each value of on, and animate covers the change.
  • A spring has a duration and a bounce. bounce: 0.6 overshoots; 0 settles without it.
  • Combined with states, this is how a hover or press eases: .onPressed(.scale(0.95)).animate(…).
Animations guide

Mix · 5 of 5

Name a value once. Change it everywhere.

The examples so far used fixed colors. A token is a typed name: a style refers to it with a call, $primary(), and the nearest MixScope supplies the value when the widget builds. Give the scope different values and every style under it follows.

Show
1. Declare the nameslib/theme.dartexcerpt · lines 5–20 of 169
// A token is a name with a type. It has no value of its own.const $canvas = ColorToken('canvas');const $surface = ColorToken('surface');const $outline = ColorToken('outline');const $ink = ColorToken('ink');const $muted = ColorToken('muted');const $primary = ColorToken('primary');const $primaryHover = ColorToken('primary.hover');const $onPrimary = ColorToken('on-primary');
const $radius = RadiusToken('radius');const $gap = SpaceToken('gap');
const $title = TextStyleToken('title');const $body = TextStyleToken('body');const $label = TextStyleToken('label');
2. Give them values: Midnightlib/theme.dartexcerpt · lines 64–78 of 169
final midnight = ThemeValues(  colors: {    $canvas: Color(0xFF0D0B14),    $surface: Color(0xFF17141F),    $outline: Color(0xFF322B45),    $ink: Color(0xFFF4F2FA),    $muted: Color(0xFF9A93AE),    $primary: Color(0xFF8B5CF6),    $primaryHover: Color(0xFF9D75FA),    $onPrimary: Color(0xFFFFFFFF),  },  radius: 12,  gap: 16,  font: 'Geist',);
3. Provide the valueslib/theme.dartexcerpt · lines 160–166 of 169
return MixScope(  colors: theme.colors,  radii: {$radius: Radius.circular(theme.radius)},  spaces: {$gap: theme.gap},  textStyles: theme.textStyles,  child: child,);
4. Use the nameslib/demos/themed_plan/style.dartcomplete file · 46 lines
// dart format offimport 'package:mix/mix.dart';
import '../../theme.dart';
final themedColumn = FlexBoxStyler()    .column()    .mainAxisSize(.min)    .spacing(20)    .padding(.all(24))    .color($canvas());
final themedCard = FlexBoxStyler()    .column()    .crossAxisAlignment(.start)    .mainAxisSize(.min)    .spacing(6)    .width(220)    .padding(.all($gap()))    .color($surface())    .borderRadius(.all($radius()))    .border(      .color($outline())      .width(1.5),    )    .animate(.easeOut(220.ms));
final themedButton = BoxStyler()    .padding(.symmetric(horizontal: 22, vertical: 13))    .color($primary())    .borderRadius(.all($radius()))    .onHovered(.color($primaryHover()))    .animate(.easeOut(220.ms));
final themedName = TextStyler()    .style($label.mix())    .color($muted());final themedPrice = TextStyler()    .style($title.mix())    .color($ink());final themedNote = TextStyler()    .style($body.mix())    .color($muted());final themedLabel = TextStyler()    .style($label.mix())    .color($onPrimary());
Theme

Only step 2 changes. The four themes differ in color, corner radius, spacing and typeface.

  • The $ is a naming convention for token variables, not syntax. The call is what matters: $primary() returns a reference that is resolved later, not a color.
  • Text styles pass through .mix(): .style($label.mix()).
  • A nested MixScope replaces the outer one. To override a few tokens and keep the rest, use MixScope.inherit.
Design tokens guide

Naked UI · Behavior

The behavior of a control, with nothing drawn.

Mix covers how a widget looks. Naked UI covers how a control behaves: it handles pointer, keyboard, focus and semantics, then hands you its state. What appears on screen is whatever your builder returns, so one behavior can look like anything.

Show
Widget
Open file
lib/demos/plan_picker/plan_picker.dartcomplete file · 58 lines
import 'package:flutter/widgets.dart';import 'package:mix/mix.dart';import 'package:naked_ui/naked_ui.dart';
import '../plans.dart';
/// How one option is drawn. Naked UI passes the state; the rest is yours.typedef PlanBuilder =    Widget Function(      Plan plan,      NakedRadioState<String> state,    );
/// A radio group of plans. Naked UI supplies the behavior; [builder]/// draws each option and [layout] places them:////// ```dart/// PlanPicker(///   plan: plan,///   onChanged: choose,///   layout: planRows,///   builder: planRow,/// )/// ```class PlanPicker extends StatelessWidget {  const PlanPicker({    super.key,    required this.plan,    required this.onChanged,    required this.layout,    required this.builder,  });
  final String plan;  final ValueChanged<String> onChanged;  final FlexBoxStyler layout;  final PlanBuilder builder;
  @override  Widget build(BuildContext context) {    // Selection, arrow keys, focus and semantics come from the group.    return NakedRadioGroup<String>(      groupValue: plan,      onChanged: (next) => onChanged(next ?? plan),      semanticLabel: 'Plan',      child: layout(        children: [          for (final plan in plans)            NakedRadio<String>(              value: plan.name,              builder: (context, state, child) =>                  builder(plan, state),            ),        ],      ),    );  }}

Both drawings are the same PlanPicker, sharing one selection. The group reported Pro through onChanged.

Handled by the widget, not by this code

Tab
moves focus into the group
↑ ↓ ← →
move to the next option and select it
Space
selects the focused option
  • The builder gets typed state: isHovered, isFocused and isPressed everywhere, isSelected on a radio or an option, and isOpen on the select.
  • Naked UI depends only on Flutter. These drawings use Mix; a Container works the same way.
NakedRadio reference

Remix · Components

Components you install, compose and own.

An interactive Remix component is Naked UI behavior drawn through a Mix style. A display component, such as a card or a badge, is a Mix style alone. The CLI copies them into your project as source. You import them from one file and compose them like any other widget.

Show
Code
Open file
lib/demos/checkout/checkout.dartcomplete file · 80 lines
import 'package:flutter/widgets.dart';
import '../../ui/ui.dart';import 'style.dart';
/// A checkout form composed from installed Remix components.class Checkout extends StatefulWidget {  const Checkout({super.key});
  @override  State<Checkout> createState() => _CheckoutState();}
class _CheckoutState extends State<Checkout> {  int seats = 3;  bool yearly = true;  bool support = false;  bool starting = false;
  int get monthly =>      seats * (yearly ? 10 : 12) + (support ? 4 : 0);
  // Nothing is submitted: the button shows its loading state for a moment.  Future<void> start() async {    setState(() => starting = true);    await Future<void>.delayed(      const Duration(milliseconds: 1400),    );    if (mounted) setState(() => starting = false);  }
  void reset() => setState(() {    seats = 3;    yearly = true;    support = false;  });
  @override  Widget build(BuildContext context) {    return UiCard(      child: form(        children: [          header(            children: [              const Text('Seats'),              UiBadge.secondary(label: '$seats'),            ],          ),          UiSlider(            value: seats.toDouble(),            min: 1,            max: 10,            snapDivisions: 9,            semanticLabel: 'Seats',            onChanged: (value) =>                setState(() => seats = value.round()),          ),          UiSwitch(            label: 'Bill yearly',            selected: yearly,            onChanged: (value) =>                setState(() => yearly = value),          ),          UiCheckbox(            label: 'Priority support',            selected: support,            onChanged: (value) =>                setState(() => support = value ?? false),          ),          UiButton(            label: 'Start for \$$monthly a month',            loading: starting,            onPressed: start,          ),          UiButton.ghost(label: 'Reset', onPressed: reset),        ],      ),    );  }}

Move the slider, flip the switch, and press Start to see its loading state. Reset is the ghost variant of the same button.

  • Six installed components and two Mix layout styles make the whole form. UiCard, UiSlider, UiSwitch, UiCheckbox, UiButton and UiBadge come from the one ui.dart import.
  • Variants are named constructors: UiButton.ghost, UiBadge.secondary. To change one call site, pass style:; it is merged last.
  • The components are source in lib/ui, not a dependency. Edit a recipe, or give a plain Remix* widget a Styler of your own.
Open code and the CLIStyling componentsFortal, a design system built with Remix

Get started

Add one to your app.

Each library installs on its own. Pick the layer that matches what you are building; you can add the others later.

  1. Add the package

    flutter pub add mix

    This page runs mix 2.4.0. mix on pub.dev

  2. Replace lib/main.dart with this

    lib/starters/mix.dartcomplete file · 37 lines
    import 'package:flutter/widgets.dart';import 'package:mix/mix.dart';
    void main() => runApp(const App());
    const purple = Color(0xFF6D4AFF);const white = Color(0xFFFFFFFF);
    final button = BoxStyler()    .color(purple)    .padding(.symmetric(horizontal: 20, vertical: 12))    .borderRadius(.circular(10))    .onHovered(.scale(1.04))    .onPressed(.scale(0.96))    .animate(.easeOut(150.ms));
    final label = TextStyler()    .fontSize(14)    .fontWeight(.w600)    .color(white);
    class App extends StatelessWidget {  const App({super.key});
      @override  Widget build(BuildContext context) {    return WidgetsApp(      color: purple,      builder: (context, child) => Center(        child: button.pressable(          onPress: () => debugPrint('Pressed'),          child: label('Hello, Mix'),        ),      ),    );  }}
  3. Run it

    flutter run

    Needs Flutter 3.47.9+ · Dart 3.13+. The starter uses Dart’s dot shorthand, such as .circular(10).

Run this page’s examples

Every example above is a file in one small Flutter package. Browse it, or clone it and run it.

Browse the source