Skip to Content
Mix 2.0 is in development! You can access the Mix 1.0 docs here.
DocsTutorialsCreating a Widget

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.

Button Example

Component Overview

Button Variants

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

Button Variants

  • 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:

Button 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 mix

After running this command, your pubspec.yaml file should look similar to this:

dependencies: flutter: sdk: flutter mix: ^1.0.0

Button 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 StyleSpec properties for each visual component
  • Implements copyWith for immutable updates
  • Implements lerp for smooth animations between specs
  • Overrides props for 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 WidgetStateVariantMixin for 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 is false).
  • icon: An optional icon next to the label (optional).
  • variant: The button’s visual style (optional, default is ButtonVariant.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:

  1. We define the CustomButton class with its constructor, which takes the required and optional properties we discussed earlier.
  2. In the build method, we start by wrapping the button content with a Pressable widget. This widget is responsible for providing the button’s interactive states, such as hover, pressed, and disabled. We set the onPress property to onPressed if the button is not disabled; otherwise, we set it to null. The enabled property is set to the opposite of disabled.
  3. Inside the Pressable, we use a StyleBuilder widget. This widget resolves the ButtonStyler into a ButtonSpec and provides it to the builder function. The builder receives both context and the resolved spec.
  4. Within the builder function, we use the resolved spec to access our styled components:
    • FlexBox: The container widget that will hold our button’s content. We pass spec.container to its styleSpec parameter.
    • StyledIcon: If an icon is provided, we display it using StyledIcon with spec.icon for styling.
    • StyledText: If a label is provided, we display it using StyledText with spec.label for styling.

Styling Your Button

Now let’s create a function that defines the complete button styling. This function will:

  1. Define the base styles shared across all variants
  2. Merge the variant-specific style
  3. 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 TextStyler and TextStyleMix.
  • 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 the WidgetStateVariantMixin.
  • 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

Button Demo

// 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

  1. ButtonSpec: Defines the resolved visual properties (container, icon, label) and implements interpolation for smooth animations.

  2. 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)
  3. ButtonVariant: An enum that associates each button variant (filled, outlined, elevated, link) with its specific styling.

  4. CustomButton: A reusable widget that combines all the pieces using Pressable for state management and StyleBuilder for style resolution.

Key Concepts

  • Style Composition: Using .merge() to combine multiple styles together, allowing for easy style overrides and extensions.
  • Widget State Management: Using WidgetStateVariantMixin methods 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.