Fliin
On this page

Components / overlays

Modal dialog

Asks for a decision or a confirmation before going on, over a dimmed page. Title, scrolling text, actions, close button, swipe down to dismiss, trapped focus, Escape and focus returned on close.

16 of 16 variants pass
Modal dialog, open state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add dialogAlso installs: core, button, close-button

Examples

Open

showFlDialog pushes a modal dialog over the page: a title, a message, and the actions that close it. The close button and the barrier dismiss it too.

Modal dialog, open state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/dialog/fl_dialog.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlButton(
      label: 'Open Dialog',
      onPressed: () => showFlDialog<void>(
        context: context,
        barrierLabel: 'Close',
        builder: (context) => const ConfirmActionDialog(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return FlDialog(
      title: 'Confirm Action',
      closeLabel: 'Close',
      content: const Text('Are you sure you want to proceed with this action? This cannot be undone.'),
      actions: [
        FlButton(
          label: 'Cancel',
          variant: FlButtonVariant.ghost,
          size: FlButtonSize.sm,
          onPressed: () => Navigator.pop(context),
        ),
        FlButton(label: 'Confirm', size: FlButtonSize.sm, onPressed: () => Navigator.pop(context)),
      ],
    );
  }
}

Icon

An icon in a soft disc above the title, and stacked actions across the panel, the main one first: the welcome or the permission request of a phone app.

Modal dialog, icon state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/dialog/fl_dialog.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlButton(
      label: 'Open Dialog',
      variant: FlButtonVariant.secondary,
      onPressed: () => showFlDialog<void>(
        context: context,
        barrierLabel: 'Close',
        builder: (context) => const NotificationsDialog(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return FlDialog(
      icon: FlIcon(FlIcons.bell),
      iconTone: FlDialogIconTone.accent,
      title: 'Turn on notifications',
      closeLabel: 'Close',
      content: const Text('Get a message when your order ships and when it is about to arrive.'),
      actionsLayout: FlDialogActionsLayout.stacked,
      actions: [
        FlButton(label: 'Turn on', onPressed: () => Navigator.pop(context)),
        FlButton(label: 'Not now', variant: FlButtonVariant.ghost, onPressed: () => Navigator.pop(context)),
      ],
    );
  }
}

Status

iconTone colors the disc after the status: accent, success, warning or danger. Here, a task about to be marked complete.

Modal dialog, status state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/dialog/fl_dialog.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlButton(
      label: 'Complete Task',
      variant: FlButtonVariant.secondary,
      onPressed: () =>
          showFlDialog<void>(context: context, barrierLabel: 'Close', builder: (context) => const CompleteTaskDialog()),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return FlDialog(
      icon: FlIcon(FlIcons.circleCheck),
      iconTone: FlDialogIconTone.success,
      title: 'Complete this task?',
      closeLabel: 'Close',
      content: const Text('All team members are notified, and the task moves to your completed list.'),
      actions: [
        FlButton(
          label: 'Not yet',
          variant: FlButtonVariant.ghost,
          size: FlButtonSize.sm,
          onPressed: () => Navigator.pop(context),
        ),
        FlButton(label: 'Mark complete', size: FlButtonSize.sm, onPressed: () => Navigator.pop(context)),
      ],
    );
  }
}

Alert

alert: true makes a dialog the user must answer: no close button, and the danger action names what it destroys. For a deletion or a loss.

Modal dialog, alert state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/dialog/fl_dialog.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlButton(
      label: 'Open Dialog',
      onPressed: () => showFlDialog<void>(
        context: context,
        barrierLabel: 'Close',
        builder: (context) => const DeleteFileDialog(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return FlDialog(
      alert: true,
      title: 'Delete this file?',
      content: const Text('It moves to the trash for 30 days.'),
      actions: [
        FlButton(
          label: 'Cancel',
          variant: FlButtonVariant.ghost,
          size: FlButtonSize.sm,
          onPressed: () => Navigator.pop(context),
        ),
        FlButton(
          label: 'Delete',
          variant: FlButtonVariant.danger,
          size: FlButtonSize.sm,
          onPressed: () => Navigator.pop(context),
        ),
      ],
    );
  }
}

Form

A form as content: it scrolls between the title and the actions, which stay in place, and the panel moves above the keyboard.

Modal dialog, form state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/dialog/fl_dialog.dart';
import 'package:your_app/ui/fliin/components/text_field/fl_text_field.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlButton(
      label: 'Open Contact Form',
      variant: FlButtonVariant.secondary,
      onPressed: () =>
          showFlDialog<void>(context: context, barrierLabel: 'Close', builder: (context) => const ContactDialog()),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return FlDialog(
      icon: FlIcon(FlIcons.envelope),
      title: 'Contact us',
      closeLabel: 'Close',
      content: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        spacing: space.lg,
        children: const [
          Text('Leave your details and we will get back to you within a day.'),
          FlTextField(label: 'Name', placeholder: 'Enter your name'),
          FlTextField(label: 'Email', placeholder: 'Enter your email', keyboardType: TextInputType.emailAddress),
        ],
      ),
      actions: [
        FlButton(
          label: 'Cancel',
          variant: FlButtonVariant.ghost,
          size: FlButtonSize.sm,
          onPressed: () => Navigator.pop(context),
        ),
        FlButton(label: 'Send', size: FlButtonSize.sm, onPressed: () => Navigator.pop(context)),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = Navigator(
  onGenerateInitialRoutes: (navigator, _) => [
    PageRouteBuilder<void>(
      pageBuilder: (context, _, _) => ColoredBox(
        color: FlScope.of(context).colors.surface,
        child: const SizedBox.expand(),
      ),
    ),
    FlDialogRoute<void>(
      context: context,
      barrierLabel: 'Close',
      builder: (context) => FlDialog(
        title: 'Delete the file?',
        closeLabel: 'Close',
        content: Text(
          'The file will stay in the trash for 30 days, then be erased from all your devices.',
        ),
        actions: [
          FlButton(
            label: 'Cancel',
            variant: FlButtonVariant.ghost,
            size: FlButtonSize.sm,
            onPressed: () => Navigator.pop(context),
          ),
          FlButton(
            label: 'Delete',
            size: FlButtonSize.sm,
            onPressed: () => Navigator.pop(context),
          ),
        ],
      ),
    ),
  ],
);

API

showFlDialog: Future<T?><T>({required BuildContext context, required WidgetBuilder builder, bool dismissible, String? barrierLabel, bool useRootNavigator})
FlDialogRoute: class<T> extends RawDialogRoute<T>
FlDialogRoute.new: new({required BuildContext context, required WidgetBuilder builder, bool dismissible, super.barrierLabel, super.settings})
FlDialogRoute.dismissible: get bool
FlDialogRoute.reverseTransitionDuration: get Duration
FlDialogRoute.popDisposition: get RoutePopDisposition
FlDialogRoute.install: void()
FlDialogRoute.dispose: void()
FlDialogRoute.createAnimationController: AnimationController()
FlDialogRoute.buildModalBarrier: Widget()
FlDialogRoute.buildTransitions: Widget(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child)
FlDialogIconTone: enum {neutral, danger, accent, success, warning}
FlDialogIconTone.neutral: static final FlDialogIconTone
FlDialogIconTone.danger: static final FlDialogIconTone
FlDialogIconTone.accent: static final FlDialogIconTone
FlDialogIconTone.success: static final FlDialogIconTone
FlDialogIconTone.warning: static final FlDialogIconTone
FlDialogActionsLayout: enum {end, stacked}
FlDialogActionsLayout.end: static final FlDialogActionsLayout
FlDialogActionsLayout.stacked: static final FlDialogActionsLayout
FlDialog: class extends StatefulWidget
FlDialog.new: const new({super.key, required String title, Widget? content, List<Widget> actions, String? closeLabel, bool alert, Widget? icon, FlDialogIconTone iconTone, FlDialogActionsLayout actionsLayout})
FlDialog.icon: get Widget?
FlDialog.iconTone: get FlDialogIconTone
FlDialog.title: get String
FlDialog.content: get Widget?
FlDialog.actions: get List<Widget>
FlDialog.actionsLayout: get FlDialogActionsLayout
FlDialog.closeLabel: get String?
FlDialog.alert: get bool
FlDialog.createState: State<FlDialog>()

Reference renders

The 16 images published with version 0.2.4, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify dialog compares your copy to them.

iOS · Light
Modal dialog, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Modal dialog, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Modal dialog, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Modal dialog, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Modal dialog, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Modal dialog, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Modal dialog, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Modal dialog, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Modal dialog, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Modal dialog, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Modal dialog, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Modal dialog, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Modal dialog, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Modal dialog, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Modal dialog, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Modal dialog, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • confirm a deletion
  • ask for a decision before continuing
  • open a modal dialog
  • show a blocking alert
  • keep focus inside a modal window
Not for
  • anchored content that does not block the page: use a popover (popover)
  • a list of actions: use a menu (menu)
  • a hint on hover: use a tooltip (tooltip)
States
  • Open
  • Alert

Source

The files fliin add dialog copies into your app, before your prefix and imports are applied.

dialog/fl_dialog.dart

// Modified from the Dialog of HeroUI Native 1.0.10, Copyright 2025 NextUI Inc.,
// licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'dart:ui' show SemanticsRole;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/close_button/fl_close_button.dart';
import 'package:fliin_ui/core/fliin.dart';

/// Opens [builder], usually a [FlDialog], in a [FlDialogRoute] and returns
/// the value passed to `Navigator.pop`. The root navigator by default: the
/// barrier then covers the tab bars of a nested navigator.
///
/// [dismissible]: Escape, Android back and swiping down close the dialog
/// (true by default, as WAI-ARIA requires, alerts included).
/// [barrierLabel]: the barrier closes it on tap too, and is announced under
/// that name; it requires [dismissible]. A dialog that does not close must
/// have an action.
Future<T?> showFlDialog<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  bool dismissible = true,
  String? barrierLabel,
  bool useRootNavigator = true,
}) => Navigator.of(
  context,
  rootNavigator: useRootNavigator,
).push(FlDialogRoute<T>(context: context, builder: builder, dismissible: dismissible, barrierLabel: barrierLabel));

/// A modal route from `widgets`, without material, set up by `FlPopup`: the
/// opaque barrier, which hides the page from screen readers, and the
/// dialog entrance, which settles from 1.04 to 1 as it appears
/// (`motion.duration.short4`) and shrinks on exit (`short3`). Under reduced
/// motion, it fades in place, with the same duration. The route's focus
/// scope keeps Tab inside the dialog and returns the focus to whatever had
/// it when it closes.
class FlDialogRoute<T> extends RawDialogRoute<T> {
  /// Reads the motion from [context] at creation; [dismissible] and
  /// [barrierLabel] follow the rules of [showFlDialog].
  FlDialogRoute({
    required BuildContext context,
    required WidgetBuilder builder,
    this.dismissible = true,
    super.barrierLabel,
    super.settings,
  }) : assert(dismissible || barrierLabel == null, 'a barrier that closes requires a dismissible dialog'),
       _motion = FlScope.of(context).motion,
       super(
         pageBuilder: (context, _, _) => builder(context),
         barrierDismissible: dismissible,
         barrierColor: null,
         transitionDuration: FlScope.of(context).motion.short4,
       );

  /// Escape, Android back and swiping down close it.
  final bool dismissible;
  final FlMotionTokens _motion;

  /// 1 at rest; swiping down moves it toward 0, and the barrier lightens
  /// by as much.
  late final AnimationController _presence;

  @override
  Duration get reverseTransitionDuration => _motion.short3;

  @override
  RoutePopDisposition get popDisposition => dismissible ? super.popDisposition : RoutePopDisposition.doNotPop;

  @override
  void install() {
    super.install();
    _presence = AnimationController(vsync: navigator!, value: 1);
  }

  @override
  void dispose() {
    _presence.dispose();
    super.dispose();
  }

  @override
  AnimationController createAnimationController() => flPopupController(vsync: navigator!, motion: _motion);

  /// The `FlPopup` barrier, which closes on tap only if it has a name;
  /// Escape follows [dismissible] through `barrierDismissible`, like back.
  @override
  Widget buildModalBarrier() => BlockSemantics(
    child: FlPopupOverlay(
      animation: _Product(animation!, _presence),
      onDismiss: barrierLabel == null ? null : () => navigator?.maybePop(),
      dismissLabel: barrierLabel,
    ),
  );

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) => FlPopupContent(animation: animation, entrance: FlPopupEntrance.dialog, child: child);
}

