Skip to content

Layout & Navigation ​

CDX UI provides these layout / navigation primitives. They cover the common shells without forcing you into a full IDE-style workspace (use cdx_panes for that).

Live examples ​

Open the full Layout example or the focused Panels and Navigation example.

MasterDetailScaffold ​

Responsive master-detail with an animated, resizable detail panel. Picks docked vs. overlay presentation based on viewport width — or forces one explicitly.

dart
MasterDetailScaffold(
  main: AreasList(areas: areas, onSelect: store.select),
  panel: AreaDetailPanel(area: store.selected),
  showPanel: store.selected != null,
  panelWidth: 600,
  presentation: const PanelPresentation.auto(breakpoint: 1024),
  onPanelClosed: () => store.clearSelection(),
)

PanelPresentation ​

dart
PanelPresentation.docked()                                    // side-by-side row
PanelPresentation.overlay(showBarrier: true)                  // floating sheet from right
PanelPresentation.auto(breakpoint: 1024)                      // docked at wide, overlay at narrow
BehaviorDockedOverlay
LayoutRow(main, panel)Stack(main, scrim, panel)
Resizable edgeyes (drag handle on left of panel)no
Barrier dismissn/atap scrim closes (when showBarrier: true)
ESC keyboth — closes panel via onPanelClosedboth

Panel sizing defaults are baked in (min 360, default 480, max 720); pass panelWidth to override the initial width.

LazyIndexedStack ​

IndexedStack that only builds children when first selected. The unbuilt children render SizedBox.shrink(). Once built, children are preserved across switches via Offstage + TickerMode (so background animations pause but state survives).

dart
LazyIndexedStack(
  index: tabIndex,
  children: const [
    OverviewTab(),
    DetailsTab(),
    HistoryTab(),     // only built when the user opens this tab
  ],
)

LazyIndexedStackBuilder is the variant that takes a builder function and an itemCount — useful for very large fixed-tab sets.

RouteAwareTabController + RouteAwareLazyIndexedStack ​

Tab controllers wired to go_router. Switching tabs updates the URL; URL changes update the active tab. Combined with LazyIndexedStack they give you router-driven tabbed pages without manual sync code.

dart
RouteAwareTabBarView(
  tabRoutes: const ['overview', 'details', 'history'],
  baseRoute: '/lots/$lotId',
  tabs: const [
    Tab(text: 'Overview'),
    Tab(text: 'Details'),
    Tab(text: 'History'),
  ],
  children: const [
    OverviewTab(),
    DetailsTab(),
    HistoryTab(),
  ],
)

Internally this composes:

  • RouteAwareTabController(tabRoutes, baseRoute, length, vsync) — bidirectional sync
  • RouteAwareTabBar — TabBar with the controller wired
  • RouteAwareLazyIndexedStack — lazy-loaded panes

Use the parts directly when you need a custom tab strip.

CdxStepper ​

The steps of a flow and where the person is in it. The stepper holds no state: pass each step's status and the current index, and move on onStepTapped.

dart
CdxStepper(
  currentIndex: 2,
  onStepTapped: (index) => flow.goTo(index),
  steps: const [
    CdxStep(title: 'Details', status: CdxStepStatus.complete),
    CdxStep(title: 'Materials', status: CdxStepStatus.complete),
    CdxStep(title: 'Checks', caption: '3 of 5 done'),
    CdxStep(title: 'Review', enabled: false),
  ],
)
variantLook
CdxStepperVariant.segmented (default)A row: one bar segment per step over its glyph, title and caption. Scrolls sideways when the steps do not fit (by more than one chevron box) and keeps the current step in view.
CdxStepperVariant.railA column: a line on the left joining the glyphs, title and caption to the right; a travelled leg of the rail is a solid line, a leg ahead is dashed. Grows downward.
CdxStepperVariant.compact"Step 2 of 4 · Checks" over a thin bar. Shows progress only.
  • CdxStep.status says complete or in error; which step is current comes from currentIndex. A current step in error keeps the error tone.
  • A step with no caption shows its status label ("Complete", "In progress", "Not started", "Needs attention"). showStatusCaptions: false hides those.
  • CdxStep.enabled: false is a step a linear flow has not reached: muted, no hover, no taps, announced as unavailable.
  • CdxStep.busy: true swaps the step's glyph for a spinner and is announced as "working". While any step is busy the stepper takes no taps and no keys.
  • With onStepTapped the stepper is one tab stop when steps can be selected (not in compact): the arrow keys of the variant's axis (left and right for the segmented row, mirrored in right-to-left; up and down for the rail) move between the enabled steps, Home and End go to the first and last, Enter or Space selects. The current step takes no tap and is never reported to onStepTapped, but stays the tab stop. Without onStepTapped the stepper only shows progress.
  • A segmented row that does not fit scrolls sideways and keeps the current step in view. The edge with more steps beyond it fades into the page and carries a chevron button ("Previous steps" or "Next steps") that pages the row; the buttons are not tab stops, the arrow keys reach every step.
  • size is CdxControlSize.standard or .compact. semanticLabel names the list for a screen reader.

