drawer_rail
A collapsible, themeable side navigation drawer for Flutter.
DrawerRail has two states, both driven by your app's ColorScheme:
- an expanded panel β optional search, uppercase section labels, a selected pill, text/count badges and inline-expandable groups; and
- a narrow icon rail β where groups open as flyout menus.
State lives in a plain ChangeNotifier (DrawerRailController), so there is
no dependency on any state-management package.
Features
- π§© Declarative content:
DrawerSection,DrawerLink,DrawerGroup. - βοΈ Animated collapse between a full panel and an icon rail.
- π±οΈ Opens and closes by itself on hover (web/desktop), without ever overwriting the state you persist.
- π Correct cursors: a hand over anything clickable, an arrow everywhere else β and it is themeable.
- π Built-in, diacritic-insensitive search (
"acao"matches"AΓ§Γ£o") that also finds a whole group by its name. - π·οΈ Text and count badges (
New,4,99+). - π¨ Themeable via
DrawerRailTheme, with automaticColorSchemefallbacks. - ποΈ Every duration and curve exposed; honours the OS "reduce motion" setting automatically.
- π Localizable chrome via
DrawerRailLabels. - β¨οΈ Keyboard-friendly: a rail button shows its label when it takes focus, and
the drawer stays out of an app-wide
SelectionArea. - π§± Custom header and footer slots (avatar, dark-mode toggle, sign out, β¦).
- π§ͺ No runtime dependencies beyond Flutter.
Installation
Add it to your pubspec.yaml:
dependencies:
drawer_rail: ^0.6.0
Or let pub add it for you:
flutter pub add drawer_rail
Requires Dart ^3.5.0 and Flutter >=3.24.0. Works on every Flutter target;
the hover behavior needs a pointer, so it simply never fires on touch.
Then import it:
import 'package:drawer_rail/drawer_rail.dart';
Quick start
Keep a DrawerRailController in your State, pass it and a list of entries to
DrawerRail, and place the drawer in a Row beside your content:
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _controller = DrawerRailController(selectedId: 'dashboard');
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
DrawerRail(
controller: _controller,
entries: [
const DrawerSection('Main'),
DrawerLink(
id: 'dashboard',
icon: Icons.dashboard_outlined,
label: 'Dashboard',
onTap: (context) => _open(context, const DashboardPage()),
),
DrawerLink(
id: 'payments',
icon: Icons.account_balance_wallet_outlined,
label: 'Payments',
badge: const DrawerBadge.count(4),
onTap: (context) => _open(context, const PaymentsPage()),
),
const DrawerSection('Workspace'),
DrawerGroup(
id: 'services',
icon: Icons.apps_rounded,
label: 'Services',
children: [
DrawerLink(
id: 'cloud',
icon: Icons.cloud_outlined,
label: 'Cloud Solutions',
onTap: (context) => _open(context, const CloudPage()),
),
],
),
],
),
const Expanded(child: Center(child: Text('Content'))),
],
),
);
}
}
DrawerRailrenders at a fixed width and animates it. Place it in aRow(or any layout that gives it an unbounded cross axis), not in theScaffold.drawerslot.
Entries
| Type | Purpose |
|---|---|
DrawerSection |
A non-interactive header; a divider in the collapsed rail. |
DrawerLink |
A tappable destination with id, icon, label, onTap. |
DrawerGroup |
A group of links; expands inline, or opens a flyout collapsed. |
DrawerLink.onTap receives the drawer's BuildContext, so you can navigate
from it directly. Set danger: true for destructive items (e.g. sign out) to
tint them with the error color.
Give a link a selectedIcon β typically the filled variant of an outlined
icon β and it swaps in while the link is selected. A DrawerGroup's
selectedIcon shows while one of its children is selected, so a closed group
still tells you where you are. No need to rebuild the entries to swap icons.
Badges
DrawerBadge.text('New'); // pill with text
DrawerBadge.count(4); // numeric; hidden at 0, shown as "99+" above 99
Search
Set showSearch: true (the default) and the panel gets a filter field; the
collapsed rail gets a magnifier that opens the panel for the search only
and focuses the field. It does not pin anything: picking a result, or tapping
anywhere outside the drawer, puts back the rail the user chose, and
controller.collapsed β the value you persist β never changes.
- Diacritics are ignored both ways, so
acaofindsAΓ§Γ£oand vice versa. - Group names match too. Typing
reposurfaces every link insideReports, not just the children whose own label happens to carry the word. - Results are shown flat β sections and group headers drop away, because a hierarchy no longer tells you anything once you are filtering.
- Pinning the drawer collapsed clears the query. The field is not on screen in the rail, so a query left running there would silently filter the panel the next time it opened. A hover peek or auto-hide is transient and deliberately leaves what you typed alone.
- Hover never closes the panel while the field has focus. Moving the pointer off the drawer mid-word no longer pulls the field out from under the keystrokes; the close waits until the field lets go of focus.
showSearch: false removes the field and the rail button alike.
The controller
DrawerRailController is a ChangeNotifier:
final controller = DrawerRailController(
collapsed: false,
selectedId: 'dashboard',
initiallyExpanded: {'services'},
);
controller.toggleCollapsed(); // panel <-> rail
controller.setCollapsed(true);
controller.select('payments'); // highlight an entry
controller.clearSelection();
controller.toggleGroup('services');
controller.setGroupExpanded('services', true); // idempotent
controller.setHoverHold(true); // keep a hover-opened drawer open for now
controller.resetHoverState(); // drop a peek/auto-hide, keep `collapsed`
DrawerRail calls resetHoverState() for you when it leaves the tree, so a
controller that outlives the drawer β one hoisted above the navigator, say β
never goes on reporting a peek that nothing is holding open. Calling it on an
already-disposed controller is a no-op, so teardown order stays yours.
Reading state β the distinction matters once hover is involved:
| Getter | Meaning |
|---|---|
collapsed |
The pinned state. This is the one to persist; hover never writes it. |
railCollapsed |
What is on screen right now. Use it for layout that must line up with the drawer's real width. |
hoverPeeking |
A collapsed rail is currently held open by the pointer. Transient. |
hoverHidden |
An expanded panel is currently held shut by the pointer having left. Transient. |
hoverHeld |
The app is holding the drawer open against hover β see Keeping it open for your own overlays. Transient. |
Selection that follows the route
By default, tapping a link selects it and then runs its onTap. If your app
derives the selection from the current route instead, turn that off and call
select as the location changes:
DrawerRail(
controller: _controller,
entries: _entries,
selectOnTap: false, // the router decides what is selected
);
// wherever you observe the route:
_controller.select(idForLocation(location));
Selecting on tap fights a route-driven selection: a link that pushes a route marks itself in the menu of the screen underneath, which stays mounted below the new one and still shows the wrong item once the user comes back.
Persisting state
The controller does not persist anything itself. To remember, say, the collapse state, listen and save it wherever you like:
controller.addListener(() {
prefs.setBool('drawer_collapsed', controller.collapsed);
});
Theming β fully customizable
Everything falls back to the ambient ColorScheme, so the drawer looks right
with zero configuration. When you want control, every size, spacing, color,
text style, icon and animation is exposed on DrawerRailTheme β override only
what you need:
DrawerRail(
controller: _controller,
entries: _entries,
theme: const DrawerRailTheme(
// Sizing & layout
expandedWidth: 320,
railWidth: 72,
borderRadius: 28,
itemBorderRadius: 12,
iconSize: 22,
railIconSize: 24,
railItemHeight: 48,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
itemPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
groupChildIndent: 28,
position: DrawerRailPosition.left, // or .right
// Colors
selectedColor: Color(0xFF6366F1),
onSelectedColor: Colors.white,
iconColor: Color(0xFF334155),
labelColor: Color(0xFF0F172A),
sectionColor: Color(0xFF6366F1),
badgeCountColor: Color(0xFFEF4444),
// Hover feedback: shadow (default), highlight or none
hoverEffect: DrawerHoverEffect.shadow,
hoverShadowColor: Color(0x1F6366F1), // used in shadow mode
hoverHighlightColor: Color(0x146366F1), // used in highlight mode
// Activation: click (default) or hover, for web/desktop
railTrigger: DrawerActivationMode.hover, // pointer peeks the rail open
groupTrigger: DrawerActivationMode.hover, // pointer opens groups/flyouts
linkTrigger: DrawerActivationMode.click, // hover here also navigates
railAutoCollapse: true, // leaving also closes it again
hoverOpenDelay: Duration(milliseconds: 120),
hoverCloseDelay: Duration(milliseconds: 220),
hoverSelectDelay: Duration(milliseconds: 300),
hoverAutoCollapseDelay: Duration(milliseconds: 450),
// Cursors
clickableCursor: SystemMouseCursors.click,
inertCursor: SystemMouseCursors.basic,
// Text styles (label color is applied automatically per state)
labelTextStyle: TextStyle(fontWeight: FontWeight.w600),
sectionTextStyle: TextStyle(fontWeight: FontWeight.w800, fontSize: 11),
badgeTextStyle: TextStyle(fontWeight: FontWeight.w800, fontSize: 11),
sectionUppercase: true,
// Icons for the built-in chrome
collapseIcon: Icons.chevron_left_rounded,
expandIcon: Icons.chevron_right_rounded,
searchIcon: Icons.search_rounded,
clearSearchIcon: Icons.close_rounded,
groupTrailingIcon: Icons.keyboard_arrow_down_rounded,
// Animation
animationDuration: Duration(milliseconds: 240),
animationCurve: Curves.easeOutCubic,
groupAnimationDuration: Duration(milliseconds: 200),
groupAnimationCurve: Curves.easeOutCubic,
hoverAnimationDuration: Duration(milliseconds: 160),
hoverAnimationCurve: Curves.easeOut,
pressedScale: 0.97,
pressAnimationDuration: Duration(milliseconds: 140),
),
);
Already have a theme and only want to change a few things? Use copyWith:
const brand = DrawerRailTheme(selectedColor: Color(0xFF6366F1));
DrawerRail(
theme: brand.copyWith(railWidth: 88, position: DrawerRailPosition.right),
// ...
);
What each field actually does
Every row says what the option turns on and what visibly moves when you change it. Defaults in parentheses.
Sizing & layout
| Field | What it changes |
|---|---|
expandedWidth (300) |
Width of the full panel. The drawer animates to this number when expanded. |
railWidth (76) |
Width of the icon rail. The collapse animation runs between these two numbers. |
railItemHeight (44) |
Height of each rail icon button β the rail's vertical density. |
iconSize (20) / railIconSize (22) |
Icon size in the panel / in the rail. They differ because rail icons carry the meaning alone. |
borderRadius (24) |
Rounds the drawer's inner edge only β the side facing your content. The outer edge stays square against the window. |
itemBorderRadius (14) |
Rounds each item's hover/selected background, the search field and flyout menus. |
contentPadding |
Padding around the scrolling list as a whole. |
itemPadding |
Padding inside each item β the main lever for row height in the panel. |
groupChildIndent (24) |
How far a group's children are pushed right, so the hierarchy reads. |
position (left) |
Which side the drawer sits on. Flips which edge is rounded and which way the default shadow falls. It does not reorder your Row β do that yourself. |
Colors
| Field | What it changes |
|---|---|
backgroundColor (surface) |
The drawer surface. Also used as the opaque backing behind a lifted item, so hover shadows read as elevation instead of haze. |
selectedColor (primary) |
Fill of the selected pill, and the badge dot in the rail. |
onSelectedColor (onPrimary) |
Icon and label color on top of the selected pill. |
iconColor / labelColor (onSurface) |
Unselected icon / label color β in the panel, the rail and the flyouts. Overridden by the error color on danger: true links. |
mutedColor (onSurfaceVariant) |
Secondary chrome: the collapse/expand and search buttons, the search field's icon, hint and clear button, group chevrons, the "no results" message. |
dividerColor (ambient) |
The divider above the footer and the section dividers in the rail. |
focusColor (ambient) |
The overlay on an item with keyboard focus β worth setting on a dark drawer in a light app, where the default is too faint to see. |
sectionColor (primary) |
Section header color. Ignored if sectionTextStyle already sets a color. |
badgeTextColor / badgeCountColor |
Background of a DrawerBadge.text pill / a DrawerBadge.count pill. Foregrounds are derived automatically. |
menuBackgroundColor |
Background of the flyout menu a group opens in the collapsed rail. |
searchFillColor |
Fill of the built-in search field. Ignored if you pass your own searchDecoration. |
shadow |
The drawer's own drop shadow. Defaults to a soft shadow on the outer edge; pass [] for a flat look. |
Hover feedback (how an item reacts under the pointer)
| Field | What it changes |
|---|---|
hoverEffect (shadow) |
shadow lifts the item on an opaque surface, highlight tints its background flat, none disables both. The press micro-scale still runs in all three. |
hoverShadowColor |
Color of the lift shadow. Only read in shadow mode. |
hoverHighlightColor |
The background tint. Only read in highlight mode. |
hoverAnimationDuration (160ms) |
How long that shadow/tint takes to fade in and out. Set to Duration.zero to snap. |
hoverAnimationCurve (easeOut) |
The curve of that fade. |
pressedScale (0.97) |
How far an item shrinks while held. It springs back with a slight overshoot on release. Set it to 1 to switch the micro-scale off. |
pressAnimationDuration (140ms) |
How long that shrink takes. |
Activation (what opens things β click or pointer)
| Field | What it turns on |
|---|---|
railTrigger (click) |
Set to hover and the collapsed rail expands when the pointer enters it and collapses again when it leaves. A temporary peek β it never writes controller.collapsed. |
groupTrigger (click) |
Set to hover and groups open under the pointer: inline in the panel, as a flyout in the rail. Flyouts open beside the rail, so the buttons below stay reachable, and only one is ever open at a time. |
linkTrigger (click) |
Set to hover and resting on a link runs its onTap β navigation included. See the warning below. |
railAutoCollapse (false) |
Only with railTrigger: hover. Also closes a drawer the user left expanded when the pointer leaves, making hover fully symmetric. Off by default because it takes away a panel someone deliberately pinned. |
hoverOpenDelay (120ms) |
How long the pointer must rest before a hover-open fires. Raise it if the drawer feels twitchy. |
hoverCloseDelay (220ms) |
Grace period after the pointer leaves. Long enough to cross the gap from a rail button to its flyout without the menu slamming shut. |
hoverSelectDelay (300ms) |
Dwell required before linkTrigger: hover navigates. Longer on purpose β the action is not undoable. |
hoverAutoCollapseDelay (450ms) |
Grace period before railAutoCollapse closes a pinned-open drawer. Longest of the four, because most is at stake. |
Cursors
| Field | What it changes |
|---|---|
clickableCursor (click) |
The cursor over every link, group header, rail button and flyout item. |
inertCursor (basic) |
The cursor over the drawer's non-interactive chrome β background, section headers, dividers. This is what makes the pointer revert when it leaves an item instead of dragging the hand cursor around. |
Text & icons
| Field | What it changes |
|---|---|
labelTextStyle / selectedLabelTextStyle |
Item label style, unselected / selected. Colors are applied per state on top, so setting a color here is usually pointless. |
sectionTextStyle |
Section header style. Unlike the others, a color set here is respected. |
badgeTextStyle |
Badge label style. |
sectionUppercase (true) |
Uppercases section labels for display. Your string is untouched β search still matches the original. |
collapseIcon / expandIcon |
The chrome toggle in each state. Flip them for a right-side drawer, or they point the wrong way. |
searchIcon / clearSearchIcon |
Search field leading icon / the clear button that appears once you type. |
groupTrailingIcon |
The chevron on a group header. It rotates 180Β° when the group opens. |
Motion
| Field | What it changes |
|---|---|
animationDuration (240ms) / animationCurve (easeOutCubic) |
The panel β rail width animation. |
groupAnimationDuration (200ms) / groupAnimationCurve (easeOutCubic) |
The group unfold and its chevron rotation. Try Curves.easeOutBack for a little overshoot. |
pressAnimationDuration (140ms) |
The press micro-scale. |
While the drawer animates its width, the content is laid out at the width it is
heading to and anchored against the edge that does not move β the rail is
revealed rather than squeezed, so nothing re-wraps, ellipsises or slides
sideways on the way. With position: right that anchor flips with it.
Reduce motion is automatic. When the OS asks for reduced animations (
MediaQuery.disableAnimations), every duration above collapses to zero andpressedScaleis forced to1, so the drawer snaps between states and items stop shrinking under the pointer β a scale is movement however short its duration. The hover delays are deliberately kept: they gate an interaction, not a motion effect, and zeroing them would make the drawer fire on the slightest twitch.
Opening and closing on hover (web & desktop)
By default everything is driven by clicks. To make the drawer open and close by itself under the mouse, use the preset:
DrawerRail(
controller: _controller,
entries: _entries,
theme: DrawerRailTheme.hoverAdaptive(),
);
That single line switches the rail to hover and turns on railAutoCollapse,
so:
| You do this | The drawer does this |
|---|---|
| Move the pointer onto the rail | Expands to the full panel after hoverOpenDelay |
| Move the pointer away | Collapses back after hoverCloseDelay (or hoverAutoCollapseDelay if it was pinned open) |
| Click the collapse button | Pins that state β hover stops fighting you until you move away and back |
| Tap anything on a touch device | Works exactly as before; hover events never fire there |
Groups stay on click. The preset used to open them on hover too, and every app built on it switched that back off: an inline group opening on the way to another item pushes that item down and out from under the pointer. Ask for it explicitly if you want it:
DrawerRailTheme.hoverAdaptive(groupTrigger: DrawerActivationMode.hover)
Layer it on top of your own styling with base, and turn the closing half off
if you only want hover to open:
DrawerRailTheme.hoverAdaptive(
base: const DrawerRailTheme(hoverEffect: DrawerHoverEffect.highlight),
autoCollapse: false,
openDelay: const Duration(milliseconds: 80),
)
Or wire the three triggers by hand for full control:
const DrawerRailTheme(
railTrigger: DrawerActivationMode.hover, // rail expands while hovered
groupTrigger: DrawerActivationMode.hover, // groups & flyouts open on hover
railAutoCollapse: true, // and closes again on exit
)
Rules worth knowing
- Hover never replaces the click. Every item stays tappable in every mode, so touch users β where hover events simply never fire β are never locked out.
- Hover never writes
controller.collapsed. A peek widens the drawer and an auto-collapse narrows it, both temporarily. The pinned state you persist stays untouched, so someone brushing past the drawer never rewrites their saved preference. Readcontroller.railCollapsedfor the width actually on screen, and keep persistingcollapsed. - An explicit click always wins. Hitting collapse/expand cancels any hover timer in flight, so a pending peek cannot undo your click a frame later.
- A group you opened by clicking stays open when the pointer leaves β inline or as a rail flyout. Only what hover itself opened is closed again β including when the whole panel closes, so nothing is left hanging open for next time.
- Clicking a group hover already opened pins it. Hover opens a group in 120ms, quicker than anyone clicks, so a click that toggled would shut nearly every group the user was reaching to open.
- Sweeping down a list of groups leaves nothing behind. Each group keeps its own timer, so moving straight from one to the next closes the first and opens the second; in the rail, only one flyout is ever open at a time.
Keeping it open for your own overlays
A menu, popup or dialog opened from your header or footer is drawn outside the drawer, so moving the pointer onto it reads as leaving β and hover would collapse the panel from under it. Hold the drawer for as long as the overlay is up:
MenuAnchor(
onOpen: () => controller.setHoverHold(true),
onClose: () => controller.setHoverHold(false),
// ...
)
The close is put off, not dropped: once released, the drawer closes if the
pointer is still away, exactly as it would have. The header also keeps its
place in the widget tree across a collapse, so a menu open in it is no longer
torn down β no GlobalKey needed.
The one to be careful with
linkTrigger: DrawerActivationMode.hover resting on a link for
hoverSelectDelay runs its onTap, navigation included. On a dense menu that
can move the user somewhere they only meant to pass over, and there is no
keyboard equivalent to undo it. The dwell delay makes it unlikely, not
impossible. hoverAdaptive deliberately leaves this on click β only turn it
on if pointer-driven navigation is genuinely what you want.
Cursors
The pointer becomes a hand over anything clickable and reverts to an arrow over the drawer's own chrome. That is on by default and needs no configuration, but both halves are themeable:
const DrawerRailTheme(
clickableCursor: SystemMouseCursors.click, // links, groups, rail buttons
inertCursor: SystemMouseCursors.basic, // background, sections, dividers
)
AnimatedPressCard is exported, so custom header/footer content can get the
same cursor and press behavior as the built-in items.
Widget-level customization
Beyond the theme, DrawerRail itself exposes toggles and slots:
| Field | Purpose |
|---|---|
showSearch |
Show/hide the built-in search. |
showCollapseButton |
Show/hide the collapse/expand toggle. |
showFooterDivider |
Draw a divider above the footer. |
headerBuilder / footerBuilder |
Fully custom header/footer per state. |
searchDecoration |
Replace the search field's InputDecoration. |
selectOnTap |
Whether tapping a link selects it (default true). Turn off when the route drives the selection. |
labels |
Localize every built-in string (see below). |
Full control of the search field:
DrawerRail(
controller: _controller,
entries: _entries,
searchDecoration: InputDecoration(
hintText: 'Type to filterβ¦',
prefixIcon: const Icon(Icons.filter_list_rounded),
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(30)),
),
);
Header & footer
Provide custom slots. Both builders receive the current collapsed state so
you can return a compact variant on the rail:
DrawerRail(
controller: _controller,
entries: _entries,
headerBuilder: (context, collapsed) =>
collapsed ? const _Logo() : const _LogoWithName(),
footerBuilder: (context, collapsed) => DarkModeToggle(collapsed: collapsed),
);
Localization
DrawerRail(
controller: _controller,
entries: _entries,
labels: const DrawerRailLabels(
searchHint: 'Buscar...',
noResults: 'Nenhum resultado',
expandTooltip: 'Expandir',
collapseTooltip: 'Recolher',
searchTooltip: 'Buscar',
),
);
Wire it to your existing delegate by building the labels from whatever
AppLocalizations you already have, or override a single string with
copyWith:
labels: const DrawerRailLabels().copyWith(noResults: 'Nada encontrado'),
DrawerRailLabels and DrawerBadge compare by value, so rebuilding them inline
costs nothing and they can be asserted on directly in tests.
Example
A complete, runnable example lives in example/: a
header logo, a dark-mode footer toggle, badges, a group, and switches that flip
Open on hover and Close on exit live, so you can feel the difference
between click-driven and pointer-driven rather than guess at it. There is also a
button that swaps to a fully custom right-side theme.
cd example
flutter run -d chrome # hover behavior needs a pointer device
Contributing
Contributions are welcome from everyone! Fork the repo, create a branch, and open a pull request. Please read CONTRIBUTING.md first and follow the Code of Conduct.
Good first steps: open an issue for bugs or ideas, improve the docs, or add tests.
License
MIT β see LICENSE.
Libraries
- drawer_rail
- A collapsible, themeable side navigation drawer for Flutter.