Stacked dialog
A multi-step dialog whose pages stack like cards.
import { Button } from "@oration/canon/components/button";import { StackedDialog } from "@oration/canon/components/stacked-dialog";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { MegaphoneIcon } from "lucide-react";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); const [read, setRead] = React.useState(false); const updates = [ { id: "matching", tag: "New", date: "Sep 24, 2026", title: "Remittances match themselves", body: "Incoming remittance advice is matched to open invoices by amount, date and reference. Anything under 90% confidence waits in Review.", }, { id: "w9", tag: "Improved", date: "Sep 17, 2026", title: "W-9 reminders on a schedule", body: "Suppliers with a missing or expired W-9 get a reminder every 7 days until one is on file. Change the cadence in Supplier settings.", }, { id: "runs", tag: "New", date: "Sep 9, 2026", title: "Approve payment runs from Slack", body: "Approvers get the run summary in Slack and can approve or hold it there. Holds still need a reason.", }, ]; return ( <> <Button type="button" variant="outline" onClick={() => setOpen(true)} > <MegaphoneIcon data-icon="inline-start" aria-hidden="true" /> What's new </Button> <StackedDialog open={open} onOpenChange={setOpen} title="What's new" description="Recent updates to Cedarline payables." pages={updates.map((update) => ({ id: update.id, label: update.title, content: ( <article className="flex min-h-64 flex-col"> <div className="flex h-28 items-center justify-center border-b border-border/70 bg-muted/40"> <MegaphoneIcon aria-hidden="true" className="size-8 text-muted-foreground" /> </div> <div className="flex flex-col gap-2.5 p-5 pt-4"> <div className="flex items-center gap-2 text-xs text-muted-foreground"> <Tag color={ update.tag === "New" ? "green" : "blue" } > {update.tag} </Tag> <span className="ml-auto tabular-nums"> {update.date} </span> </div> <h2 className="text-base font-semibold text-balance"> {update.title} </h2> <p className="text-sm/relaxed text-pretty text-muted-foreground"> {update.body} </p> </div> </article> ), }))} actions={ <Button type="button" variant="ghost" size="sm" aria-disabled={read || undefined} onClick={() => { setRead(true); toast.add({ title: "All updates marked as read" }); }} className="text-muted-foreground aria-disabled:opacity-50 max-sm:hidden" > {read ? "All read" : "Mark all as read"} </Button> } /> </> );}Usage#
Stacked dialog shows three to six pages that are read or completed in order, as a pile of cards with a control bar below. Next slides the front card away and the one behind rises; Previous brings it back. The product uses it for What's new and for adding an identity provider. The control bar never moves, so focus stays on Next while the pages change. It is not a wizard for long forms: each page should fit on one card and stand on its own.
When to use
- For a short sequence people page through: what's new, a feature tour, the steps of connecting a bank feed.
- For setup with three or four steps where each step is one decision, ending in Activate or Connect.
- When people should be able to go back a page without losing what they entered.
When not to use
- For one task on one screen, even a long one. Use Dialog
- For showing progress through stages on the page itself, such as an invoice moving from received to paid. Use Stepper
- For a single form with a submit. Use Form dialog
- For a collapsed pile of cards on a page, such as the what's new cards in the sidebar. Use Card stack
- For slides people browse in any order. Use Carousel
The One Filled Button Rule
actions beside it are ghost buttons, and pages don't add filled buttons of their own.Exits are faster than enters
Anatomy#
Choose your bank
Cedarline reads statements to match remittances.
- Back cards. Up to two decorative cards peek 10px each above the front one, at 95% and 90% scale and 75% and 50% opacity. They show how many pages are left.
- Page. The front card: Popover White, 16px corners, the dialog shadow and ring. It has no padding; your
contentpads itself. - Close button. A 28px ghost icon button over the top right of the page. Leave room for it in your content.
- Previous. A 28px ghost icon button named Previous. On the first page it dims with
aria-disabledinstead ofdisabled, so it keeps focus. - Progress. Dots (16px for the current page, ink at 35% behind it and 15% ahead) and an n of m count that is announced when it changes.
- Actions. Optional
actions, such as Mark all as read, before Next. - Next. A small filled button labelled
nextLabel, becomingdoneLabelon the last page. It takes focus when the dialog opens.
Examples#
A setup flow
Control index so closing can tell whether people finished. doneLabel names the outcome, and onOpenChange acts only when the last page closes it. Pages pad themselves and leave room for the close button.
import { Button } from "@oration/canon/components/button";import { Field, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { StackedDialog } from "@oration/canon/components/stacked-dialog";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, LandmarkIcon } from "lucide-react";import * as React from "react";export function SetupFlow() { const [open, setOpen] = React.useState(false); const [index, setIndex] = React.useState(0); const [bank, setBank] = React.useState("Chase"); const [routing, setRouting] = React.useState("021000021"); const [account, setAccount] = React.useState(""); const routingId = React.useId(); const accountId = React.useId(); const banks = ["Chase", "Wells Fargo", "Bank of America", "SVB"]; const last = 2; return ( <> <Button type="button" variant="outline" onClick={() => { setIndex(0); setOpen(true); }} > <LandmarkIcon data-icon="inline-start" aria-hidden="true" /> Connect a bank account </Button> <StackedDialog open={open} onOpenChange={(next) => { if (!next && index === last) { toast.add({ type: "success", title: `${bank} connected`, description: "Statements sync every morning at 6:00 AM CT.", }); } setOpen(next); }} index={index} onIndexChange={setIndex} title="Connect a bank account" doneLabel="Connect bank" pages={[ { id: "bank", label: "Choose your bank", content: ( <div className="flex flex-col gap-4 p-5 pr-12"> <div className="flex flex-col gap-1"> <h3 className="text-base font-semibold"> Choose your bank </h3> <p className="text-sm text-muted-foreground"> Cedarline reads statements to match remittances. </p> </div> <div className="grid grid-cols-2 gap-2"> {banks.map((option) => ( <button key={option} type="button" aria-pressed={bank === option} onClick={() => setBank(option)} className={cn( "flex h-10 items-center justify-between rounded-[10px] px-3 text-left text-13 shadow-border transition-shadow duration-150 ease-out outline-none hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40", bank === option && "bg-primary/6 font-medium shadow-[inset_0_0_0_1px_var(--primary)]", )} > {option} {bank === option ? ( <CheckIcon aria-hidden="true" className="size-4 text-primary" /> ) : null} </button> ))} </div> </div> ), }, { id: "details", label: "Account details", content: ( <div className="flex flex-col gap-4 p-5 pr-12"> <div className="flex flex-col gap-1"> <h3 className="text-base font-semibold"> Account details </h3> <p className="text-sm text-muted-foreground"> The operating account payment runs draw from. </p> </div> <Field> <FieldLabel htmlFor={routingId}> Routing number </FieldLabel> <Input id={routingId} inputMode="numeric" value={routing} onChange={(event) => setRouting(event.target.value) } className="font-mono" /> </Field> <Field> <FieldLabel htmlFor={accountId}> Account number </FieldLabel> <Input id={accountId} inputMode="numeric" placeholder="000123456789" value={account} onChange={(event) => setAccount(event.target.value) } className="font-mono" /> </Field> </div> ), }, { id: "review", label: "Review", content: ( <div className="flex flex-col gap-4 p-5 pr-12"> <div className="flex flex-col gap-1"> <h3 className="text-base font-semibold"> Review </h3> <p className="text-sm text-muted-foreground"> We send two small deposits to verify the account. </p> </div> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-[10px] bg-muted/70 p-3 text-13"> <dt className="text-muted-foreground"> Bank </dt> <dd className="text-right">{bank}</dd> <dt className="text-muted-foreground"> Routing </dt> <dd className="text-right font-mono"> {routing} </dd> <dt className="text-muted-foreground"> Account </dt> <dd className="text-right font-mono"> {account ? `ending ${account.slice(-4)}` : "Not entered"} </dd> </dl> </div> ), }, ]} /> </> );}Opening at a page
defaultIndex picks the page shown each time it opens, so a list can open the sequence at the item that was clicked. Previous still reaches the earlier pages.
import { StackedDialog } from "@oration/canon/components/stacked-dialog";import * as React from "react";export function StartAtPage() { const [open, setOpen] = React.useState(false); const [start, setStart] = React.useState(0); const tips = [ { id: "hold", title: "Hold an invoice", body: "Held invoices stay out of every payment run until someone releases them. Add a reason so the approver knows why.", }, { id: "split", title: "Split a payment", body: "Pay part of an invoice now and the rest in a later run. The remaining balance keeps the original due date.", }, { id: "early", title: "Take an early-pay discount", body: "Invoices with 2/10 net 30 terms are flagged when paying inside 10 days saves money.", }, ]; return ( <div className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> <ul className="flex flex-col p-2"> {tips.map((tip, i) => ( <li key={tip.id}> <button type="button" onClick={() => { setStart(i); setOpen(true); }} className="flex w-full items-center justify-between rounded-lg px-2 py-2 text-left text-13 outline-none transition-colors duration-150 ease-out hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > {tip.title} <span className="text-xs text-muted-foreground tabular-nums"> {i + 1} of {tips.length} </span> </button> </li> ))} </ul> <StackedDialog open={open} onOpenChange={setOpen} defaultIndex={start} title="Payment run tips" pages={tips.map((tip) => ({ id: tip.id, label: tip.title, content: ( <div className="flex min-h-40 flex-col gap-2 p-5 pr-12"> <h3 className="text-base font-semibold"> {tip.title} </h3> <p className="text-sm/relaxed text-pretty text-muted-foreground"> {tip.body} </p> </div> ), }))} /> </div> );}States#
| State | Treatment |
|---|---|
| Opening | Fades in from a 0.96 scale over 200ms at defaultIndex, with the scrim. Focus lands on Next. |
| Paging forward | The front card leaves to the left, rotating 3 degrees as it fades in 160ms. The next card rises from 12px above at a 0.95 scale on a 240ms spring. |
| Paging back | The previous card returns from the left and the current one sinks back into the pile. |
| First page | Previous dims to 40% and does nothing when pressed. |
| Last page | Next reads doneLabel and closes the dialog. No back cards remain. |
| Reduced motion | With the app's MotionConfig reducedMotion="user", the slides and scales drop out and pages cross-fade. The dots stop animating their width. |
Behavior#
- Controlled open state:
openandonOpenChangeare required. Esc, the scrim, the close button and Done all close it. - The page index is uncontrolled by default and resets to
defaultIndexeach time it opens. PassindexandonIndexChangeto control it, for example to gate a step or to act on close depending on where people are. - The right and left arrow keys page, unless focus is in an input, textarea, select or editable region.
- Done calls
onOpenChange(false). To act on completion, check the index inonOpenChange, as the identity provider flow does before activating. titleanddescriptionname and describe the dialog for assistive tech only. Give each page a visible heading and pass the same words as itslabel.- Built on Base UI Dialog with the dialog
data-slots, so it shares Dialog's 200ms entrance and 140ms exit. Paging uses Motion: the arriving card on the slow spring (240ms, slight bounce), the leaving card on a 160ms ease-out tween.
Choosing an overlay#
Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog | A focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer. | Centered, 24rem by default, up to 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog | A decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it. | Centered, 20 or 24rem | Esc or an answer. The scrim doesn't close it |
| Form dialog | Creating or editing one record with two to six fields, a submit and a cancel. | Centered, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog (this page) | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
label, so the page is announced by name.actions as ghost buttons beside Next.Content#
titlenames the whole sequence: What's new, Connect a bank account.- Page headings are short and specific: Choose your bank, Faster remittance matching.
- Keep
nextLabelas Next. MakedoneLabelthe outcome: Connect bank, Activate, or Done for something only read. - Actions are short ghost labels: Mark all as read, Skip tour.
Accessibility#
- The popup is a Base UI dialog named by the hidden
titleand described by the hiddendescription. - Each page is a
<section>witharia-roledescription="page"and anaria-labelfromlabel, falling back to n of m. - The n of m count is
aria-live="polite", so paging is announced. The dots are hidden from assistive tech. - Focus starts on Next and stays there while pages change, because the control bar doesn't move. Previous uses
aria-disabledon the first page so focus isn't dropped. - The back cards are
aria-hiddenand ignore pointer events.
| Keys | Action |
|---|---|
| → | Next page. |
| ← | Previous page. |
| Enter | Activates the focused button, usually Next. |
| Tab | Moves through the page and the control bar. |
| Esc | Closes the dialog. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Page, back cards and control bar |
shadow-lg | Page and control bar |
shadow-md | Back cards |
--foreground | Rings at 10% and 8%, and the dots |
--radius-2xl | 16px corners on the pages |
--radius-xl | 12px corners on the control bar |
spring.slow | The arriving card, 240ms with 0.12 bounce |
exit.slow | The leaving card, 160ms ease-out |
API reference#
StackedDialog
The whole dialog. Takes no other props.
| Prop | Type | Default | Description |
|---|---|---|---|
openRequired | boolean | No default | Whether it is open. |
onOpenChangeRequired | (open: boolean) => void | No default | Called by Esc, the scrim, the close button and Done. |
pagesRequired | StackedDialogPage[] | No default | The pages, in order. |
index | number | No default | Controlled page index. |
defaultIndex | number | 0 | The page shown each time it opens, when uncontrolled. |
onIndexChange | (index: number) => void | No default | Called when Next, Previous or an arrow key pages. |
titleRequired | string | No default | The dialog's accessible name. Not shown. |
description | string | No default | The dialog's accessible description. Not shown. |
actions | React.ReactNode | No default | Extra controls in the control bar, before Next. |
nextLabel | string | "Next" | Next button label. |
doneLabel | string | "Done" | Next button label on the last page. |
className | string | No default | Merged onto the popup, which holds the stack and the bar. |
StackedDialogPage
Type of one page.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable key, used to animate the right card. |
label | string | No default | Accessible name for the page, usually its heading. |
contentRequired | React.ReactNode | No default | The page body. Pad it yourself (the product uses 20px). |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Pages use 16px corners (rounded-2xl). DESIGN.md gives dialogs and cards 12px container corners, and 16px isn't in the ramp.
The arriving card uses the slow spring with a 0.12 bounce. DESIGN.md's motion grammar is ease-out everywhere, and other springs in the suite have no bounce.
Previous is icon-only with no tooltip.
The close button sits over the page content with no reserved space, so every page has to pad its top right by hand.
The dialog title is always hidden. There is no visible heading for the sequence as a whole.