Building a Design System Widget
When creating a design system in Flutter, building reusable components that maintain consistency and adhere to predefined design principles is crucial. In this guide, we’ll explore the process of creating a button component for a design system using Mix’s manual styling API, which gives you full control over how styles are defined, composed, and resolved.

Component Overview
Button Variants
A design system button should support multiple variants to cater to different use cases and visual styles. Common variants include:

- Filled: A button with a solid background color.
- Outline: A button with a transparent background and a visible border.
- Elevated: A button with a shadow effect to give it a raised appearance.
- Link: A button that looks like a clickable link, usually without a background or border.
Each variant should have a distinct visual style while maintaining consistency with the overall design system.
States of a Button
To provide visual feedback to the user, each button variant should have well-defined states:

- Normal: The default state of the button.
- Hover: The state when the user hovers over the button or focuses on it using keyboard navigation.
- Pressed: The state when the button is actively being pressed.
- Disabled: The state when the button is non-interactive and cannot be clicked.
Implementing these states ensures that users have a clear understanding of the button’s interactivity and current state.
Shared Visual Attributes
While each button variant may have distinct visual characteristics, they should share common properties to maintain consistency across the design system. Some shared properties include:
- Border Radius: The roundness of the button’s corners.
- Color Palette: The colors used for the button’s background, text, and border should be derived from the design system’s color palette.
- Typography: The font family, size, and weight used for the button’s text should align with the design system’s typography guidelines.
- Padding and Spacing: The internal padding and spacing between the button’s text and its edges should be consistent across variants.
By sharing these properties, the button component ensures a cohesive and consistent appearance throughout the application.
Setup Mix
Add the mix package to your Flutter project:
flutter pub add mixAfter running this command, your pubspec.yaml file should look similar to this:
dependencies:
flutter:
sdk: flutter
mix: ^1.0.0Button Structure
Next, let’s implement an example of a design system button.
Structure Overview
Here is a simple example structure for a button.
- Container: Wraps the entire button and provides box decoration (border radius, background color, spacing).
- Icon (optional): Represents an icon or visual embellishment for the button.
- Label: Displays the button’s text content.
Create a Button Spec
A Spec, which is short for Specification, defines the resolved visual properties and attributes that a Button can have. Our ButtonSpec will contain specs for the container (FlexBox), icon, and label.
import 'package:flutter/material.dart';
import 'package:mix/mix.dart';
class ButtonSpec extends Spec<ButtonSpec> {
final StyleSpec<FlexBoxSpec>? container;
final StyleSpec<IconSpec>? icon;
final StyleSpec<TextSpec>? label;
const ButtonSpec({this.container, this.icon, this.label});
@override
ButtonSpec copyWith({
StyleSpec<FlexBoxSpec>? container,
StyleSpec<IconSpec>? icon,
StyleSpec<TextSpec>? label,
}) {
return ButtonSpec(
container: container ?? this.container,
icon: icon ?? this.icon,
label: label ?? this.label,
);
}
@override
ButtonSpec lerp(covariant ButtonSpec? other, double t) {
return ButtonSpec(
container: container?.lerp(other?.container, t),
icon: icon?.lerp(other?.icon, t),
label: label?.lerp(other?.label, t),
);
}
@override
List<Object?> get props => [container, icon, label];
}The ButtonSpec class:
- Extends
Spec<ButtonSpec>to integrate with Mix’s styling system - Contains
StyleSpecproperties for each visual component - Implements
copyWithfor immutable updates - Implements
lerpfor smooth animations between specs - Overrides
propsfor equality comparison
Create a Button Styler
The ButtonStyler is the API class that provides a fluent interface for styling your button. It extends Style<ButtonSpec> and allows you to compose styles in a type-safe manner.
class ButtonStyler extends Style<ButtonSpec>
with WidgetStateVariantMixin<ButtonStyler, ButtonSpec> {
final Prop<StyleSpec<FlexBoxSpec>>? $container;
final Prop<StyleSpec<IconSpec>>? $icon;
final Prop<StyleSpec<TextSpec>>? $label;
ButtonStyler({
FlexBoxStyler? container,
IconStyler? icon,
TextStyler? label,
super.animation,
super.modifier,
super.variants,
}) : $container = Prop.maybeMix(container),
$icon = Prop.maybeMix(icon),
$label = Prop.maybeMix(label);
// Component methods
ButtonStyler container(FlexBoxStyler value) {
return merge(ButtonStyler(container: value));
}
ButtonStyler icon(IconStyler value) {
return merge(ButtonStyler(icon: value));
}
ButtonStyler label(TextStyler value) {
return merge(ButtonStyler(label: value));
}
// Convenience methods
ButtonStyler backgroundColor(Color value) {
return merge(ButtonStyler(container: FlexBoxStyler().color(value)));
}
ButtonStyler textColor(Color value) {
return merge(ButtonStyler(label: TextStyler().color(value)));
}
ButtonStyler iconColor(Color value) {
return merge(ButtonStyler(icon: IconStyler().color(value)));
}
ButtonStyler borderRadius(double value) {
return merge(ButtonStyler(container: FlexBoxStyler().borderRounded(value)));
}
ButtonStyler padding({required double x, required double y}) {
return merge(
ButtonStyler(container: FlexBoxStyler().paddingX(x).paddingY(y)),
);
}
ButtonStyler.create({
Prop<StyleSpec<FlexBoxSpec>>? container,
Prop<StyleSpec<IconSpec>>? icon,
Prop<StyleSpec<TextSpec>>? label,
super.animation,
super.modifier,
super.variants,
}) : $container = container,
$icon = icon,
$label = label;
@override
ButtonStyler merge(covariant ButtonStyler? other) {
return ButtonStyler.create(
container: MixOps.merge($container, other?.$container),
icon: MixOps.merge($icon, other?.$icon),
label: MixOps.merge($label, other?.$label),
animation: MixOps.mergeAnimation($animation, other?.$animation),
modifier: MixOps.mergeModifier($modifier, other?.$modifier),
variants: MixOps.mergeVariants($variants, other?.$variants),
);
}
@override
List<Object?> get props => [$container, $icon, $label];
@override
StyleSpec<ButtonSpec> resolve(BuildContext context) {
final container = MixOps.resolve(context, $container);
final icon = MixOps.resolve(context, $icon);
final label = MixOps.resolve(context, $label);
return StyleSpec(
spec: ButtonSpec(container: container, icon: icon, label: label),
);
}
@override
ButtonStyler variant(Variant variant, ButtonStyler style) {
return merge(ButtonStyler(variants: [VariantStyle(variant, style)]));
}
}The ButtonStyler provides:
- Component methods (
container,icon,label) for direct component styling - Convenience methods (
backgroundColor,textColor, etc.) for common operations - Merging capabilities to compose styles together
- Resolution logic to convert styles into specs
- Widget state support via
WidgetStateVariantMixinfor states like pressed, disabled, hover
Define Variants
We’ll use an enum to define our button variants. Each variant will have its own style getter:
enum ButtonVariant {
filled,
outlined,
elevated,
link;
ButtonStyler get style {
switch (this) {
case ButtonVariant.filled:
return ButtonStyler()
.backgroundColor(Colors.blueAccent)
.textColor(Colors.white)
.iconColor(Colors.white);
case ButtonVariant.outlined:
return ButtonStyler()
.container(
FlexBoxStyler()
.color(Colors.transparent)
.borderAll(width: 1.5, color: Colors.blueAccent),
)
.textColor(Colors.blueAccent)
.iconColor(Colors.blueAccent);
case ButtonVariant.elevated:
return ButtonStyler()
.backgroundColor(Colors.blueAccent)
.textColor(Colors.white)
.iconColor(Colors.white)
.container(
FlexBoxStyler().shadow(
BoxShadowMix()
.color(Colors.blueAccent.shade700)
.offset(x: 0, y: 5),
),
);
case ButtonVariant.link:
return ButtonStyler()
.container(
FlexBoxStyler()
.borderAll(style: BorderStyle.none)
.color(Colors.transparent),
)
.textColor(Colors.blueAccent)
.iconColor(Colors.blueAccent);
}
}
}This approach makes it easy to associate each variant with its specific styling while keeping the code organized and type-safe.
Create the Button Widget
Let’s define a CustomButton class that extends StatelessWidget. We will use the following properties:
label: The button text (required).disabled: If the button is disabled (optional, default isfalse).icon: An optional icon next to the label (optional).variant: The button’s visual style (optional, default isButtonVariant.filled).onPressed: Callback function when pressed (required).style: Additional custom styling (optional).
With these properties in mind, here’s the code for the CustomButton class:
class CustomButton extends StatelessWidget {
const CustomButton({
super.key,
required this.label,
this.disabled = false,
this.icon,
required this.onPressed,
this.variant = ButtonVariant.filled,
this.style,
});
final String label;
final bool disabled;
final IconData? icon;
final ButtonVariant variant;
final VoidCallback? onPressed;
final ButtonStyler? style;
@override
Widget build(BuildContext context) {
return Pressable(
onPress: disabled ? null : onPressed,
enabled: !disabled,
child: StyleBuilder(
style: buttonStyle(style, variant),
builder: (context, spec) {
return FlexBox(
styleSpec: spec.container,
children: [
if (icon != null) StyledIcon(icon: icon, styleSpec: spec.icon),
if (label.isNotEmpty) StyledText(label, styleSpec: spec.label),
],
);
},
),
);
}
}Here’s a breakdown of the CustomButton class implementation:
- We define the
CustomButtonclass with its constructor, which takes the required and optional properties we discussed earlier. - In the
buildmethod, we start by wrapping the button content with aPressablewidget. This widget is responsible for providing the button’s interactive states, such as hover, pressed, and disabled. We set theonPressproperty toonPressedif the button is not disabled; otherwise, we set it tonull. Theenabledproperty is set to the opposite ofdisabled. - Inside the
Pressable, we use aStyleBuilderwidget. This widget resolves theButtonStylerinto aButtonSpecand provides it to the builder function. The builder receives bothcontextand the resolvedspec. - Within the builder function, we use the resolved
specto access our styled components:FlexBox: The container widget that will hold our button’s content. We passspec.containerto itsstyleSpecparameter.StyledIcon: If an icon is provided, we display it usingStyledIconwithspec.iconfor styling.StyledText: If a label is provided, we display it usingStyledTextwithspec.labelfor styling.
Styling Your Button
Now let’s create a function that defines the complete button styling. This function will:
- Define the base styles shared across all variants
- Merge the variant-specific style
- Add widget state styling (pressed, disabled)
ButtonStyler buttonStyle(ButtonStyler? style, ButtonVariant? variant) {
// Base styles shared across all variants
final container = FlexBoxStyler()
.borderRounded(6)
.paddingX(8)
.paddingY(12)
.spacing(8)
.mainAxisAlignment(MainAxisAlignment.center)
.crossAxisAlignment(CrossAxisAlignment.center)
.mainAxisSize(MainAxisSize.min);
final label = TextStyler().style(
TextStyleMix().fontSize(16).fontWeight(FontWeight.w500),
);
final icon = IconStyler().size(18);
return ButtonStyler()
.container(container)
.label(label)
.icon(icon)
.merge(variant?.style)
.onPressed(
ButtonStyler()
.container(FlexBoxStyler().scale(0.9)),
)
.onDisabled(
ButtonStyler()
.container(FlexBoxStyler().color(Colors.blueGrey.shade100))
.label(
TextStyler().style(
TextStyleMix().color(Colors.blueGrey.shade700),
),
)
.icon(IconStyler().color(Colors.blueGrey.shade700)),
)
.merge(style);
}Here’s what’s happening:
- Base Container: We define the base container with border radius, padding (using
.paddingX()and.paddingY()for horizontal/vertical padding), spacing between children, and alignment properties. - Label Style: We set up the text styling with font size and weight using
TextStylerandTextStyleMix. - Icon Style: We define the icon size.
- Variant Merge: We merge the variant’s style (from the enum we created earlier) using
.merge(variant?.style). - Pressed State: When pressed, we scale the button down to 0.9 using
.onPressed()which leverages theWidgetStateVariantMixin. - Disabled State: When disabled, we change the colors to grey using
.onDisabled(). - Custom Style Override: Finally, we merge any custom style passed to the button with
.merge(style).
The buttonStyle function is already being called in our CustomButton widget’s StyleBuilder. The function combines all the styling logic and is passed to the StyleBuilder which then resolves it into a ButtonSpec for rendering.
Button Variant Widgets
If you don’t want to pass the variant manually every time you use it, you can go ahead and create a new widget for each variant like this.
final class FilledButton extends CustomButton {
const FilledButton({
super.key,
required super.label,
super.disabled = false,
super.icon,
required super.onPressed,
super.style,
}) : super(variant: ButtonVariant.filled);
}
final class OutlinedButton extends CustomButton {
const OutlinedButton({
super.key,
required super.label,
super.disabled = false,
super.icon,
required super.onPressed,
super.style,
}) : super(variant: ButtonVariant.outlined);
}
final class ElevatedButton extends CustomButton {
const ElevatedButton({
super.key,
required super.label,
super.disabled = false,
super.icon,
required super.onPressed,
super.style,
}) : super(variant: ButtonVariant.elevated);
}
final class LinkButton extends CustomButton {
const LinkButton({
super.key,
required super.label,
super.disabled = false,
super.icon,
required super.onPressed,
super.style,
}) : super(variant: ButtonVariant.link);
}Results

