RemixRemix
Navigation

Sidebar

A controlled navigation panel with a fixed header, scrolling destinations, and a fixed footer

Open catalog
Starting Flutter example…
Live Flutter from this checkout’s component catalog. Examples include Fortal styling; the source tab shows the catalog code, not a standalone application.

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.

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 navigation landmark, named by semanticLabel when supplied
  • header and footer sit 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 semanticLabel replaces its visible label for accessibility services
  • excludeSemantics removes the landmark and destination subtree, and leaves header and footer semantics untouched
  • Tab and Shift+Tab traverse enabled destinations in visual order
  • Enter and Space activate 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:

SlotResponsibility
containerLayout and decoration for the panel that stacks the three regions
headerLayout and decoration around the fixed header content
contentLayout, padding, and section spacing inside the scrolling region
footerLayout and decoration around the fixed footer content
sectionLayout and decoration for one heading plus destination group
sectionLabelHeading typography and modifiers
destinationsLayout and spacing between destination rows
destinationDefault SidebarDestinationStyler for every destination
tooltipAutomatic 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();
View page source on GitHub

On this page