/// The barrier follows the route's opening and the dialog's swipe.
class _Product extends CompoundAnimation<double> {
  _Product(Animation<double> first, Animation<double> next) : super(first: first, next: next);

  @override
  double get value => first.value * next.value;
}

/// The disc behind an [FlDialog]'s icon.
enum FlDialogIconTone {
  /// `control` disc, `onSurfaceMuted` icon.
  neutral,

  /// `dangerSoft` disc, `onDangerSoft` icon: a destructive confirmation.
  danger,

  /// `accentSoft` disc, `accent` icon: an invitation or a piece of news.
  accent,

  /// `successSoft` disc, `onSuccessSoft` icon: a task about to be completed.
  success,

  /// `warningSoft` disc, `onWarningSoft` icon: changes about to be lost.
  warning,
}

/// How an [FlDialog] lays out its actions.
enum FlDialogActionsLayout {
  /// On one line at the end, in the order given (the main action last), and
  /// stacked when they do not fit: a confirmation.
  end,

  /// Each one across the panel's width, one under the other, in the order
  /// given (the main action first): the welcome
  /// or the form of a phone.
  stacked,
}

/// The panel of a dialog: `surfaceOverlay`
/// background, `overlay` shadow, continuous corners of `shape.radiusCard`.
/// [title], with a close button named [closeLabel] at the end of its line,
/// then [content], in a single scrolling area, with a scrollbar visible when it scrolls
/// (text at 2.0×, keyboard open), then [actions], fixed at the bottom,
/// aligned to the end and stacked when they do not fit on one line. It
/// stays clear of notches, system bars and the keyboard, at most
/// `size.dialogMaxWidth` wide.
///
/// In a route that closes, swipe it down to close it: released after a tenth of the travel to the bottom of the
/// screen, it closes; before that, it springs back. No swipe under reduced
/// motion.
///
/// It announces itself as a dialog (or an alert with [alert]) named by
/// [title], which carries and names its route.
class FlDialog extends StatefulWidget {
  /// Creates a dialog panel. Without [actions], its route must let it close
  /// through Escape and back: an assertion checks this in debug.
  const FlDialog({
    super.key,
    required this.title,
    this.content,
    this.actions = const [],
    this.closeLabel,
    this.alert = false,
    this.icon,
    this.iconTone = FlDialogIconTone.neutral,
    this.actionsLayout = FlDialogActionsLayout.end,
  });

