ashlarui

Button

Six variants and four sizes, with leading and trailing icons.

Button, as it looks in an app Button, as it looks in an app, dark
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/button.dart';

class ButtonDemo extends StatelessWidget {
  const ButtonDemo({super.key});

  @override
  Widget build(BuildContext context) => Wrap(
    spacing: 8,
    runSpacing: 8,
    alignment: WrapAlignment.center,
    crossAxisAlignment: WrapCrossAlignment.center,
    children: [
      Button(onPressed: () {}, child: const Text('Button')),
      Button(variant: ButtonVariant.secondary, onPressed: () {}, child: const Text('Secondary')),
      Button(variant: ButtonVariant.outline, onPressed: () {}, child: const Text('Outline')),
      Button(variant: ButtonVariant.ghost, onPressed: () {}, child: const Text('Ghost')),
      Button(variant: ButtonVariant.destructive, onPressed: () {}, child: const Text('Delete')),
      Button(variant: ButtonVariant.link, onPressed: () {}, child: const Text('Link')),
      Button(
        variant: ButtonVariant.outline,
        leading: const AshlarIcon(AshlarIconsRoundedStroke.plus),
        onPressed: () {},
        child: const Text('New project'),
      ),
      Button(
        size: ButtonSize.icon,
        variant: ButtonVariant.outline,
        semanticLabel: 'Settings',
        onPressed: () {},
        child: const AshlarIcon(AshlarIconsRoundedStroke.settings),
      ),
    ],
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Button with controls, as it looks in an app Button with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add button

It writes theme.dart, palette.dart, group.dart, pressable.dart and button.dart into lib/ui, keeping any you already have. It needs pressable and group, which come with it.

Examples

Other ways to use it, each running live, with its code.

Variants

Primary for the main action, then the rest in order of weight.

Variants, as it looks in an app Variants, as it looks in an app, dark
// Variants: Primary for the main action, then the rest in order of weight.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';

class ButtonVariantsExample extends StatelessWidget {
  const ButtonVariantsExample({super.key});

  @override
  Widget build(BuildContext context) => Wrap(
    spacing: 8,
    runSpacing: 8,
    children: [
      Button(onPressed: () {}, child: const Text('Save')),
      Button(variant: ButtonVariant.secondary, onPressed: () {}, child: const Text('Draft')),
      Button(variant: ButtonVariant.outline, onPressed: () {}, child: const Text('Preview')),
      Button(variant: ButtonVariant.ghost, onPressed: () {}, child: const Text('Cancel')),
      Button(variant: ButtonVariant.destructive, onPressed: () {}, child: const Text('Delete')),
      Button(variant: ButtonVariant.link, onPressed: () {}, child: const Text('Learn more')),
    ],
  );
}

Sizes

Extra small to large, and square ones for an icon alone.

Sizes, as it looks in an app Sizes, as it looks in an app, dark
// Sizes: Extra small to large, and square ones for an icon alone.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/button.dart';

class ButtonSizesExample extends StatelessWidget {
  const ButtonSizesExample({super.key});

  @override
  Widget build(BuildContext context) => Wrap(
    spacing: 8,
    runSpacing: 8,
    crossAxisAlignment: WrapCrossAlignment.center,
    children: [
      Button(size: ButtonSize.xs, onPressed: () {}, child: const Text('Extra small')),
      Button(size: ButtonSize.sm, onPressed: () {}, child: const Text('Small')),
      Button(onPressed: () {}, child: const Text('Medium')),
      Button(size: ButtonSize.lg, onPressed: () {}, child: const Text('Large')),
      for (final size in [ButtonSize.iconXs, ButtonSize.iconSm, ButtonSize.icon, ButtonSize.iconLg])
        Button(
          size: size,
          variant: ButtonVariant.outline,
          semanticLabel: 'Add',
          onPressed: () {},
          child: const AshlarIcon(AshlarIconsRoundedStroke.plus),
        ),
    ],
  );
}

With icons

An icon before or after the label.

With icons, as it looks in an app With icons, as it looks in an app, dark
// With icons: An icon before or after the label.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/button.dart';

class ButtonIconsExample extends StatelessWidget {
  const ButtonIconsExample({super.key});

  @override
  Widget build(BuildContext context) => Wrap(
    spacing: 8,
    children: [
      Button(
        leading: const AshlarIcon(AshlarIconsRoundedStroke.mail),
        onPressed: () {},
        child: const Text('Email me'),
      ),
      Button(
        variant: ButtonVariant.outline,
        trailing: const AshlarIcon(AshlarIconsRoundedStroke.arrowRight),
        onPressed: () {},
        child: const Text('Continue'),
      ),
    ],
  );
}

Disabled and wrong

Null onPressed disables it; error marks it.

Disabled and wrong, as it looks in an app Disabled and wrong, as it looks in an app, dark
// Disabled and wrong: Null onPressed disables it; error marks it.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';

class ButtonStatesExample extends StatelessWidget {
  const ButtonStatesExample({super.key});

  @override
  Widget build(BuildContext context) => Wrap(
    spacing: 8,
    children: [
      const Button(onPressed: null, child: Text('Disabled')),
      Button(variant: ButtonVariant.outline, error: true, onPressed: () {}, child: const Text('Fix the form')),
    ],
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

ButtonVariantenum

ValueWhat it is for
primaryThe main action on a screen.
secondaryA quieter action beside the main one.
outlineAn action with a border and no fill.
ghost An action with no border or fill until it is hovered: toolbars, menus.
destructiveAn action that deletes or cannot be undone.
linkText that acts like a link.

ButtonSizeenum

ValueWhat it is for
sm32 tall.
md36 tall.
lg40 tall.
iconA 36 square for one icon: give it a semanticLabel.
xs24 tall, for dense rows.
iconXsSquares for one icon: 24, 32 and 40.
iconSm
iconLg

Buttonclass

A button. child is usually a Text; leading and trailing an icon each side.

Parameter Type Default What it is for
onPressedrequired VoidCallback? Null disables the button.
childrequired Widget What it shows: usually a Text, an icon, or both.
variant ButtonVariant ButtonVariant.primary Its fill: primary for the main action, then secondary, outline, ghost, destructive or link.
size ButtonSize ButtonSize.md Its height and room, or a square for an icon alone.
leading Widget? Before the label: usually an icon.
trailing Widget? After the label: usually an icon.
focusNode FocusNode? Its focus node, to move focus to it or follow it from outside; one is made when not given.
autofocus bool false Takes the keyboard's focus when it first appears.
semanticLabel String? What a screen reader calls it when its visible text does not say, as for an icon alone.
expand bool false Fills the width it is given.
error bool false Draws it as invalid: a destructive border and ring, as on a field.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/button.dart 233 lines
// Ashlar UI: Button. Copied into your app by `ashlar add button`; it is yours to change.

import 'package:flutter/widgets.dart';

import 'group.dart';
import 'pressable.dart';
import 'theme.dart';

enum ButtonVariant {
  /// The main action on a screen.
  primary,

  /// A quieter action beside the main one.
  secondary,

  /// An action with a border and no fill.
  outline,

  /// An action with no border or fill until it is hovered: toolbars, menus.
  ghost,

  /// An action that deletes or cannot be undone.
  destructive,

  /// Text that acts like a link.
  link,
}

enum ButtonSize {
  /// 32 tall.
  sm,

  /// 36 tall.
  md,

  /// 40 tall.
  lg,

  /// A 36 square for one icon: give it a semanticLabel.
  icon,

  /// 24 tall, for dense rows.
  xs,

  /// Squares for one icon: 24, 32 and 40.
  iconXs,
  iconSm,
  iconLg,
}

/// A button. [child] is usually a Text; [leading] and [trailing] an icon each side.
///
/// ```dart
/// Button(onPressed: save, child: Text('Save'))
/// Button(variant: ButtonVariant.outline, leading: AshlarIcon(AshlarIconsRoundedStroke.plus), onPressed: add, child: Text('Add'))
/// Button(size: ButtonSize.icon, semanticLabel: 'Settings', onPressed: open, child: AshlarIcon(AshlarIconsRoundedStroke.settings))
/// ```
class Button extends StatelessWidget {
  const Button({
    super.key,
    required this.onPressed,
    required this.child,
    this.variant = ButtonVariant.primary,
    this.size = ButtonSize.md,
    this.leading,
    this.trailing,
    this.focusNode,
    this.autofocus = false,
    this.semanticLabel,
    this.expand = false,
    this.error = false,
  });

  /// Null disables the button.
  final VoidCallback? onPressed;

  /// What it shows: usually a Text, an icon, or both.
  final Widget child;

  /// Its fill: primary for the main action, then secondary, outline, ghost, destructive
  /// or link.
  final ButtonVariant variant;

  /// Its height and room, or a square for an icon alone.
  final ButtonSize size;

  /// Before the label: usually an icon.
  final Widget? leading;

  /// After the label: usually an icon.
  final Widget? trailing;

  /// Its focus node, to move focus to it or follow it from outside; one is made when not
  /// given.
  final FocusNode? focusNode;

  /// Takes the keyboard's focus when it first appears.
  final bool autofocus;

  /// What a screen reader calls it when its visible text does not say, as for an icon
  /// alone.
  final String? semanticLabel;

  /// Fills the width it is given.
  final bool expand;

  /// Draws it as invalid: a destructive border and ring, as on a field.
  final bool error;

  bool get _square =>
      size == ButtonSize.icon ||
      size == ButtonSize.iconXs ||
      size == ButtonSize.iconSm ||
      size == ButtonSize.iconLg;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Pressable(
      onPressed: onPressed,
      focusNode: focusNode,
      autofocus: autofocus,
      semanticLabel: semanticLabel,
      builder: (context, state) {
        final (Color bg, Color fg, Color? border) = switch (variant) {
          ButtonVariant.primary => (
            state.hovered ? c.primary.withValues(alpha: .9) : c.primary,
            c.primaryForeground,
            null,
          ),
          ButtonVariant.secondary => (
            state.hovered ? c.secondary.withValues(alpha: .8) : c.secondary,
            c.secondaryForeground,
            null,
          ),
          ButtonVariant.destructive => (
            state.hovered ? c.destructive.withValues(alpha: .9) : c.destructive,
            c.destructiveForeground,
            null,
          ),
          ButtonVariant.outline => (
            state.hovered ? c.accent : c.background,
            state.hovered ? c.accentForeground : c.foreground,
            c.input,
          ),
          ButtonVariant.ghost => (
            state.hovered ? c.accent : c.accent.withValues(alpha: 0),
            state.hovered ? c.accentForeground : c.foreground,
            null,
          ),
          ButtonVariant.link => (const Color(0x00000000), c.primary, null),
        };
        final (double standard, double padding, double gap) = switch (size) {
          ButtonSize.sm => (32.0, 12.0, 6.0),
          ButtonSize.md => (36.0, 16.0, 8.0),
          ButtonSize.lg => (40.0, 24.0, 8.0),
          ButtonSize.icon => (36.0, 0.0, 0.0),
          ButtonSize.xs => (24.0, 8.0, 4.0),
          ButtonSize.iconXs => (24.0, 0.0, 0.0),
          ButtonSize.iconSm => (32.0, 0.0, 0.0),
          ButtonSize.iconLg => (40.0, 0.0, 0.0),
        };
        // Taller or shorter with the theme's density.
        final height = t.control(standard);
        final label = DefaultTextStyle.merge(
          style: (size == ButtonSize.xs ? t.text.xs : t.text.sm).copyWith(
            color: fg,
            fontWeight: FontWeight.w500,
            decoration: variant == ButtonVariant.link && state.hovered
                ? TextDecoration.underline
                : null,
            decorationColor: fg,
          ),
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          child: IconTheme.merge(
            data: IconThemeData(color: fg, size: 16),
            // The row sets the height and centres in it; the button hugs it unless [expand].
            // A height at least: larger text makes it taller rather than being cut off.
            child: ConstrainedBox(
              constraints: BoxConstraints(
                minHeight: height,
                minWidth: _square ? height : 0,
                maxWidth: _square ? height : double.infinity,
              ),
              child: Row(
                mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  if (leading != null) ...[leading!, SizedBox(width: gap)],
                  Flexible(child: child),
                  if (trailing != null) ...[SizedBox(width: gap), trailing!],
                ],
              ),
            ),
          ),
        );
        return Opacity(
          opacity: state.enabled ? 1 : .5,
          child: AnimatedContainer(
            duration: t.duration,
            padding: EdgeInsets.symmetric(horizontal: padding),
            decoration: BoxDecoration(
              color: bg,
              borderRadius: joinedRadius(context, t.radiusMd),
              border: error
                  ? joinedBorder(context, c.destructive)
                  : border == null
                  ? null
                  : joinedBorder(context, state.focused ? c.ring : border),
              boxShadow: [
                if (state.focused)
                  error
                      ? BoxShadow(
                          color: c.destructive.withValues(alpha: .2),
                          spreadRadius: 3,
                        )
                      : t.focusRing.first,
                if (variant == ButtonVariant.outline ||
                    variant == ButtonVariant.primary ||
                    variant == ButtonVariant.secondary ||
                    variant == ButtonVariant.destructive)
                  ...t.shadowXs,
              ],
            ),
            child: label,
          ),
        );
      },
    );
  }
}