Skip to content

Stacked dialog

A multi-step dialog whose pages stack like cards.

Status
Beta
Category
Overlays
Adoption
Not used yet
import { StackedDialog } from "@oration/canon/components/stacked-dialog";
packages/canon/src/components/stacked-dialog.tsx
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

Next (or Done) is the only filled button, in the control bar. Extra actions beside it are ghost buttons, and pages don't add filled buttons of their own.

Exits are faster than enters

The leaving card fades in 160ms while the arriving card settles on a 240ms spring, so the stack never waits on the card going away.

Anatomy#

Choose your bank

Cedarline reads statements to match remittances.

1 of 3Skip
  1. 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.
  2. Page. The front card: Popover White, 16px corners, the dialog shadow and ring. It has no padding; your content pads itself.
  3. Close button. A 28px ghost icon button over the top right of the page. Leave room for it in your content.
  4. Previous. A 28px ghost icon button named Previous. On the first page it dims with aria-disabled instead of disabled, so it keeps focus.
  5. 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.
  6. Actions. Optional actions, such as Mark all as read, before Next.
  7. Next. A small filled button labelled nextLabel, becoming doneLabel on 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#

States
StateTreatment
OpeningFades in from a 0.96 scale over 200ms at defaultIndex, with the scrim. Focus lands on Next.
Paging forwardThe 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 backThe previous card returns from the left and the current one sinks back into the pile.
First pagePrevious dims to 40% and does nothing when pressed.
Last pageNext reads doneLabel and closes the dialog. No back cards remain.
Reduced motionWith 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: open and onOpenChange are required. Esc, the scrim, the close button and Done all close it.
  • The page index is uncontrolled by default and resets to defaultIndex each time it opens. Pass index and onIndexChange to 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 in onOpenChange, as the identity provider flow does before activating.
  • title and description name and describe the dialog for assistive tech only. Give each page a visible heading and pass the same words as its label.
  • 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.

Which overlay to use
ComponentReach for it whenShapeDismissed by
DialogA 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 48remEsc, a click on the scrim, the close button
Confirm dialogConfirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc or an answer. The scrim doesn't close it
Alert dialogA 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 24remEsc or an answer. The scrim doesn't close it
Form dialogCreating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, 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 barEsc, the scrim, Done, the close button
SheetRecord details, logs or a longer edit that should keep the list it came from in view.From an edge, 24rem wide by defaultEsc, the scrim, the close button
DrawerTouch-first flows on phones that people expect to swipe away. Not used in the product yet.From the bottom, up to the viewport minus 6remA swipe, Esc, the scrim

Do and don't#

2 of 4
Do. Keep it to three to six pages, each one short enough to read without scrolling.
2 of 12
Don't. Stack twelve pages. The dots stop meaning anything, and people close it halfway.
Do. Give every page a visible heading and pass it as label, so the page is announced by name.
Don't. Rely on the dots and 3 of 5 to tell people where they are.
Do. Put extra actions in actions as ghost buttons beside Next.
Don't. Put a filled button inside a page. It competes with Next and moves as the cards change.

Content#

  • title names the whole sequence: What's new, Connect a bank account.
  • Page headings are short and specific: Choose your bank, Faster remittance matching.
  • Keep nextLabel as Next. Make doneLabel the 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 title and described by the hidden description.
  • Each page is a <section> with aria-roledescription="page" and an aria-label from label, 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-disabled on the first page so focus isn't dropped.
  • The back cards are aria-hidden and ignore pointer events.
Keyboard interactions
KeysAction
→Next page.
←Previous page.
EnterActivates the focused button, usually Next.
TabMoves through the page and the control bar.
EscCloses the dialog.

Design tokens#

Design tokens
TokenUsed for
--popoverPage, back cards and control bar
shadow-lgPage and control bar
shadow-mdBack cards
--foregroundRings at 10% and 8%, and the dots
--radius-2xl16px corners on the pages
--radius-xl12px corners on the control bar
spring.slowThe arriving card, 240ms with 0.12 bounce
exit.slowThe leaving card, 160ms ease-out

API reference#

StackedDialog

The whole dialog. Takes no other props.

Props of StackedDialog
PropTypeDefaultDescription
openRequiredbooleanNo defaultWhether it is open.
onOpenChangeRequired(open: boolean) => voidNo defaultCalled by Esc, the scrim, the close button and Done.
pagesRequiredStackedDialogPage[]No defaultThe pages, in order.
indexnumberNo defaultControlled page index.
defaultIndexnumber0The page shown each time it opens, when uncontrolled.
onIndexChange(index: number) => voidNo defaultCalled when Next, Previous or an arrow key pages.
titleRequiredstringNo defaultThe dialog's accessible name. Not shown.
descriptionstringNo defaultThe dialog's accessible description. Not shown.
actionsReact.ReactNodeNo defaultExtra controls in the control bar, before Next.
nextLabelstring"Next"Next button label.
doneLabelstring"Done"Next button label on the last page.
classNamestringNo defaultMerged onto the popup, which holds the stack and the bar.

StackedDialogPage

Type of one page.

Props of StackedDialogPage
PropTypeDefaultDescription
idRequiredstringNo defaultStable key, used to animate the right card.
labelstringNo defaultAccessible name for the page, usually its heading.
contentRequiredReact.ReactNodeNo defaultThe 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.