Wizard ​

A vertical-sidebar wizard. WizardSteps pair a label with a content builder; WizardController manages navigation, busy state, and sequential gating.

The sidebar is a CdxStepper rail with glyphs and titles and no status words: steps before the current one get the complete glyph, a step the controller does not allow is disabled, and the current step takes no tap. While the controller is busy the current step shows a spinner and nothing in the sidebar can be tapped. The steps take the theme's colours, so a dark or gradient sidebar decoration gives the rail a matching theme with sidebarBuilder, which wraps the rail alone; the content keeps the page's theme:

dart
Wizard(
  controller: controller,
  steps: steps,
  decoration: const BoxDecoration(gradient: brandGradient),
  sidebarBuilder: (context, rail) =>
      Theme(data: onGradient(Theme.of(context)), child: rail),
)
dart
final controller = WizardController(
  stepCount: 3,
  isSequential: true,                // disallow forward jumps to unvisited steps
  onStepChanged: (i) => analytics.log('wizard_step', {'step': i}),
);

Wizard(
  controller: controller,
  steps: [
    WizardStep(title: 'Basics', builder: (_) => const _BasicsStep()),
    WizardStep(title: 'Details', builder: (_) => const _DetailsStep()),
    WizardStep(title: 'Review', builder: (_) => const _ReviewStep()),
  ],
  decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerLow),
)

WizardController ​

dart
controller.currentStep;        // 0-based
controller.highestStepVisited;
controller.isFirstStep / isLastStep;
controller.canGoToNextStep / canGoToPreviousStep;
controller.canGoToStep(2);     // honors isSequential + stepGuard

controller.nextStep();
controller.previousStep();
controller.goToStep(2);

// Async work — block all navigation while busy
controller.setBusy(true);
await api.save();
controller.setBusy(false);
controller.nextStep();

CdxEdgeFade ​

Fades out each edge of a scroll view that has more content beyond it, so the view reads as one that scrolls. It follows the nearest scrollable below it, in either axis, and ignores scrollables nested further in. An edge fades only while content is hidden beyond it; the edges follow the scroll direction, so a right-to-left row fades its start on the right.

The fade is a mask: the content goes from opaque to transparent over extent (CdxEdgeFade.defaultExtent, CdxSpacing.lg), whatever it sits on, so it needs no background color, and taps reach the content under it. Nothing moves or resizes as a fade comes or goes, and no layer is pushed while no edge fades.

dart
CdxEdgeFade(
  child: SingleChildScrollView(
    scrollDirection: Axis.horizontal,
    child: Padding(
      // Keeps the end chips clear of the fade when scrolled to an end.
      padding: const EdgeInsets.symmetric(horizontal: CdxSpacing.sm),
      child: Row(children: chips),
    ),
  ),
)

CdxEdgeFadeState.fadesBefore and fadesAfter say which edges fade. A scrollable CdxSegmentedTabs uses it for its edges.

CdxRevealRegion and CdxRevealed ​

The one rule for a row's quiet controls (a remove button, a grip, an edit icon): hidden at rest, shown while the pointer is on the row or the focus is anywhere in it, and always shown where nothing can hover (a phone or a tablet with no mouse connected).

CdxRevealRegion wraps the row and tells its builder whether the row is revealed; the row adds reasons of its own (it is selected, open, being dragged) and hands the answer to each CdxRevealed.

dart
CdxRevealRegion(
  builder: (context, revealed) => Row(
    spacing: CdxSpacing.xs,
    children: [
      Expanded(child: Text(rule.name)),
      CdxRevealed(
        revealed: revealed || selected,
        child: Button.icon(
          icon: CdxIcons.delete,
          tooltip: 'Remove rule',
          onPressed: () async => remove(rule),
        ),
      ),
    ],
  ),
)

A hidden child keeps its room, so nothing shifts when it fades in; it cannot be tapped, stays in the tab order (Tab lands on it, which reveals it) and stays in the semantics tree. CdxRevealRegion.hoverAtHand() says whether a pointer that hovers is at hand. CdxDisclosure's trailing actions and CdxReorderReveal read the same rule.

  • Panels — CdxSlideInPanel is the modal alternative to MasterDetailScaffold's overlay mode
  • Buttons — Button.filled for wizard "Next" actions
  • State Views — AsyncView inside each tab pane