Sidebar
A controlled navigation panel with a fixed header, scrolling destinations, and a fixed footer
A sidebar presents sections of destinations that select an in-app view. It owns the panel that stacks a fixed header, a scrolling destination region, and a fixed footer. It also owns destination interaction, selected state presentation, ordinary Tab traversal, and navigation semantics. The application continues to own placement, width, safe areas, drawer or sheet presentation, routing, and dismissal behavior.
When to use this
- Application sidebars: Select one page or workspace view
- Compact drawers: Present the same panel inside a caller-owned overlay
- Settings navigation: Move between in-place settings panels
- Sectioned navigation: Group destinations under semantic headings
Use RemixLink for a true URL or document link. Sidebar destinations behave as
selected buttons: both Enter and Space activate them, and activation does not
itself change routes or browser history.
Basic implementation
Basic implementation
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
enum AppPage { overview, customers, orders }
class AppNavigation extends StatelessWidget {
const AppNavigation({
super.key,
required this.selectedPage,
required this.onSelected,
});
final AppPage? selectedPage;
final ValueChanged<AppPage> onSelected;
static const sections = [
RemixSidebarSection(
label: 'Workspace',
destinations: [
RemixSidebarDestination(
value: AppPage.overview,
label: 'Overview',
icon: Icons.space_dashboard_outlined,
),
],
),
RemixSidebarSection(
label: 'Data',
destinations: [
RemixSidebarDestination(
value: AppPage.customers,
label: 'Customers',
icon: Icons.people_outline,
),
RemixSidebarDestination(
value: AppPage.orders,
label: 'Orders',
icon: Icons.receipt_long_outlined,
),
],
),
];
@override
Widget build(BuildContext context) {
return RemixSidebar<AppPage>(
header: const Text('Acme'),
sections: sections,
selectedValue: selectedPage,
onSelected: onSelected,
footer: const Text('Account'),
semanticLabel: 'Primary navigation',
);
}
}Collapsing to an icon rail
Set collapsed: true to hide destination labels and section heading text.
Accessible names, semantic headings, selected state, Tab traversal, and
activation stay available. Every rendered destination must provide an icon
when collapsed; a missing icon throws a descriptive FlutterError in all
build modes. Expanded sidebars continue to support text-only destinations.
The sidebar owns its 200ms animation. Set expandedWidth and collapsedWidth
together to animate width with labels and headings; omit both for host-sized
panels. Parent constraints still take precedence. Initial state is immediate,
rapid reversals continue from the current frame, and reduced motion settles on
the latest target without animation. Destination icons and section rows hold
their positions while the panel moves: the narrowing row clips each label, and
a settled rail centers each icon in its target.
animationStyle uses Flutter's existing AnimationStyle, as RemixDisclosure
does. Its duration/curve configure expansion; reverseDuration/reverseCurve
configure collapse, falling back to the forward settings. Defaults are 200ms
and ease-in-out. Labels ease out over the first 30% of a collapse and ease in
over the final 75% of an expansion; these phases scale with the duration. Set
animationStyle: AnimationStyle.noAnimation for immediate updates; the host's
reduced-motion preference also takes precedence. Custom curves are clamped to
the width endpoints.
Mix .animate(...) remains available for visual style changes such as hover
colors. Use animationStyle for collapse motion; independently animating the
same width/padding with Mix would add a second interpolation to that geometry.
Animated icon rail
import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';
import 'ui/ui.dart';
Widget buildCollapsibleSidebar({
required bool collapsed,
required String selectedPage,
required ValueChanged<String> onSelected,
required List<RemixSidebarSection<String>> sections,
}) => FortalSidebar<String>(
collapsed: collapsed,
expandedWidth: 256,
collapsedWidth: 72,
sections: sections,
selectedValue: selectedPage,
onSelected: onSelected,
);The example assumes a FortalScope and an Overlay supplied by the host.
Use a normal icon button to change the caller's collapsed value. Header and
footer widgets can read RemixSidebar.animationOf(context) to coordinate custom
content using expansion and labelOpacity, while keeping interactive controls
mounted. Read it inside the slot widget's build method or a Builder, so the
context is below the sidebar. No separate transition widget or input is needed.
Collapsed destinations reveal their labels on hover, keyboard focus, or long
press. Tooltips are suppressed during movement and dismissed on expansion.
showTooltips: false disables automatic tooltips without changing accessible
names. Expanded use, and collapsed use with automatic tooltips disabled,
requires no overlay. Otherwise provide an Overlay, such as Overlay.wrap
in a minimal host. tooltipPositioning overrides the default logical-end,
centered placement with an 8px gap and collision avoidance.
Fortal interpolates navigation padding between space3 expanded and space2
collapsed, preserving theme scaling, and adds the difference back to each
destination's leading padding so icons hold still. With the dashboard's 256px
and 72px widths, icons settle on the rail's center line without a final shift;
custom widths must accommodate padding, borders, display insets, and at least
48px destination targets. Increase the host width when custom/scaled tokens
need more room. Keep a mobile drawer's open state separate from the desktop
collapse preference and present the drawer expanded.
Controlled selection
selectedValue is the only source of selected state. A null value means no
destination is selected; otherwise it must match one destination. Activating
an already-selected destination calls onSelected again. That reselection can
close a drawer, reset nested content, or scroll the current view without
inventing a second callback.
Destination values must be unique across every section and retain stable equality and hash codes while rendered. At most one destination may request autofocus. Rebuild with new section and destination lists when content or order changes instead of mutating the supplied lists.
onSelected: null and enabled: false both disable every destination while
preserving selected styling. A destination's own enabled value combines with
the panel-level state. Neither flag changes header or footer content.
Header, footer, and scrolling
The panel fills the height it is given. The header and footer keep their own height and stay fixed, and the destination region takes the remaining space and scrolls when its content is taller.
Given unbounded height the panel sizes to its content and scrolls nothing, which leaves scrolling to the host. Both slots are optional, so a sidebar with no header and no footer is a bare destination panel.
Host-owned placement
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
Widget buildSidebar({
required String? selectedPage,
required ValueChanged<String> selectPage,
}) {
return SizedBox(
width: 256,
child: SafeArea(
child: RemixSidebar<String>(
header: const Text('Acme'),
sections: const [
RemixSidebarSection(
destinations: [
RemixSidebarDestination(
value: 'overview',
label: 'Overview',
),
],
),
],
selectedValue: selectedPage,
onSelected: selectPage,
footer: const Text('Account'),
),
),
);
}Ordinary use requires no Material ancestor, Overlay, Navigator, router, or SafeArea. Width, safe areas, drawer dismissal, and focus restoration stay with the caller that places the panel.
Semantics and keyboard behavior
- The destination region publishes one
navigationlandmark, named bysemanticLabelwhen supplied headerandfootersit outside that landmark, because brand, search, and account content are not destinations- Section labels are separate header nodes; visual text transforms preserve their authored accessible names
- Empty sections and orphan headings are skipped
- Every destination publishes one button node with selected state and no toggled state
- A destination's
semanticLabelreplaces its visible label for accessibility services excludeSemanticsremoves the landmark and destination subtree, and leaves header and footer semantics untouchedTabandShift+Tabtraverse enabled destinations in visual orderEnterandSpaceactivate the focused destination
Arrow keys, Home, and End do not rove between destinations. This preserves ordinary document traversal instead of applying menu, tab, or toggle-group behavior.
Styling
SidebarStyler exposes nine slots:
| Slot | Responsibility |
|---|---|
container | Layout and decoration for the panel that stacks the three regions |
header | Layout and decoration around the fixed header content |
content | Layout, padding, and section spacing inside the scrolling region |
footer | Layout and decoration around the fixed footer content |
section | Layout and decoration for one heading plus destination group |
sectionLabel | Heading typography and modifiers |
destinations | Layout and spacing between destination rows |
destination | Default SidebarDestinationStyler for every destination |
tooltip | Automatic collapsed destination tooltip style |
The component forces the four structural FlexBox slots into source-order vertical layout after style resolution. Styles can change spacing, alignment, decoration, and sizing, but cannot reorder the panel regions, the navigation, or its semantics.
A destination's style merges after the panel's default destination style, so
local exceptions retain shared hover, focus, selected, and disabled variants.
A raw SidebarSpec is authoritative and bypasses both fluent layers.
Fluent styling
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
final style = SidebarStyler()
.color(const Color(0xFFFBFBFB))
.content(
FlexBoxStyler()
.spacing(12)
.padding(EdgeInsetsGeometryMix.all(12)),
)
.section(
FlexBoxStyler()
.spacing(6),
)
.destinations(
FlexBoxStyler()
.spacing(2),
)
.sectionLabel(
TextStyler()
.uppercase()
.fontSize(12),
)
.destination(
SidebarDestinationStyler()
.mainAxisSize(MainAxisSize.max)
.mainAxisAlignment(MainAxisAlignment.start),
);SidebarDestinationStyler is the destination style type. It is a ToggleStyler
because destinations reuse toggle interaction states, so depend on this name
rather than on the toggle behind it.
Fortal recipe
The public FortalSidebar<T> wrapper is produced by Mix code generation. Its
recipe paints the solid panel surface with a trailing edge border, pads the
scrolling destination region, and draws the footer divider. It uses compact
uppercase section headings, a 12 logical-pixel section gap, a 2 logical-pixel
destination gap, and full-width ghost size2 toggle content inside a 48
logical-pixel minimum target. The optional highContrast flag strengthens
heading and selected-destination content without changing those metrics.
The recipe sets no panel width and no header padding. Width belongs to the
host, which must also size any drawer that presents the same panel, and header
metrics usually have to match an application top bar. Pass host-owned display
insets through panelPadding when the panel surface and trailing border must
paint behind those insets while its contents remain clear of them.
Sidebar is a Fortal extension, not a Radix Themes parity family, so it is intentionally absent from the generated Radix parity catalog.
Sizing, the wide/compact switch, and the compact sheet presentation are the
open-code sidebar_layout template's job, not this recipe's: run
remix add sidebar_layout after sidebar for a shell layout that rows this
panel beside a header and body above its compact breakpoint, and presents it
as a start-edge sheet below it. See
Open-code components for its catalog
entry.
Fortal sidebar
import 'package:flutter/foundation.dart';
import 'package:remix/remix.dart';
import 'ui/ui.dart';
FortalSidebar<String> buildNavigation({
required ValueChanged<String> onSelected,
}) => FortalSidebar<String>(
sections: const [
RemixSidebarSection(
label: 'Workspace',
destinations: [
RemixSidebarDestination(
value: 'overview',
label: 'Overview',
),
],
),
],
selectedValue: 'overview',
onSelected: onSelected,
semanticLabel: 'Primary navigation',
);Constructors
Public API
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
RemixSidebar<T> remixSidebarConstructor<T extends Object>({
Key? key,
Widget? header,
bool collapsed = false,
bool showTooltips = true,
OverlayPositionConfig? tooltipPositioning,
double? expandedWidth,
double? collapsedWidth,
AnimationStyle animationStyle = const AnimationStyle(),
required List<RemixSidebarSection<T>> sections,
required T? selectedValue,
ValueChanged<T>? onSelected,
Widget? footer,
bool enabled = true,
String? semanticLabel,
bool excludeSemantics = false,
SidebarStyler style = const SidebarStyler.create(),
SidebarSpec? styleSpec,
}) => throw UnimplementedError();
RemixSidebarSection<T> remixSidebarSectionConstructor<T extends Object>({
String? label,
required List<RemixSidebarDestination<T>> destinations,
}) => throw UnimplementedError();
RemixSidebarDestination<T>
remixSidebarDestinationConstructor<T extends Object>({
required T value,
required String label,
IconData? icon,
String? semanticLabel,
bool enabled = true,
FocusNode? focusNode,
bool autofocus = false,
SidebarDestinationStyler style = const SidebarDestinationStyler.create(),
}) => throw UnimplementedError();