// Main App
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: ButtonExampleScreen(),
);
}
}
class ButtonExampleScreen extends StatelessWidget {
const ButtonExampleScreen({super.key});
@override
Widget build(BuildContext context) {
final icon = Icons.favorite;
return Scaffold(
appBar: AppBar(
title: const Text('Button Examples'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
label: 'Filled Button',
icon: icon,
onPressed: () {},
),
const SizedBox(height: 10),
OutlinedButton(
label: 'Outlined Button',
icon: icon,
onPressed: () {},
),
const SizedBox(height: 10),
ElevatedButton(
label: 'Elevated Button',
icon: icon,
onPressed: () {},
),
const SizedBox(height: 10),
LinkButton(
label: 'Link Button',
icon: icon,
onPressed: () {},
),
const SizedBox(height: 20),
const Text(
'Disabled State:',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
FilledButton(
label: 'Disabled Button',
icon: icon,
disabled: true,
onPressed: () {},
),
],
),
),
);
}
}Summary
Congratulations! You’ve built a complete, production-ready button component for a design system using Mix. Here’s what we’ve accomplished:
Key Components
-
ButtonSpec: Defines the resolved visual properties (container, icon, label) and implements interpolation for smooth animations.
-
ButtonStyler: Provides a fluent, type-safe API for styling with:
- Component methods for direct styling
- Convenience methods for common operations
- Built-in support for widget states (pressed, disabled, hover)
-
ButtonVariant: An enum that associates each button variant (filled, outlined, elevated, link) with its specific styling.
-
CustomButton: A reusable widget that combines all the pieces using
Pressablefor state management andStyleBuilderfor style resolution.
Key Concepts
- Style Composition: Using
.merge()to combine multiple styles together, allowing for easy style overrides and extensions. - Widget State Management: Using
WidgetStateVariantMixinmethods like.onPressed()and.onDisabled()to define state-specific styling. - Type Safety: The entire styling system is strongly typed, catching errors at compile time.
- Separation of Concerns: Styles are defined separately from the widget implementation, making them easier to maintain and test.
This pattern can be extended to create other design system components like cards, inputs, dialogs, and more. The manual approach gives you complete control over the styling API while maintaining consistency and composability.