Components
A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions.
Playground
form that owns the ordered items, the active item, answer state, validation, progress, and navigation. Define the collection once and pass it as items — that is what lets the active item, progress, actions, and shortcuts render on the server — then map the same collection into the parts. Answers serialize through native controls, so FormData.get(name) reads a single answer and getAll(name) reads a multiple one.import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire"
const items = [
{
choices: [{ value: "delegation" }, { value: "questions" }, { value: "both" }],
name: "direction",
required: true,
},
{ choices: [{ value: "focused" }, { value: "complete" }], name: "detail" },
] as const
<Questionnaire items={items} onSubmit={handleSubmit}>
<QuestionnaireProgress />
<QuestionnaireItem name="direction" required>
<QuestionnaireTitle>What should we prototype next?</QuestionnaireTitle>
<QuestionnaireDescription>
Choose a direction or write your own.
</QuestionnaireDescription>
<QuestionnaireChoices>
<QuestionnaireChoice value="delegation">
<span className="font-medium">Delegation</span>
<QuestionnaireChoiceDescription>
Show how work moves to a specialist.
</QuestionnaireChoiceDescription>
</QuestionnaireChoice>
<QuestionnaireChoice value="both">
<span className="font-medium">Both together</span>
</QuestionnaireChoice>
<QuestionnaireInput
aria-label="Another answer"
placeholder="Type another answer…"
/>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│ ├── QuestionnaireTitle
│ ├── QuestionnaireDescription
│ ├── QuestionnaireChoices
│ │ ├── QuestionnaireChoice
│ │ │ └── QuestionnaireChoiceDescription
│ │ └── QuestionnaireInput
│ └── QuestionnaireError
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmitmultiple turns the fixed choices of one item into checkboxes: the indicator squares off, the dot becomes a check, and the answer serializes as a list. Read it with FormData.getAll("context").<QuestionnaireItem name="context" multiple required>
<QuestionnaireTitle>What context should the agent inspect?</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="source">Relevant source files</QuestionnaireChoice>
<QuestionnaireChoice value="tests">Existing tests</QuestionnaireChoice>
<QuestionnaireChoice value="docs">Architecture documentation</QuestionnaireChoice>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>QuestionnaireInput inside QuestionnaireChoices when the user can write an answer the fixed list does not cover. The input shares the item name, so a typed answer satisfies a required item exactly like a selected choice. Give it a real accessible name — a placeholder is not a label.<QuestionnaireChoices>
<QuestionnaireChoice value="incremental">
Make the smallest safe change
</QuestionnaireChoice>
<QuestionnaireChoice value="module">
Refactor one module at a time
</QuestionnaireChoice>
<QuestionnaireInput
aria-label="Another refactoring approach"
placeholder="Describe another approach…"
/>
</QuestionnaireChoices>QuestionnaireSkip is visible only while the active item is optional, so it disappears on required items without any work on your side. A skipped item is absent from FormData — identical to an unanswered one — so use onStatusChangewhen the difference between "skipped" and "never asked" matters.const [status, setStatus] = React.useState<QuestionnaireItemStatus>("unanswered")
<QuestionnaireItem name="constraints" onStatusChange={setStatus}>
<QuestionnaireTitle>Are there any implementation constraints?</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="no-dependencies">
Do not add dependencies
</QuestionnaireChoice>
</QuestionnaireChoices>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext>Next</QuestionnaireNext>
<QuestionnaireSubmit>Submit brief</QuestionnaireSubmit>
</QuestionnaireActions>shortcuts assigns a letter or a number to every answer of the active item and renders it in the trailing chip. The keys are scoped to the questionnaire and pause while you type in a text field, so a freeform input never swallows a shortcut. Switch the mode above the question to compare the two.<Questionnaire items={items} shortcuts="letters" onSubmit={handleSubmit}>
{/* … */}
</Questionnaire>
<Questionnaire items={items} shortcuts="numbers" onSubmit={handleSubmit}>
{/* … */}
</Questionnaire>invalid on the offending item and move item back to it. Pass the message as children of QuestionnaireError. Here, a public audience rejects a summary-only answer.const [item, setItem] = React.useState("detail")
const [errors, setErrors] = React.useState<ValidationErrors>({})
<Questionnaire
item={item}
items={items}
onItemChange={setItem}
onSubmit={handleSubmit}
>
<QuestionnaireItem invalid={Boolean(errors.detail)} name="detail" required>
{/* … */}
<QuestionnaireError>{errors.detail}</QuestionnaireError>
</QuestionnaireItem>
</Questionnaire>item with onItemChange to hold the active item in host state. Questionnaire still validates before it requests a move, so a controlled root cannot skip past an unanswered required item — it just gives you a place to persist the checkpoint or to drive it from elsewhere in the page.Current checkpoint: Change scope
const [item, setItem] = React.useState("scope")
<Questionnaire
item={item}
items={items}
onItemChange={setItem}
onSubmit={handleSubmit}
>
<QuestionnaireProgress />
{/* … */}
</Questionnaire>defaultItem for the checkpoint and defaultChecked or defaultValue for the answers. Because the root is a native form, a plain <button type="reset"> returns the whole questionnaire to that saved state — item, answers, skips, and validation together.<Questionnaire
defaultItem="verification"
items={items}
onReset={() => toast("Saved answers restored")}
onSubmit={handleSubmit}
>
<QuestionnaireChoice value="tests" defaultChecked>
Run migration tests
</QuestionnaireChoice>
<QuestionnaireInput
aria-label="Saved migration note"
defaultValue="Keep the existing public API stable."
/>
<QuestionnaireActions>
<Button type="reset" variant="outline">
Reset changes
</Button>
</QuestionnaireActions>
</Questionnaire>disabled, not unmounting. A disabled item is omitted from progress and from navigation, so the total shrinks and Next steps over it — while the item itself stays mounted and keeps its answer if the branch comes back. Mirror the flag in the items collection so the server-rendered progress agrees with the client.const [runtime, setRuntime] = React.useState("local")
const items = React.useMemo(
() => [
{ name: "runtime", required: true },
{ disabled: runtime !== "cloud", name: "environment", required: true },
{ name: "approval", required: true },
],
[runtime]
)
<QuestionnaireItem disabled={runtime !== "cloud"} name="environment" required>
{/* … */}
</QuestionnaireItem>onStatusChange and pass disabled yourself. Every action also mirrors the active status to data-status, which is enough to restyle it without any state at all.<QuestionnaireItem
name="permission"
required
onStatusChange={(status) => setStatus("permission", status)}
>
{/* … */}
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext
className="data-[status=unanswered]:opacity-50"
disabled={unanswered}
variant="secondary"
>
Next
</QuestionnaireNext>
<QuestionnaireSubmit disabled={unanswered}>Save permissions</QuestionnaireSubmit>
</QuestionnaireActions>QuestionnaireProgress takes a render function whose second argument carries current, total, first, and last. Spread the first argument onto your element to keep the progressbar role and its text value; mark purely decorative graphics aria-hidden so the count is announced once.<QuestionnaireProgress
className="w-full"
render={(props, state) => (
<div {...props}>
<div className="mb-2 flex gap-1.5" aria-hidden="true">
{Array.from({ length: state.total }, (_, index) => (
<span
key={index}
className={
index < state.current
? "h-1.5 flex-1 rounded-full bg-primary"
: "h-1.5 flex-1 rounded-full bg-muted"
}
/>
))}
</div>
<span>
Checkpoint {state.current} of {state.total}
</span>
</div>
)}
/>QuestionnaireItem and keys off data-active. Progress and the action row sit outside the item and stay stationary, which is what makes the transition read as one question replacing another rather than the whole form moving. Always pair it with motion-reduce:animate-none.const itemClassName =
"data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:animate-none"
<QuestionnaireItem className={itemClassName} name="task" required>
{/* … */}
</QuestionnaireItem>render swaps a part for another component while keeping its behavior, which is how the question title becomes a CardTitle. That costs the native legend, so give the title an id and point the item at it with aria-labelledby — otherwise the fieldset loses its accessible name.const taskTitleId = React.useId()
<Card>
<QuestionnaireItem aria-labelledby={taskTitleId} name="task" required>
<CardHeader>
<QuestionnaireTitle id={taskTitleId} render={<CardTitle />}>
What should the agent work on?
</QuestionnaireTitle>
<QuestionnaireDescription render={<CardDescription />}>
Choose the task that should be handled next.
</QuestionnaireDescription>
<CardAction>
<QuestionnaireProgress />
</CardAction>
</CardHeader>
<CardContent>{/* choices */}</CardContent>
</QuestionnaireItem>
<CardFooter>
<QuestionnaireActions className="w-full">
<QuestionnairePrevious />
<QuestionnaireNext>Next</QuestionnaireNext>
<QuestionnaireSubmit>Create task</QuestionnaireSubmit>
</QuestionnaireActions>
</CardFooter>
</Card>DialogContent so each item can render its own header, and close the dialog from onSubmit rather than from the submit button — the button has to stay a real submit for validation to run.<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Open clarification</Button>
</DialogTrigger>
<DialogContent>
<Questionnaire defaultItem="scope" items={items} onSubmit={handleSubmit}>
<QuestionnaireItem name="scope" required>
<DialogHeader>
<QuestionnaireProgress />
<QuestionnaireTitle render={<DialogTitle />}>
Which files are in scope?
</QuestionnaireTitle>
</DialogHeader>
{/* choices */}
</QuestionnaireItem>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext>Next</QuestionnaireNext>
<QuestionnaireSubmit>Send answer</QuestionnaireSubmit>
</QuestionnaireActions>
</DialogFooter>
</Questionnaire>
</DialogContent>
</Dialog>onKeyDown turns questionnaire key handling off entirely.| Key | Behavior |
|---|---|
| Tab | Moves focus between answer controls and visible actions. |
| ShiftTab | Moves focus to the previous control or action. |
| ↑↓ | Moves between answers from the item, a fixed answer, or an empty freeform input. Native radios also select on move. |
| ←→ | Moves to the previous or next item when focus is outside a radio or text entry control. Forward also requires the active item to be answered or skipped. |
| Space | Selects a radio, toggles a checkbox, or activates a focused action. |
| Enter | Continues from a selected choice or a filled input; activates a focused action. |
| ⌘Enter | Validates and continues from anywhere inside the questionnaire, or submits the final item. |
QuestionnaireItem renders a fieldset and QuestionnaireTitle renders its legend, so each question carries its own accessible name; the description and the active error are connected with aria-describedby, and an invalid item exposes aria-invalid on itself and on its answer controls. Fixed choices stay native radios and checkboxes, progress is a named progressbar, navigation uses real buttons, and inactive items and actions are hidden and inert. Successful navigation focuses the newly active item; failed validation focuses an available answer control. Two things are on you: give every QuestionnaireInput a real name with a visible label, an aria-label, or aria-labelledby — a placeholder is not a label — and when render replaces the title so it is no longer a legend, point the item at it with aria-labelledby.<QuestionnaireInput
aria-label="Another refactoring approach"
placeholder="Describe another approach…"
/>
<QuestionnaireItem aria-labelledby={taskTitleId} name="task" required>
<QuestionnaireTitle id={taskTitleId} render={<CardTitle />}>
What should the agent work on?
</QuestionnaireTitle>
</QuestionnaireItem>@shadcn/react/questionnaire primitives; the navigation actions add Button size and variant, and QuestionnaireActions is a styled-only layout helper with no behavior of its own. Every part accepts render to swap its element, and all native props pass through.| Part | Prop | Default |
|---|---|---|
| Questionnaire | item?: string | — |
| Questionnaire | defaultItem?: string | first item |
| Questionnaire | items?: readonly QuestionnaireItemDefinition[] | — |
| Questionnaire | onItemChange?: (item: string) => void | — |
| Questionnaire | shortcuts?: "letters" | "numbers" | — |
| Questionnaire | noValidate?: boolean | true |
| QuestionnaireProgress | children?: ReactNode | Question {current} of {total} |
| QuestionnaireProgress | render?: ReactElement | (props, state) => ReactElement | <div> |
| QuestionnaireItem | name: string | required |
| QuestionnaireItem | required?: boolean | false |
| QuestionnaireItem | multiple?: boolean | false |
| QuestionnaireItem | disabled?: boolean | false |
| QuestionnaireItem | invalid?: boolean | false |
| QuestionnaireItem | onStatusChange?: (status: QuestionnaireItemStatus) => void | — |
| QuestionnaireChoice | value: string | required |
| QuestionnaireChoice | checked?: boolean · defaultChecked?: boolean | false |
| QuestionnaireInput | type?: QuestionnaireInputType | "text" |
| QuestionnaireError | children?: ReactNode | contextual message |
| Previous · Skip · Next · Submit | size?: Button size · variant?: Button variant | "default" |
Tokens used
18 design tokens consumed by the Questionnaire component.
--primary
Primary
--primary-foreground
Primary Foreground
--muted
Muted
--muted-foreground
Muted Foreground
--background
Background
--input
Input
--ring
Ring
--destructive
Destructive
--radius-md
Radius Medium
--radius-sm
Radius Small
--shadow-xs
Shadow Extra Small
--font-heading
Heading font (GT Alpina)
--font-mono
Mono font (Geist Mono)
--text-base
Text Base (16px)
--text-sm
Text Small (14px)
--text-xs
Text Extra Small (12px)
--font-weight-medium
Font Weight Medium (500)
--spacing
Spacing unit (4px)