Crafting Perfect UI Kit in Flutter

Crafting Perfect UI Kit in Flutter

Having a good UI kit is essential to building great apps. It's more than just aesthetics - it's the backbone of a seamless user experience and consistent branding. In this article, we will create a theme, buttons, and text fields similar to those in the popular shadcn-ui.

0:00
/0:19

Outline

  1. Theme
    1. Color Palette
    2. Typography
    3. Combining extensions
  2. Creating custom components
    1. Buttons
    2. Text Fields

Theme

A theme represents the overall design and visual style of an application, encompassing various elements that contribute to the user interface. In Flutter, this is managed through ThemeData, which defines the color scheme, typography and appearance of widgets like Scaffold, AppBar, TextField, buttons, and more.

However, ThemeData is not just limited to its default properties; it can be extended using the ThemeExtension API. This powerful feature allows to create custom extensions, enhancing the Theme inherited widget with additional, app-specific styling options. In this guide, we’ll demonstrate how to create two such extensions— one for custom colors and another for typography.

Color Palette

The color palette consists of all the colors used in the application. Usually these colors are named in an abstract way (like primary, secondary - not yellow or blue) so that it is possible to easily create different themes (e.g. light and dark).

💡
Even if you're creating an MVP, it's beneficial to consider a color palette or use an existing one. Changing it later can be costly.

Once you have an established color palette, you can create a class that combines all the palette colors in it. Let's add the key colors from Shadcn to it:

class ColorPalette extends ThemeExtension<ColorPalette> {
  const ColorPalette({
    required this.background,
    required this.foreground,
    required this.muted,
    required this.mutedForeground,
    required this.border,
    required this.primary,
    required this.primaryForeground,
    required this.secondary,
    required this.secondaryForeground,
    required this.accent,
    required this.accentForeground,
    required this.destructive,
    required this.destructiveForeground,
    required this.ring,
  });

  @override
  ThemeExtension<ColorPalette> copyWith();

  @override
  ThemeExtension<ColorPalette> lerp();
}

Color Palette contains all the colors used in the application.

The ColorPalette extension includes copyWith and lerp methods inherited from ThemeExtension. The lerp method enables smooth transitions between palettes when switching themes. Checkout the full code here.

Typography

The same applies to typography. All text styles should be organized in one class. When combined with a cohesive color scheme, this approach ensures a consistent look for your app, eliminating the need to duplicate code. Here's how it looks like:

/// An extension for theme to provide typography.
class AppTypography extends ThemeExtension<AppTypography> {
  const AppTypography({
    required this.displayLarge,
    required this.displayMedium,
    required this.displaySmall,
    required this.headlineLarge,
    required this.headlineMedium,
    required this.headlineSmall,
    required this.titleLarge,
    required this.titleMedium,
    required this.titleSmall,
    required this.bodyLarge,
    required this.bodyMedium,
    required this.bodySmall,
    required this.labelLarge,
    required this.labelMedium,
    required this.labelSmall,
  });

  @override
  ThemeExtension<AppTypography> copyWith();

  @override
  ThemeExtension<AppTypography> lerp();
}

This uses the default Material 3 styles, but you can use whatever you want or what the designer gives you.

💡
If possible, always ask the designer to stick to a color scheme and defined typography styles, so they don't use dozens of different colors and styles. This will help you and the application a lot.

Combining extensions

Let’s unify the color palette and typography extensions into a single ThemeData. I’ve created a simple function to do this:

/// Creates a [ThemeData] object based on the provided [ColorPalette] and [AppTypography].
///
/// The [brightness] parameter is used to determine the brightness of the theme.
ThemeData createThemeData({
  required ColorPalette palette,
  required AppTypography typography,
  required Brightness brightness,
}) =>
    ThemeData(
      brightness: brightness,
      extensions: {
        palette,
        typography,
      },
    );

Now, we can use this method to create themes, like this:

final lightTheme = createThemeData(
  brightness: Brightness.light,
  palette: lightPalette,
  typography: appTypography,
);

final darkTheme = createThemeData(
  brightness: Brightness.dark,
  palette: darkPalette,
  typography: appTypography,
);

Depending on how you manage the current theme and user settings, you can pass the theme to the MaterialApp's theme and darkTheme properties. This way, every widget within the app will have access to these extensions. Here’s how it works in practice:

/// Create a helper extension
/// An extension for theme to get color palette and typography from [BuildContext].
extension ThemeDataExtensions on ThemeData {
  /// The color palette set for the app.
  ColorPalette get colorPalette => extension<ColorPalette>() ?? lightPalette;

  /// The typography set for the app.
  AppTypography get appTypography => extension<AppTypography>() ?? defaultTypography;
}

/// How to use these extensions
class SampleWidget extends StatelessWidget {
  const SampleWidget({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        body: Center(
          child: UiText.bodyLarge(
            'Hello, World!',
            color: Theme.of(context).colorPalette.primary,
          ),
        ),
      );
}

Consider creating a helper extension, as shown in the snippet above, to retrieve the specified extension from ThemeData. If the context lacks this extension, it will return the default one. While optional, this can be useful for tests, saving you from including these extensions in every test.

Custom components

The main motivation for a UI kit is to have a collection of components that are used to build the UI. This provides the same benefits as the extensions we defined earlier - consistency of look, and ease of development.

💡
A common misconception is to customize built-in Flutter widgets (from Cupertino or Material) right where they're used. This makes the layout inconsistent and inconvenient to maintain.

It is recommended to develop custom components for each design element in your layouts. The UI library should include reusable elements like buttons, text fields, cards, text elements, tooltips, popups and more.

Buttons

Buttons allow people to take actions and make choices with a single tap. They can come in a variety of styles, from the least prominent to the most prominent. For example, Material 3 has filled, elevated, outlined, and text buttons.

There are various ways to create custom buttons. Some people build them from scratch, while others modify existing buttons. Although these methods can work, they are not foolproof and can be challenging due to the complexity of implementing a button correctly. Today, I want to show a solution using ButtonStyleButton, which gives you full control over the button's appearance and simplifies the process by allowing you to focus on styling rather than implementation.

Flutter's Material library offers the ButtonStyleButton class for creating custom button implementations. This class leverages ButtonStyle to define the button's appearance across various states, such as focused, enabled, disabled, and hovered. While it’s part of the Material library, ButtonStyleButton can be used to create entirely custom buttons. Here’s what it's general declaration looks like:

abstract class ButtonStyleButton extends StatefulWidget {
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
  const ButtonStyleButton({
    super.key,
    required this.style,
    ...
  });

  /// Optionally override the style from default style
  final ButtonStyle? style;

  @protected
  ButtonStyle defaultStyleOf(BuildContext context);
}

The defaultStyleOf method fetches the default style for a specific button implementation, and the optional style parameter lets you override these defaults. The easiest way to create custom button styles is by extending ButtonStyle and overriding the necessary fields.

Let’s create a few buttons inspired by the popular Shadcn UI styles: Primary, Secondary, Destructive, Outline, and Text. To start, we’ll define an enum that includes all the possible button variants:

enum ButtonVariant {
  /// Button with background filled with primary color
  filledPrimary,

  /// Button with background filled with secondary color
  filledSecondary,

  /// Button with background filled with destructive color
  filledDestructive,

  /// Button with border
  outlined,

  /// Button just with text
  text,

  /// Button just with icon
  icon,

  /// Button with outlined border and icon
  iconOutlined,
}

Now let's create a base style that shares some common properties with all buttons:

class _UiBaseButtonStyle extends ButtonStyle {
  const _UiBaseButtonStyle({
    required this.colorPalette,
    required this.typography,
  });

  /// The color palette to use for the button.
  final ColorPalette colorPalette;

  /// The typography to use for the button.
  final AppTypography typography;

  @override
  AlignmentGeometry? get alignment => Alignment.center;

  @override
  Duration? get animationDuration => const Duration(milliseconds: 200);
  
  @override
  ButtonLayerBuilder? get backgroundBuilder => _backgroundBuilder;

  Widget _backgroundBuilder(BuildContext context, Set<WidgetState> states, Widget? child) {
    if (child == null) return const SizedBox.shrink();

    if (states.contains(WidgetState.focused)) {
      return OutlineFocusButtonBorder(
        showBorder: true,
        focusedBorderColor: colorPalette.focusOutline,
        focusedBorderWidth: 2,
        child: child,
      );
    }

    return child;
  }
  
