Fliin
On this page

Components / navigation

Drawer

Opens a navigation panel from the side of the screen over a scrim, mirrored in right-to-left layouts, and closes by swipe, tap outside, Escape or back.

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

Import

In a Flutter app where fliin init has run:

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

Examples

Open

showFlDrawer slides a navigation panel in from the start edge, over a barrier that closes it; the panel takes a title, its links and actions at the bottom.

Drawer, open state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/drawer/fl_drawer.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlButton(
      label: 'Menu',
      variant: FlButtonVariant.secondary,
      onPressed: () => showNavigationDrawer(context),
    );
  }
}

Future<void> showNavigationDrawer(BuildContext context) {
  return showFlDrawer<void>(
    context: context,
    barrierLabel: 'Close menu',
    builder: (context) => FlDrawer(
      title: 'Navigation',
      closeLabel: 'Close',
      actions: [
        FlButton(
          label: 'Sign out',
          size: FlButtonSize.sm,
          variant: FlButtonVariant.tertiary,
          onPressed: () => Navigator.pop(context),
        ),
      ],
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          for (final label in const ['Home', 'Orders', 'Messages', 'Settings'])
            FlButton(label: label, variant: FlButtonVariant.ghost, onPressed: () => Navigator.pop(context)),
        ],
      ),
    ),
  );
}

Usage

The code the reference images render:

Navigator(onGenerateInitialRoutes: (navigator, _) => [PageRouteBuilder<void>(pageBuilder: (context, _, _) => ColoredBox(color: FlScope.of(context).colors.surface, child: const SizedBox.expand())), FlDrawerRoute<void>(context: context, barrierLabel: 'Close the menu', builder: (context) => FlDrawer(title: 'Navigation', closeLabel: 'Close', actions: [FlButton(label: 'Sign out', size: FlButtonSize.sm, onPressed: () => Navigator.pop(context))], child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: FlScope.of(context).space.lg, children: [for (final (fr, ar) in const [('Home', 'الرئيسية'), ('Orders', 'الطلبات'), ('Messages', 'الرسائل'), ('Settings', 'الإعدادات')]) Textfr, style: TextStyle(color: FlScope.of(context).colors.onSurface))])))])

API

FlDrawerSide: enum {start, end}
FlDrawerSide.start: static final FlDrawerSide
FlDrawerSide.end: static final FlDrawerSide
showFlDrawer: Future<T?><T>({required BuildContext context, required WidgetBuilder builder, required String barrierLabel, FlDrawerSide side, bool useRootNavigator})
FlDrawerRoute: class<T> extends RawDialogRoute<T>
FlDrawerRoute.new: new({required BuildContext context, required WidgetBuilder builder, required String barrierLabel, FlDrawerSide side, super.settings})
FlDrawerRoute.side: get FlDrawerSide
FlDrawerRoute.reverseTransitionDuration: get Duration
FlDrawerRoute.install: void()
FlDrawerRoute.dispose: void()
FlDrawerRoute.createAnimationController: AnimationController()
FlDrawerRoute.buildModalBarrier: Widget()
FlDrawerRoute.buildTransitions: Widget(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child)
FlDrawer: class extends StatefulWidget
FlDrawer.new: const new({super.key, required String title, required Widget child, List<Widget> actions, String? closeLabel})
FlDrawer.title: get String
FlDrawer.child: get Widget
FlDrawer.actions: get List<Widget>
FlDrawer.closeLabel: get String?
FlDrawer.createState: State<FlDrawer>()

Reference renders

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

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

Use it to

  • open the main menu from the side
  • add a side navigation drawer that slides in from the left
  • drag the side menu away to close it
  • show the main destinations in a drawer
  • add a hamburger menu
Not for
  • a sheet anchored at the bottom: use a bottom sheet (sheet)
  • a question awaiting an answer: use a dialog (dialog)
  • main navigation always visible: use a tab bar (tab-bar)
States
  • Open

Source

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

drawer/fl_drawer.dart

// Modified from the Drawer of HeroUI React v3, Copyright 2020 Next UI Inc.,
// licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the barrier, close button and colors of HeroUI Native
// 1.0.10, Copyright 2025 NextUI Inc., licensed under Apache 2.0.
import 'dart:async' show unawaited;
import 'dart:math' as math;
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';

// drawer.css: `--drawer-enter-ease` and `--drawer-exit-ease`.
const Curve _ease = Cubic(0.32, 0.72, 0, 1);

// drawer.css: `w-80 max-w-[85vw] sm:w-96`.
const double _narrowWidth = 320;
const double _wideWidth = 384;
const double _wideViewport = 640;
const double _maxWidthFraction = 0.85;