  /// An icon above the title, in a disc of `space.xxl + space.sm` (40 with
  /// the default tokens), Izydesk's confirmation of a deletion; decorative,
  /// never read. When null, none.
  final Widget? icon;

  /// The disc's colors: neutral by default, `danger` for a deletion,
  /// `accent`, `success` or `warning` for the other statuses.
  final FlDialogIconTone iconTone;

  /// The title at the top of the panel, in medium-weight `textLg`
  /// (`Dialog.Title`), which also names the dialog for screen readers.
  final String title;

  /// The body under the title, in `textBase` and `onSurfaceMuted` by default
  /// (`Dialog.Description`), which scrolls with it. When null: the title alone.
  final Widget? content;

  /// The bottom buttons, outside the scrolling area, usually `sm`
  /// `FlButton`s. Empty by default.
  final List<Widget> actions;

  /// On one line at the end by default, or [FlDialogActionsLayout.stacked]
  /// across the panel's width.
  final FlDialogActionsLayout actionsLayout;

  /// The name of the close button (`Dialog.Close`), placed at the top, at
  /// the end of the line; when null, no button. It requires a route that closes.
  final String? closeLabel;

  /// True: announced as an alert (`alertDialog`), for a message that awaits
  /// an answer. False by default: a dialog (`dialog`).
  final bool alert;