  ... and more
}

ButtonStyle also gives the ability to inject additional widgets into the widget tree created by ButtonStyleButton. For instance, in the snippet above, it adds a widget that draws an outline around the button when it’s focused.

This is the style for the primary filled button:

class _FilledButtonPrimaryStyle extends _UiBaseButtonStyle {
  const _FilledButtonPrimaryStyle({
    required super.colorPalette,
    required super.typography,
  });

  @override
  WidgetStateProperty<Color?>? get foregroundColor => WidgetStateProperty.resolveWith(
        (Set<WidgetState> states) {
          if (states.contains(WidgetState.disabled)) {
            return colorPalette.primary.withOpacity(.38);
          }

          return colorPalette.primaryForeground;
        },
      );

  @override
  WidgetStateProperty<Color?>? get backgroundColor => WidgetStateProperty.resolveWith(
        (states) {
          if (states.contains(WidgetState.disabled)) {
            return colorPalette.primary.withOpacity(.12);
          }

          return colorPalette.primary;
        },
      );

  @override
  WidgetStateProperty<Color?>? get overlayColor => WidgetStateProperty.resolveWith((Set<WidgetState> states) {
        final color = colorPalette.primaryForeground;

        if (states.contains(WidgetState.pressed)) {
          return color.withOpacity(0.1);
        }
        if (states.contains(WidgetState.hovered)) {
          return color.withOpacity(0.08);
        }
        if (states.contains(WidgetState.focused)) {
          return color.withOpacity(0.1);
        }

        return null;
      });

  @override
  WidgetStateProperty<double>? get elevation => WidgetStateProperty.resolveWith((Set<WidgetState> states) {
        if (states.contains(WidgetState.disabled)) {
          return 0.0;
        }
        if (states.contains(WidgetState.pressed)) {
          return 0.0;
        }
        if (states.contains(WidgetState.hovered)) {
          return 1.0;
        }
        if (states.contains(WidgetState.focused)) {
          return 0.0;
        }
        return 0.0;
      });

  @override
  WidgetStateProperty<Color>? get shadowColor =>
      WidgetStatePropertyAll<Color>(colorPalette.foreground.withOpacity(.18));
}

It is needed to create a style for each button variant. Now, let's implement the UiButton widget:

class UiButton extends ButtonStyleButton {
  UiButton.filledPrimary({
    required VoidCallback? onPressed,
    bool enabled = true,
    IconAlignment iconAlignment = IconAlignment.start,
    Widget? label,
    Widget? icon,
    VoidCallback? onLongPress,
    super.autofocus = false,
    super.onHover,
    super.onFocusChange,
    super.style,
    super.focusNode,
    super.clipBehavior,
    super.statesController,
    super.isSemanticButton,
    super.key,
  })  : variant = ButtonVariant.filledPrimary,
        super(
          child: _ButtonIconAndLabel(icon: icon, label: label, iconAlignment: iconAlignment),
          onPressed: enabled ? onPressed : null,
          onLongPress: enabled ? onLongPress : null,
        );

  UiButton.filledSecondary({
    ...
  })  : variant = ButtonVariant.filledSecondary;

  UiButton.filledDestructive({
    ...
  })  : variant = ButtonVariant.filledDestructive;

  UiButton.outlined({
    ...
  })  : variant = ButtonVariant.outlined;

  UiButton.text({
    ...
  })  : variant = ButtonVariant.text;

  const UiButton.icon({
    required VoidCallback? onPressed,
    bool enabled = true,
    IconAlignment iconAlignment = IconAlignment.start,
    Widget? icon,
    VoidCallback? onLongPress,
    super.autofocus = false,
    super.onHover,
    super.onFocusChange,
    super.style,
    super.focusNode,
    super.clipBehavior,
    super.statesController,
    super.isSemanticButton,
    super.key,
  })  : variant = ButtonVariant.icon,
        super(
          child: icon,
          onPressed: enabled ? onPressed : null,
          onLongPress: enabled ? onLongPress : null,
        );

  const UiButton.iconOutlined({
    ...
  })  : variant = ButtonVariant.iconOutlined;

  final ButtonVariant variant;