// drawer.tsx: closed beyond 30 % of the width, or flung faster than
// 0.5 px/ms.
const double _dismissFraction = 0.3;
const double _dismissVelocity = 500;

/// The side of the screen a [FlDrawer] comes from.
enum FlDrawerSide {
  /// The start of the line: left, right in RTL. Android's navigation
  /// drawer.
  start,

  /// The end of the line: right, left in RTL.
  end,
}

/// Opens [builder], a [FlDrawer], in a [FlDrawerRoute] from [side], and
/// returns the value passed to `Navigator.pop`. The root navigator by
/// default, like `showFlDialog`.
Future<T?> showFlDrawer<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  required String barrierLabel,
  FlDrawerSide side = FlDrawerSide.start,
  bool useRootNavigator = true,
}) => Navigator.of(
  context,
  rootNavigator: useRootNavigator,
).push(FlDrawerRoute<T>(context: context, builder: builder, barrierLabel: barrierLabel, side: side));

/// A modal route from `widgets` that places a side panel over the `FlPopup`
/// barrier. The panel enters from [side] over `motion.duration.medium1` and
/// exits over `short4`, along a decelerating curve; under reduced motion, it
/// fades in and out in place, with the same durations.
///
/// It closes through the barrier (named [barrierLabel]), Escape, system
/// back, and by swiping it toward its edge: released beyond 30 % of its
/// width or flung, it exits; before that, it returns on the `release`
/// spring. The route's focus scope keeps Tab inside the panel and returns
/// the focus to whatever had it when it closes. The builder's [FlDrawer]
/// places the panel and makes it slide: another widget would not slide.
class FlDrawerRoute<T> extends RawDialogRoute<T> {
  /// Reads the motion from [context] at creation.
  FlDrawerRoute({
    required BuildContext context,
    required WidgetBuilder builder,
    required String super.barrierLabel,
    this.side = FlDrawerSide.start,
    super.settings,
  }) : _motion = FlScope.of(context).motion,
       super(
         pageBuilder: (context, _, _) => builder(context),
         barrierDismissible: true,
         barrierColor: null,
         transitionDuration: FlScope.of(context).motion.medium1,
       );

  /// The side the panel comes from.
  final FlDrawerSide side;
  final FlMotionTokens _motion;

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

  /// The opening along the drawer's curve, minus the swipe: the panel's
  /// position and the barrier's opacity. Created once, with the route.
  late final CurvedAnimation _open;
  late final Animation<double> _presence;

  @override
  Duration get reverseTransitionDuration => _motion.short4;

  @override
  void install() {
    super.install();
    _drag = AnimationController(vsync: navigator!);
    _open = CurvedAnimation(parent: animation!, curve: _ease, reverseCurve: _ease.flipped);
    _presence = _Presence(_open, _drag);
  }

  @override
  void dispose() {
    _open.dispose();
    _drag.dispose();
    super.dispose();
  }

  @override
  AnimationController createAnimationController() => AnimationController(
    vsync: navigator!,
    duration: transitionDuration,
    reverseDuration: reverseTransitionDuration,
    animationBehavior: AnimationBehavior.preserve,
  );

  @override
  Widget buildModalBarrier() => BlockSemantics(
    child: FlPopupOverlay(animation: _presence, onDismiss: () => navigator?.maybePop(), dismissLabel: barrierLabel),
  );

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    if (FlMotion.of(context).reduced) {
      return FadeTransition(opacity: _presence, child: child);
    }
    return child;
  }
}

/// The panel's presence: the route's opening, minus the swipe.
class _Presence extends CompoundAnimation<double> {
  _Presence(Animation<double> open, Animation<double> drag) : super(first: open, next: drag);

  @override
  double get value => first.value * (1 - next.value);
}

/// Shifts the panel outward by [away] times its painted width, to the left
/// when [left].
class _Slide extends StatelessWidget {
  const _Slide({required this.away, required this.left, required this.child});

  final double away;
  final bool left;
  final Widget child;

  @override
  Widget build(BuildContext context) =>
      FractionalTranslation(translation: Offset(left ? -away : away, 0), child: child);
}

/// The panel of a drawer: `surfaceOverlay` background, `overlay` shadow, the edge that
/// faces the page rounded at `shape.radiusSheet`, full height, 320 wide (384
/// on a screen of 640 and more), never more than 85 % of the screen.
/// `space.xl` padding above and below, 20 at the sides, plus the safe area.
/// [title] at the top, in medium-weight `textLg`; [child] below, in
/// `textBase`, in a scrolling area; [actions] at the bottom, aligned to the
/// start. A close button at the end of the title's line with [closeLabel].
///
/// It announces itself as a dialog named by [title], which carries and
/// names its route. It sits in a [FlDrawerRoute], through [showFlDrawer].
class FlDrawer extends StatefulWidget {
  /// A [title] panel.
  const FlDrawer({super.key, required this.title, required this.child, this.actions = const [], this.closeLabel});

