Plan
The card an assistant hands you before it acts: what it intends to do, as a short list of to-dos you can read, edit and approve. Deliberately quiet, so status lives in a small mark at the head of each row.
Installation#
Usage#
import {
Plan,
PlanHeader,
PlanIcon,
PlanLabel,
PlanActions,
PlanHeading,
PlanTitle,
PlanDescription,
PlanStatus,
PlanGroup,
PlanGroupHeader,
PlanGroupTitle,
PlanGroupCount,
PlanSteps,
PlanStep,
PlanStepContent,
PlanStepTitle,
PlanStepDescription,
PlanStepTrigger,
PlanStepDetail,
PlanTasks,
PlanTask,
PlanStepAction,
PlanStepEdit,
PlanStepInput,
PlanStepSkeleton,
PlanAddStep,
PlanMore,
PlanFooter,
PlanSummary,
} from "@/components/ui/plan"
export function Example() {
return (
<Plan status="review">
<PlanHeader>
<PlanIcon><ListChecks weight="bold" /></PlanIcon>
<PlanLabel>Plan overview</PlanLabel>
</PlanHeader>
<PlanHeading>
<PlanTitle>Refresh the pricing page</PlanTitle>
</PlanHeading>
<PlanGroup>
<PlanGroupHeader>
<PlanGroupTitle>To-dos</PlanGroupTitle>
<PlanGroupCount>3</PlanGroupCount>
</PlanGroupHeader>
<PlanSteps>
<PlanStep>
<PlanStepContent>
<PlanStepTitle>Pull the three current tiers</PlanStepTitle>
</PlanStepContent>
</PlanStep>
{/* …more to-dos */}
</PlanSteps>
</PlanGroup>
<PlanFooter>
<PlanSummary>3 to-dos · about 2 min</PlanSummary>
<Button size="sm">Approve</Button>
</PlanFooter>
</Plan>
)
}Anatomy#
Four blocks, in reading order. An eyebrow says what kind of card this is (PlanIcon + PlanLabel, with PlanActions trailing). A heading says what the plan will do. A PlanGroup holds the work as a flat list under its own quiet label and count. A footer carries the meta and the decision — ghost on the left, primary on the right, per the house rule.
The card is the only container. Nothing inside it is boxed again and there are no dividers: groups are made by a label and whitespace, the way the Accordion's minimal variant makes rows. A panel inside a card reads as a slab in the dark themes and as a redundant line in the light ones, and every row shares the card's own left edge, so a plan has exactly one text axis from the eyebrow down to the footer. Every block is droppable.
To-do states#
Each PlanStep carries a status, and that mark plus one step of ink is the whole of its styling: a dashed ring while it waits, a brand spinner while it runs, a green check once it's done, a red warning when it isn't. Only the live row goes to full ink; a finished one fades back. No row fill, no rail, no numerals — a column of solid circles reads as a diagram, and this is a paragraph you happen to be able to click.
Collapsible to-dos#
Give a to-do collapsible and its PlanStepTrigger unfolds a PlanStepDetail with the sub-tasks it breaks down into. It wraps Radix Collapsible, so keyboard and ARIA come for free, and the panel opens on the DS collapsible keyframes. A plan mixes both shapes freely: rows with nothing to show stay plain. Sub-tasks are marked with a dash rather than a ring and sit a step quieter, so an unfolded row can never be mistaken for more to-dos of the plan itself.
Read-only, then edit#
A plan is read-only by default. That is how it's looked at nine times out of ten: you read what the assistant proposes and approve it. Editing is a mode you deliberately enter, so PlanStepEdit and PlanAddStep render nothing at all — not hidden, not transparent, not in the DOM — until the root gets editing. Leave them in your markup unconditionally and let the one flag decide; no call site can leave half the card in the wrong mode.
Inside edit mode, every row is already a field — there is no pencil to press first, because a pencil whose only job is to focus the input sitting under it is a button that shouldn't exist. Render a PlanStepInput in place of each PlanStepTitle: it inherits the row's type and its resting tone and paints no frame, so the rows sit exactly where they sat, in the ink they were in, and simply start taking keys. Ink arrives on the one you reach for or land in. The field grows with the copy, Escape cancels, and Enter is yours to wire — the demo opens the next to-do with it, the way every list editor behaves.
That leaves PlanStepEdit holding one button: remove. It fades in with the pointer — six rows each carrying a trash can is a toolbar, not a plan — but never away from the keyboard or from a touch screen. It is also out of the row's flow, which matters more than it sounds: in flow, a 32px button is taller than a 20px line, so every single-line to-do would grow to the button's height while a wrapped one stayed taller and the rhythm would fall apart the moment you pressed Edit. Instead the row reserves one uniform 36px gutter for it, so the list is sized by its copy in both modes.
Long lists and drafting#
A plan is something you scan, so a long one opens at a readable length and offers the rest: PlanMore is the row that closes the panel — “3 more” collapsed, “Show less” open. Which rows you actually render is yours; this is the control and the label.
While the model is still writing, drop PlanStepSkeleton rows in. They keep the list's exact geometry — the same mark column, the same row height — so the real to-dos land in place instead of pushing the panel around when they arrive. Vary width across rows so a draft reads organic rather than striped, and each row that mounts while the plan is drafting plays the DS entrance, which is all the cascade a stream needs: rows arrive one at a time already.
Status chip#
PlanStatus derives its chip from the root's status, so a label and a state can never drift apart, and the root sets aria-busy while the plan is still moving. It is optional on purpose: in the quiet default the eyebrow says what the card is and the footer says what happens next, and a chip on top of both is one badge too many. Reach for it when a plan sits in a feed where it has to announce itself.
Inside a conversation#
Set variant="plain" for a plan the assistant hands back inside a Chat turn or an AI Panel body. It drops the ring, the lift and the gutter, so the plan reads as part of the answer instead of a card inside a card. The panel keeps its own edge, which is all the structure the turn needs.
Density#
Like Card and Dialog, the Plan reads the density context (or a density prop). Here it tunes the row rhythm and the block gaps only. The card's 12px gutter and the panel's 4px inset are the concentric ladder — they set the panel's and the rows' corners — so they are fixed in both tiers, because a gutter that moves with density breaks that nesting in one of them.
API reference#
Plan
The root card and the context provider. Forwards all div props.
status?: "drafting" | "review" | "running" | "complete" | "failed"— the plan's own state. DrivesPlanStatus, the row entrance andaria-busy. Defaults to"review".editing?: boolean— put the card in edit mode. A plan is read-only by default;PlanStepEditandPlanAddSteprender nothing until this is on. Also exposed asdata-editing.variant?: "card" | "plain"—cardis the standalone artifact;plaindrops the chrome and the gutter for a plan rendered inside a Chat turn or an AI Panel.density?: "comfortable" | "compact"— row rhythm and block gaps; defaults from the density context.asChild?: boolean— render as a child element via Radix Slot.
PlanStep
status?: "pending" | "active" | "done" | "failed"— the row's 14px mark and its ink. Defaults to"pending".collapsible?: booleanplusopen/defaultOpen/onOpenChange— turns the row into a Radix Collapsible whose trigger isPlanStepTriggerand whose panel isPlanStepDetail.
PlanStepAction / PlanStepEdit
Two trailing slots with different lifetimes. PlanStepAction is what the row is telling you — a duration, a Badge, a Retry on a failed row — and is always visible. PlanStepEdit is what you can do to the row — in practice one Remove button, since every row is already a field in edit mode — renders nothing unless the plan is editing, and fades in with the pointer (staying visible on keyboard focus and on a coarse pointer, where there is no hover).
PlanStepInput
The inline editor that replaces a row's title while the plan is editing — every row at once, so there is no rename button. It inherits the row's type and resting tone, so flipping the mode changes nothing on screen. Controlled: value + onValueChange, with onCommit (Enter or blur) and onCancel (Escape). Shift+Enter still breaks a line and an open IME composition is never hijacked. Forwards all textarea props.
PlanMore / PlanAddStep
The two rows that close a list, both forwarding all button props. PlanMore takes expanded?: boolean and count?: number and labels itself “3 more” / “Show less”. PlanAddStep renders nothing unless the plan is editing.
PlanStepSkeleton
The placeholder row for a plan still being written. Takes width?: "full" | "long" | "short" so a draft reads organic.
Layout parts
PlanHeader, PlanIcon, PlanLabel, PlanActions, PlanHeading, PlanTitle (takes asChild), PlanDescription, PlanGroup + PlanGroupHeader / PlanGroupTitle / PlanGroupCount, PlanSteps, PlanTasks / PlanTask, PlanFooter and PlanSummary. Every part is droppable: a plan renders correctly with any subset of them.