  @override
  ButtonStyle defaultStyleOf(BuildContext context) {
    final theme = Theme.of(context);
    final colors = theme.colorPalette;
    final typography = theme.appTypography;

    return switch (variant) {
      ButtonVariant.filledPrimary => _FilledButtonPrimaryStyle(
          colorPalette: colors,
          typography: typography,
        ),
      ButtonVariant.filledSecondary => _FilledButtonSecondaryStyle(
          colorPalette: colors,
          typography: typography,
        ),
      ButtonVariant.filledDestructive => _FilledButtonDestructiveStyle(
          colorPalette: colors,
          typography: typography,
        ),
      ButtonVariant.outlined => _OutlinedButtonStyle(
          colorPalette: colors,
          typography: typography,
        ),
      ButtonVariant.text => _TextButtonStyle(
          colorPalette: colors,
          typography: typography,
        ),
      ButtonVariant.icon => _IconButtonStandardStyle(
          colorPalette: colors,
          typography: typography,
        ),
      ButtonVariant.iconOutlined => _IconButtonOutlinedStyle(
          colorPalette: colors,
          typography: typography,
        ),
    };
  }

  @override
  ButtonStyle? themeStyleOf(BuildContext context) => null;
}

class _ButtonIconAndLabel extends StatelessWidget {
  const _ButtonIconAndLabel({
    required this.icon,
    required this.label,
    required this.iconAlignment,
  });

  final Widget? icon;
  final Widget? label;
  final IconAlignment iconAlignment;

  @override
  Widget build(BuildContext context) => Row(
        mainAxisSize: MainAxisSize.min,
        children: iconAlignment == IconAlignment.start
            ? [
                if (icon != null) icon!,
                if (icon != null && label != null) const SizedBox(width: 8),
                if (label != null) Flexible(child: label!),
              ]
            : [
                if (label != null) Flexible(child: label!),
                if (icon != null && label != null) const SizedBox(width: 8),
                if (icon != null) icon!,
              ],
      );
}

The UiButton class provides constructors for each button type, automatically setting the appropriate ButtonVariant and optionally including additional fields as needed. For instance, the 'filled' button variant can include both a label and an icon, whereas the 'icon' button variant requires only an icon.

A few interesting details:

  • The enabled flag determines whether the button is active. If enabled is false, the onPressed callback is set to null using a ternary operator.
  • The icon and label are encapsulated in a _ButtonIconAndLabel class that arranges them in a Row based on the IconAlignment (start or end). This approach respects the locale’s directionality, allowing the Row to automatically adjust the arrangement of the icon and label according to the text direction, eliminating concerns about their appearance.

This is how the filled button can be used:

UiButton.filledPrimary(
  onPressed: () {},
  label: const Text('Primary'),
);

UiButton.filledSecondary(
  icon: const Icon(Icons.add_rounded),
  onPressed: () {},
  label: const Text('Secondary'),
);

UiButton.outlined(
  icon: const Icon(Icons.add_rounded),
  onPressed: () {},
  label: const Text('Outlined'),
);

UiButton.icon(
  onPressed: () {},
  icon: const Icon(Icons.add_rounded),
);

UiButton.iconOutlined(
  onPressed: () {},
  icon: const Icon(Icons.add_rounded),
);

UiButton.text(
  onPressed: () {},
  label: const Text('standard'),
);

Filled Button showcase

Take a look at how these buttons render:

The preview also includes outlined, icon, and text buttons. You can check out the full code in the GitHub repository here.

Text Fields

Another component found in nearly every application is the text field. Text fields are versatile and can be used for various actions, from simple text input to implementing pickers or rich text editors.

Flutter provides the EditableText widget, a low-level component that handles platform communication and manages the field's state. Additionally, there's a TextField widget from the Material library, which builds on EditableText by adding input decorations, validations, and more.

To configure the visual appearance of a text field, Flutter uses the InputDecorator widget, which utilizes the InputDecoration class. This class defines elements such as borders, colors, shapes, hints, labels, error texts, and icons—essentially everything related to decoration.

The styling approach is similar to that for buttons, but there is no equivalent to ButtonStyleButton for text fields. Instead, we'll create an InputDecoration and pass it directly to a TextField. Let's start by creating a subclass of InputDecoration to define the styling configurations:

class UiTextFieldStyle extends InputDecoration {
  const UiTextFieldStyle({
    super.icon,
    super.iconColor,
    super.label,
    ...
    this.textStyle,
    this.cursorColor,
    this.cursorWidth,
    this.cursorHeight,
  });

  /// The style to use for the text being edited.
  final TextStyle? textStyle;

  /// The color to use for the cursor.
  final Color? cursorColor;

  /// The width of the cursor.
  final double cursorWidth;