  @override
  State<FlDialog> createState() => _FlDialogState();
}

class _FlDialogState extends State<FlDialog> {
  final ScrollController _scroll = ScrollController();
  final GlobalKey _panel = GlobalKey();

  /// The swipe travel: from the top of the panel to the bottom of the screen.
  double _travel = 1;

  Animation<double>? _entrance;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _entrance?.removeStatusListener(_settled);
    _entrance = ModalRoute.of(context)?.animation?..addStatusListener(_settled);
  }

  /// The entrance scales the panel over the page's paint boundary, which is
  /// not repainted: once settled, it repaints once so that `FlPixelSnap`
  /// aligns it to the pixels at scale 1, not at 0.96.
  void _settled(AnimationStatus status) {
    if (status.isCompleted) {
      _panel.currentContext?.findRenderObject()?.markNeedsPaint();
    }
  }

  @override
  void dispose() {
    _entrance?.removeStatusListener(_settled);
    _scroll.dispose();
    super.dispose();
  }

  FlDialogRoute<Object?>? get _route => switch (ModalRoute.of(context)) {
    final FlDialogRoute<Object?> route => route,
    _ => null,
  };

  void _dragStart(DragStartDetails details) {
    final box = _panel.currentContext!.findRenderObject()! as RenderBox;
    final top = box.localToGlobal(Offset.zero).dy;
    _travel = MediaQuery.sizeOf(context).height - top;
    _route!._presence.stop();
  }

  void _dragUpdate(DragUpdateDetails details) {
    final presence = _route!._presence;
    presence.value = (presence.value - details.primaryDelta! / _travel).clamp(0.0, 1.0);
  }

  void _dragEnd(DragEndDetails details) {
    final route = _route!;
    if (1 - route._presence.value > 0.1) {
      route.navigator?.maybePop();
      return;
    }
    route._presence.driveTo(1, FlMotion.of(context).spring(FlSpring.release));
  }

  @override
  Widget build(BuildContext context) {
    final route = _route;
    assert(
      route == null || route.dismissible || widget.actions.isNotEmpty,
      'a dialog that closes neither through Escape nor through back must have an action',
    );
    assert(
      route == null || route.dismissible || widget.closeLabel == null,
      'a close button requires a dismissible dialog',
    );
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final content = widget.content;
    final closeLabel = widget.closeLabel;
    final swipeable = route != null && route.dismissible && !FlMotion.of(context).reduced;
    final radius = theme.shape.radiusCard;
    // Izydesk's dialog sits 16 from the screen's edges.
    final margin = theme.space.lg;
    // Izydesk's dialog: 24 inside, 16 above the actions. The title and its
    // body go together, 8 apart, under an icon 12 above them.
    final padding = theme.space.xl;
    final gap = theme.space.lg;
    Widget panel = FlPixelSnap(
      key: _panel,
      child: FlShadow(
        layers: theme.shadows.overlay,
        // React Native shadows a view based on its circular corners (FlSurface).
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)),
        child: DecoratedBox(
          decoration: ShapeDecoration(
            color: colors.surfaceOverlay,
            shape: FlContinuousBorder(radius: radius),
          ),
          child: Padding(
            padding: EdgeInsetsDirectional.all(padding),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Flexible(
                  child: Stack(
                    clipBehavior: Clip.none,
                    children: [
                      RawScrollbar(
                        controller: _scroll,
                        thumbVisibility: true,
                        thickness: theme.size.indicatorStroke,
                        radius: Radius.circular(theme.size.indicatorStroke),
                        thumbColor: colors.onSurfaceMuted,
                        child: SingleChildScrollView(
                          controller: _scroll,
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.stretch,
                            children: [
                              if (widget.icon case final icon?)
                                Padding(
                                  padding: EdgeInsetsDirectional.only(bottom: theme.space.md),
                                  child: Align(
                                    alignment: AlignmentDirectional.centerStart,
                                    child: ExcludeSemantics(
                                      child: SizedBox.square(
                                        dimension: theme.space.xxl + theme.space.sm,
                                        child: DecoratedBox(
                                          decoration: ShapeDecoration(
                                            // A disc by default, square under a
                                            // square preset, like an avatar.
                                            shape: FlContinuousBorder(radius: theme.shape.radiusButtonLarge),
                                            color: switch (widget.iconTone) {
                                              FlDialogIconTone.neutral => colors.control,
                                              FlDialogIconTone.danger => colors.dangerSoft,
                                              FlDialogIconTone.accent => colors.accentSoft,
                                              FlDialogIconTone.success => colors.successSoft,
                                              FlDialogIconTone.warning => colors.warningSoft,
                                            },
                                          ),
                                          child: Center(
                                            child: IconTheme.merge(
                                              data: IconThemeData(
                                                size: theme.space.lg + theme.space.xs,
                                                color: switch (widget.iconTone) {
                                                  FlDialogIconTone.neutral => colors.onSurfaceMuted,
                                                  FlDialogIconTone.danger => colors.onDangerSoft,
                                                  FlDialogIconTone.accent => colors.accent,
                                                  FlDialogIconTone.success => colors.onSuccessSoft,
                                                  FlDialogIconTone.warning => colors.onWarningSoft,
                                                },
                                              ),
                                              child: icon,
                                            ),
                                          ),
                                        ),
                                      ),
                                    ),
                                  ),
                                ),
                              ExcludeSemantics(
                                child: Padding(
                                  // The title stops before the close button,
                                  // `space.md` away.
                                  padding: EdgeInsetsDirectional.only(
                                    end: closeLabel == null
                                        ? 0
                                        : theme.size.closeButton + theme.space.md - theme.space.sm,
                                  ),
                                  child: _text(
                                    context,
                                    FlTextRole.textLg,
                                    color: colors.onSurface,
                                    weight: theme.tokens.typography.weight.medium,
                                    heading: true,
                                    child: Text(
                                      widget.title,
                                      strutStyle: theme.typography.strut(
                                        FlTextRole.textLg,
                                        inheritedFamily: DefaultTextStyle.of(context).style.fontFamily,
                                        heading: true,
                                      ),
                                    ),
                                  ),
                                ),
                              ),
                              if (content != null)
                                Padding(
                                  padding: EdgeInsetsDirectional.only(top: theme.space.sm),
                                  child: _text(
                                    context,
                                    FlTextRole.textBase,
                                    color: colors.onSurfaceMuted,
                                    child: content,
                                  ),
                                ),
                            ],
                          ),
                        ),
                      ),
                      // On the title's line, outside the scrolling view (which
                      // would clip its touch target), drawn into the corner
                      // as in Izydesk's dialog.
                      if (closeLabel != null)
                        PositionedDirectional(
                          top: -theme.space.xs,
                          end: -theme.space.sm,
                          child: FlCloseButton(label: closeLabel, onPressed: () => Navigator.maybePop(context)),
                        ),
                    ],
                  ),
                ),
                if (widget.actions.isNotEmpty)
                  Padding(
                    padding: EdgeInsetsDirectional.only(top: gap),
                    child: switch (widget.actionsLayout) {
                      FlDialogActionsLayout.end => OverflowBar(
                        alignment: MainAxisAlignment.end,
                        overflowAlignment: OverflowBarAlignment.end,
                        spacing: theme.space.sm,
                        overflowSpacing: theme.space.sm,
                        children: widget.actions,
                      ),
                      FlDialogActionsLayout.stacked => Column(
                        mainAxisSize: MainAxisSize.min,
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        spacing: theme.space.sm,
                        children: widget.actions,
                      ),
                    },
                  ),
              ],
            ),
          ),
        ),
      ),
    );
    if (route != null) {
      panel = AnimatedBuilder(
        animation: route._presence,
        builder: (context, child) {
          final away = 1 - route._presence.value;
          return Transform.translate(
            offset: Offset(0, away * _travel),
            child: Transform.scale(scale: 1 - 0.05 * away, child: child),
          );
        },
        child: GestureDetector(
          excludeFromSemantics: true,
          onVerticalDragStart: swipeable ? _dragStart : null,
          onVerticalDragUpdate: swipeable ? _dragUpdate : null,
          onVerticalDragEnd: swipeable ? _dragEnd : null,
          child: panel,
        ),
      );
    }
    return LayoutBuilder(
      builder: (context, constraints) => Stack(
        children: [
          Positioned.fromRect(
            rect: flAnchorBounds(
              overlay: constraints.biggest,
              padding: MediaQuery.paddingOf(context),
              viewInsets: MediaQuery.viewInsetsOf(context),
              margin: margin,
            ),
            child: Center(
              child: ConstrainedBox(
                constraints: BoxConstraints(maxWidth: theme.size.dialogMaxWidth),
                child: Semantics(
                  container: true,
                  explicitChildNodes: true,
                  scopesRoute: true,
                  namesRoute: true,
                  role: widget.alert ? SemanticsRole.alertDialog : SemanticsRole.dialog,
                  label: widget.title,
                  child: panel,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// [child] in the role [role]: the role's line height forced on the system
/// font (see FlText), as a minimum for a named font.
Widget _text(
  BuildContext context,
  FlTextRole role, {
  required Color color,
  FontWeight? weight,
  bool? heading,
  required Widget child,
}) {
  final scale = FlScope.of(context).typography;
  final metrics = scale.style(role);
  final style = scale.resolve(role, heading: heading).copyWith(color: color, fontWeight: weight);
  return DefaultTextStyle.merge(
    style: scale.familyOf(role, heading: heading) == null
        ? style.copyWith(height: metrics.lineHeight / metrics.size, leadingDistribution: TextLeadingDistribution.even)
        : style,
    child: child,
  );
}

Arrows to move, Enter to open, Escape to close.