  /// The title (`Drawer.Heading`), which also names the panel for the
  /// screen reader.
  final String title;

  /// The content (`Drawer.Body`): navigation links, settings, filters. It
  /// scrolls when it overflows.
  final Widget child;

  /// The bottom buttons (`Drawer.Footer`), usually `FlButton`s. Empty by
  /// default.
  final List<Widget> actions;

  /// The name of the close button (`Drawer.CloseTrigger`); when null, no
  /// button.
  final String? closeLabel;

  @override
  State<FlDrawer> createState() => _FlDrawerState();
}

class _FlDrawerState extends State<FlDrawer> {
  final ScrollController _scroll = ScrollController();
  final ScrollController _footer = ScrollController();

  /// The painted width, safe area included: the distance the panel travels.
  double _extent = 1;

  @override
  void dispose() {
    _footer.dispose();
    _scroll.dispose();
    super.dispose();
  }

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

  /// The direction, in x, that moves the panel out: -1 to the left.
  double _outward(FlDrawerSide side) =>
      (side == FlDrawerSide.start) != (Directionality.of(context) == TextDirection.rtl) ? -1 : 1;

  void _dragStart(DragStartDetails details) => _route!._drag.stop();

  void _dragUpdate(DragUpdateDetails details) {
    final route = _route!;
    final drag = route._drag;
    drag.value = (drag.value + details.primaryDelta! * _outward(route.side) / _extent).clamp(0.0, 1.0);
  }

