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.
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
PanelPresentation.docked() // side-by-side row
PanelPresentation.overlay(showBarrier: true) // floating sheet from right
PanelPresentation.auto(breakpoint: 1024) // docked at wide, overlay at narrow| Behavior | Docked | Overlay |
|---|---|---|
| Layout | Row(main, panel) | Stack(main, scrim, panel) |
| Resizable edge | yes (drag handle on left of panel) | no |
| Barrier dismiss | n/a | tap scrim closes (when showBarrier: true) |
| ESC key | both — closes panel via onPanelClosed | both |
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).
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.
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 syncRouteAwareTabBar—TabBarwith the controller wiredRouteAwareLazyIndexedStack— 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.
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),
],
)variant | Look |
|---|---|
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.rail | A 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.statussays complete or in error; which step is current comes fromcurrentIndex. A current step in error keeps the error tone.- A step with no
captionshows its status label ("Complete", "In progress", "Not started", "Needs attention").showStatusCaptions: falsehides those. CdxStep.enabled: falseis a step a linear flow has not reached: muted, no hover, no taps, announced as unavailable.CdxStep.busy: trueswaps 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
onStepTappedthe 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 toonStepTapped, but stays the tab stop. WithoutonStepTappedthe 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.
sizeisCdxControlSize.standardor.compact.semanticLabelnames 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:
Wizard(
controller: controller,
steps: steps,
decoration: const BoxDecoration(gradient: brandGradient),
sidebarBuilder: (context, rail) =>
Theme(data: onGradient(Theme.of(context)), child: rail),
)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
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.
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.
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.
Cross-links
- Panels —
CdxSlideInPanelis the modal alternative toMasterDetailScaffold's overlay mode - Buttons —
Button.filledfor wizard "Next" actions - State Views —
AsyncViewinside each tab pane