  /// The height of the cursor.
  final double? cursorHeight;

  /// Merge two [UiTextFieldStyle] objects.
  ///
  /// If a property is not null in [other], it will be used,
  /// otherwise the property from `this` will be used.
  UiTextFieldStyle merge(UiTextFieldStyle? other) {
    if (other == null) return this;
    return UiTextFieldStyle(
      ...
      textStyle: other.textStyle ?? textStyle,
      cursorColor: other.cursorColor ?? cursorColor,
    );
  }
}

The main difference from InputDecoration is that this class adds additional properties, such as textStyle, cursorColor and more. Originally, these are provided directly to the TextField, but defining styles centrally is more convenient. Additionally, this class includes a merge method, allowing you to easily override certain decorations (such as adding a hint text or a counter).

Since we're building on top of Material, we're subclassing InputDecoration, which offers numerous configurations and elements. If you don't need all of them, you can safely remove the unnecessary parts. For instance, if your application doesn't use labelText or helperText, you can exclude them from your InputDecoration subclass.

In this article we gonna create only one text field variant - standard, but leave a possibility to create others. This is how the configuration for it looks like:

class _StandardUiTextFieldStyle extends UiTextFieldStyle {
  const _StandardUiTextFieldStyle({required this.palette, required this.typography});

  final ColorPalette palette;
  final AppTypography typography;

  @override
  TextStyle? get textStyle => typography.bodyMedium.copyWith(color: palette.foreground);

  @override
  TextStyle? get counterStyle => typography.labelSmall.copyWith(color: palette.foreground.withOpacity(.58));

  @override
  TextStyle? get errorStyle => typography.bodySmall.copyWith(color: palette.destructive);

  @override
  TextStyle? get hintStyle => WidgetStateTextStyle.resolveWith(
        (states) {
          if (states.contains(WidgetState.disabled)) {
            return typography.bodyMedium.copyWith(color: palette.foreground.withOpacity(.38));
          }

          return typography.bodyMedium.copyWith(color: palette.foreground.withOpacity(.58));
        },
      );

  @override
  TextStyle? get helperStyle => typography.bodySmall.copyWith(color: palette.foreground.withOpacity(.58));

  @override
  InputBorder? get focusedBorder => OutlineInputBorder(
        borderRadius: BorderRadius.circular(6),
        borderSide: BorderSide(
          color: palette.primary,
          width: 1,
        ),
      );

  ...

  @override
  EdgeInsetsGeometry? get contentPadding => const EdgeInsets.symmetric(horizontal: 12, vertical: 12);

  @override
  BoxConstraints? get constraints => const BoxConstraints(minHeight: 32);

  @override
  Color? get cursorColor => palette.primary;

  @override
  bool? get isDense => true;
}

Outlined text field style

This approach is quite similar to the one we used for buttons, as it also utilizes a color palette and typography. Additionally, for hint style it uses WidgetStateTextStyle, allowing the value to reflect the widget's state.

This is how to use these text fields:

UiTextField.standard(
  style: UiTextFieldStyle(
    hintText: 'Text input',
  ),
),
SizedBox(height: 16),
UiTextField.standard(
  style: UiTextFieldStyle(
    hintText: 'Text input',
    helperText: 'Helper',
  ),
),
SizedBox(height: 16),
UiTextField.standard(
  style: UiTextFieldStyle(
    hintText: 'Text input',
    errorText: 'Error',
  ),
),
SizedBox(height: 16),
UiTextField.standard(
  showCounter: true,
  maxLength: 10,
  style: UiTextFieldStyle(hintText: 'Text input'),
),
SizedBox(height: 16),
UiTextField.standard(
  showCounter: true,
  enabled: false,
  style: UiTextFieldStyle(
    hintText: 'Disabled',
  ),
),

This is what the above configurations look like:

Text Fields preview

You can find the complete code for the TextField component here.

Conclusion

In this article we have created two essential theme extensions - Color Palette and Typography. We also created Button and TextField components. Stay tuned for the next part!

Coming in Part Two

  1. Creating custom components
    1. Text and more
  2. Iconography
  3. Responsive Design
  4. Best Practices

Remember to check the full code with preview here:

GitHub - hawkkiller/sizzle_starter: Production ready template for flutter applications.
Production ready template for flutter applications. - hawkkiller/sizzle_starter