  Future<void> _dragEnd(DragEndDetails details) async {
    final route = _route!;
    final outward = (details.primaryVelocity ?? 0) * _outward(route.side);
    if (route._drag.value > _dismissFraction || outward > _dismissVelocity) {
      // A PopScope may refuse the exit, and maybePop returns true anyway:
      // only a route that exits does not come back.
      await route.navigator?.maybePop();
      if (!mounted || !route.isActive || route.animation?.status == AnimationStatus.reverse) {
        return;
      }
    }
    switch (FlMotion.of(context).spring(FlSpring.release)) {
      case final FlSpringMotion spring:
        unawaited(route._drag.driveTo(0, spring, velocity: outward / _extent));
      case FlFadeMotion():
        route._drag.value = 0;
    }
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final media = MediaQuery.of(context);
    final route = _route;
    final placement = route?.side ?? FlDrawerSide.start;
    final rtl = Directionality.of(context) == TextDirection.rtl;
    final left = (placement == FlDrawerSide.start) != rtl;
    final viewport = media.size.width;
    final width = math.min(viewport >= _wideViewport ? _wideWidth : _narrowWidth, viewport * _maxWidthFraction);
    // Izydesk's drawer: 24 above and below, 20 at the sides, plus the safe
    // area on the side of the screen the panel touches; 20 between its parts.
    final padding = theme.space.xl;
    final side = theme.space.xs * 5;
    final gap = theme.space.xs * 5;
    final safe = media.padding;
    final closeLabel = widget.closeLabel;
    final scale = theme.typography;
    final inherited = DefaultTextStyle.of(context).style.fontFamily;
    final title = _style(
      scale,
      FlTextRole.textLg,
      heading: true,
    ).copyWith(color: colors.onSurface, fontWeight: theme.tokens.typography.weight.medium);
    final body = _style(scale, FlTextRole.textBase).copyWith(color: colors.onSurface);
    // The safe area of the screen edge the panel touches.
    final edge = left ? safe.left : safe.right;
    final start = placement == FlDrawerSide.start;
    _extent = width + edge;
    Widget scrollbar(ScrollController controller, Widget child) => RawScrollbar(
      controller: controller,
      thumbVisibility: true,
      thickness: theme.size.indicatorStroke,
      radius: Radius.circular(theme.size.indicatorStroke),
      thumbColor: colors.onSurfaceMuted,
      child: SingleChildScrollView(controller: controller, child: child),
    );
    final content = Padding(
      // Like FlDialog: clear of the safe area and the keyboard, the larger
      // of the two at the bottom.
      padding: EdgeInsetsDirectional.fromSTEB(
        side + (start ? edge : 0),
        padding + safe.top,
        side + (start ? 0 : edge),
        padding + math.max(safe.bottom, media.viewInsets.bottom),
      ),
      child: LayoutBuilder(
        // The title scrolls with the body; the actions stay at the bottom,
        // and scroll in turn beyond half the height (enlarged text in
        // landscape), like FlSheet's header.
        builder: (context, constraints) => Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Expanded(
              child: scrollbar(
                _scroll,
                Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  spacing: gap,
                  children: [
                    Padding(
                      // The close button's space, laid on top at the end of
                      // the title's line, `space.md` away.
                      padding: EdgeInsetsDirectional.only(
                        end: closeLabel == null ? 0 : theme.size.closeButton + theme.space.md,
                      ),
                      // The line is as tall as the close button, the title
                      // centred on it.
                      child: ConstrainedBox(
                        constraints: BoxConstraints(minHeight: closeLabel == null ? 0 : theme.size.closeButton),
                        child: Align(
                          alignment: AlignmentDirectional.centerStart,
                          widthFactor: 1,
                          child: ExcludeSemantics(
                            child: Text(
                              widget.title,
                              style: title,
                              strutStyle: scale.strut(FlTextRole.textLg, inheritedFamily: inherited, heading: true),
                            ),
                          ),
                        ),
                      ),
                    ),
                    DefaultTextStyle.merge(style: body, child: widget.child),
                  ],
                ),
              ),
            ),
            if (widget.actions.isNotEmpty)
              ConstrainedBox(
                constraints: BoxConstraints(maxHeight: constraints.maxHeight / 2),
                child: scrollbar(
                  _footer,
                  Padding(
                    padding: EdgeInsetsDirectional.only(top: gap),
                    child: OverflowBar(
                      alignment: MainAxisAlignment.start,
                      overflowAlignment: OverflowBarAlignment.start,
                      spacing: theme.space.sm,
                      overflowSpacing: theme.space.sm,
                      children: widget.actions,
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
    // Izydesk rounds the edge that faces the page, at `shape.radiusSheet`.
    final shape = RoundedRectangleBorder(
      borderRadius: BorderRadiusDirectional.horizontal(
        start: start ? Radius.zero : Radius.circular(theme.shape.radiusSheet),
        end: start ? Radius.circular(theme.shape.radiusSheet) : Radius.zero,
      ),
    );
    final line = scale.style(FlTextRole.textLg).lineHeight;
    Widget panel = FlShadow(
      layers: theme.shadows.overlay,
      shape: shape,
      child: DecoratedBox(
        decoration: ShapeDecoration(color: colors.surfaceOverlay, shape: shape),
        child: Stack(
          children: [
            Positioned.fill(child: content),
            if (closeLabel != null)
              PositionedDirectional(
                // Centered on the title's first line, which is at least as
                // tall as the button.
                top:
                    padding +
                    safe.top +
                    math.max(0, (MediaQuery.textScalerOf(context).scale(line) - theme.size.closeButton) / 2),
                end: side + (start ? 0 : edge),
                child: FlCloseButton(label: closeLabel, onPressed: () => Navigator.maybePop(context)),
              ),
          ],
        ),
      ),
    );
    if (route != null && !FlMotion.of(context).reduced) {
      panel = GestureDetector(
        excludeFromSemantics: true,
        onHorizontalDragStart: _dragStart,
        onHorizontalDragUpdate: _dragUpdate,
        onHorizontalDragEnd: _dragEnd,
        child: panel,
      );
    }
    final semantics = Semantics(
      container: true,
      explicitChildNodes: true,
      scopesRoute: true,
      namesRoute: true,
      role: SemanticsRole.dialog,
      label: widget.title,
      child: panel,
    );
    final presence = route?._presence;
    return Align(
      alignment: start ? AlignmentDirectional.centerStart : AlignmentDirectional.centerEnd,
      child: SizedBox(
        width: _extent,
        height: double.infinity,
        child: presence == null || FlMotion.of(context).reduced
            ? semantics
            : AnimatedBuilder(
                animation: presence,
                builder: (context, child) => _Slide(away: 1 - presence.value, left: left, child: child!),
                child: semantics,
              ),
      ),
    );
  }
}

/// The style of the role [role]: the role's line height forced on the system
/// font (see FlText), the named font's otherwise.
TextStyle _style(FlTypeScale scale, FlTextRole role, {bool? heading}) {
  final metrics = scale.style(role);
  final style = scale.resolve(role, heading: heading);
  return scale.familyOf(role, heading: heading) == null
      ? style.copyWith(height: metrics.lineHeight / metrics.size, leadingDistribution: TextLeadingDistribution.even)
      : style;
}

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