Questionnaire
A guided one-question-at-a-time form — fixed choices, a freeform answer, explicit skip, shortcut keys, validation, resume and conditional items.
- Status
- Since
0.1.0- Accessibility pattern
- fieldset/legend per question, polite progressbar
Last updated
No answer submitted yet.
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Questionnaire before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify questionnaireInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/questionnaireRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button.
It also adds the sanctioned engine to your package.json: @shadcn/react.
Usage
Declare the collection once. Passing it to Questionnaire as items is what lets progress, the
navigation actions and the answer shortcuts render correctly on the server, before any measurement
happens on the client.
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const items = [
{ name: "direction", required: true },
{ 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">Delegation</QuestionnaireChoice>
<QuestionnaireChoice value="questions">
Question prompts
</QuestionnaireChoice>
<QuestionnaireInput
aria-label="Another answer"
placeholder="Type another answer…"
/>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>;The root is a real <form>, so the answers arrive as FormData — get for a single answer,
getAll for a multiple item.
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const answers = new FormData(event.currentTarget);
// answers.get("direction"), answers.getAll(...) for multiple items.
}No answer submitted yet.
Anatomy
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│ ├── QuestionnaireTitle
│ ├── QuestionnaireDescription
│ ├── QuestionnaireChoices
│ │ ├── QuestionnaireChoice
│ │ │ └── QuestionnaireChoiceDescription
│ │ └── QuestionnaireInput
│ └── QuestionnaireError
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmitQuestionnaire owns the ordered items, the active item, the answer state, validation, progress and navigation. The page, card, dialog or drawer around it owns close and cancellation behaviour, persistence, transport and branching.
Examples
Composition
Each QuestionnaireItem is a <fieldset> and its QuestionnaireTitle is the <legend>, so a
question is a real group whichever chrome you wrap it in. Only the active item is rendered — the
rest are hidden and inert.
No answer submitted yet.
Server Rendering
Pass items and the active item, progress, actions and answer shortcuts are all server-rendered.
Without it the flow still works, but the first paint has to wait for the client to discover how many
questions there are.
No answer submitted yet.
Multiple Selection
multiple on an item turns its choices into checkboxes and its answer into an array. Read it with
FormData.getAll(name).
No answer submitted yet.
Freeform Answer
Compose QuestionnaireInput inside QuestionnaireChoices when the reader may supply an answer you
did not list. It is one more option in the same group: filling it clears the fixed choices, and
picking a fixed choice clears it. Always give it an accessible name — a placeholder is not a label.
No answer submitted yet.
Explicit Skip
Add QuestionnaireSkip when an optional item may be left unanswered on purpose. onStatusChange
reports "unanswered" | "answered" | "skipped", so "deliberately skipped" is a value you can store
rather than an absence you have to guess at.
No answer submitted yet.
Shortcuts
shortcuts="letters" or "numbers" assigns a key to each answer and renders it in the choice's
shortcut slot. The engine also puts the key on aria-keyshortcuts, so it is announced rather than
merely drawn.
No answer submitted yet.
Custom Validation
Combine controlled navigation with an external schema — Zod here — to return to an invalid item and
present its error. invalid on the item drives aria-invalid and reveals QuestionnaireError,
which is role="alert" only while it is showing.
No answer submitted yet.
Controlled
item plus onItemChange hands the active item to the host, which is what lets a second control —
a checkpoint label, a router, a saved draft — read the same value.
Current checkpoint: Change scope
No answer submitted yet.
Resume
defaultItem restores the saved position and defaultChecked / defaultValue restore the saved
answers. A native <button type="reset"> returns the form to exactly that state, and onReset is
where you tell the reader it happened.
No answer submitted yet.
Conditional Items
Mark an item disabled — in the items collection and on the item itself — when an earlier answer
makes it irrelevant. Navigation steps over it and progress stops counting it, so the reader never
lands on a question that does not apply.
No answer submitted yet.
Navigation State
Every navigation part exposes its own state — disabled, visible, status and the Enter
shortcut — so you can hold Next until the question is answered, or restyle it from
data-status.
No answer submitted yet.
Custom Progress
QuestionnaireProgress takes a render prop with { current, first, last, total }. Render whatever
you like inside it; the engine keeps the role="progressbar", the value attributes and the polite
announcement on the element you return.
No answer submitted yet.
Animated Items
Animate the active item and leave progress and navigation stationary — the eye should follow the
question, not the chrome. data-active:motion-enter-up is the system's fade-and-rise arrival, and
base.css collapses it under prefers-reduced-motion.
No answer submitted yet.
Card
Compose with Card slots while keeping the question's semantics: render the title through
render={<CardTitle />} and give the item an aria-labelledby pointing at it, so the fieldset is
still named by its question.
No answer submitted yet.
Dialog
Inside a Dialog, cancellation and dismissal stay host-owned: DialogClose is not a Questionnaire
part, and closing the dialog is not the same as answering the question.
No answer submitted yet.
Unstyled
The behaviour comes from @shadcn/react/questionnaire. Mount those parts directly when the flow has
to look like something else entirely; the styled components above are that engine plus our token
chrome and nothing more.
No answer submitted yet.
API Reference
Questionnaire
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly QuestionnaireItemDefinition[] | — | The ordered collection: { name, required?, disabled?, choices? }. Pass it to server-render progress, actions and shortcuts. |
defaultItem | string | — | Uncontrolled starting item, by name. Use it to resume a saved flow. |
item | string | — | Controlled active item, by name. |
onItemChange | (item: string) => void | — | Called when the active item changes. |
shortcuts | "letters" | "numbers" | — | Assign a key to each answer and expose it on aria-keyshortcuts. Omit for no shortcuts. |
Everything else a <form> accepts — onSubmit, onReset, noValidate, className, ref — passes
through.
QuestionnaireItem
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | The answer's form field name, and the item's identity for navigation and progress. |
required | boolean | false | Block navigation past this item until it is answered or skipped. |
multiple | boolean | false | Accept more than one fixed answer. Choices become checkboxes and the answer becomes an array. |
disabled | boolean | false | Skip this item entirely: navigation steps over it and progress stops counting it. |
invalid | boolean | false | Mark the item invalid. Drives aria-invalid and reveals QuestionnaireError. |
onStatusChange | (status: "unanswered" | "answered" | "skipped") => void | — | Called whenever this item's answer status changes. |
QuestionnaireChoice
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The submitted value for this answer. |
checked | boolean | — | Controlled checked state. |
defaultChecked | boolean | — | Uncontrolled initial checked state — how a resumed answer is restored. |
disabled | boolean | false | Disable this answer. It stays hoverable so a Tooltip can explain why (FRM-4). |
onChange | React.ChangeEventHandler<HTMLInputElement> | — | Called when this answer is picked or cleared. |
QuestionnaireInput
| Prop | Type | Default | Description |
|---|---|---|---|
type | "text" | "email" | "number" | "password" | "search" | "tel" | "url" | "date" | "datetime-local" | "month" | "time" | "week" | "text" | The input type. There is no textarea variant. |
aria-label | string | — | Required unless a visible label or aria-labelledby names it. A placeholder is not a label. |
QuestionnaireProgress
| Prop | Type | Default | Description |
|---|---|---|---|
render | (props, state: { current: number; first: boolean; last: boolean; total: number }) => React.ReactElement | — | Render your own indicator. The engine keeps role=progressbar, the value attributes and the polite announcement on the element you return. |
QuestionnairePrevious, QuestionnaireSkip, QuestionnaireNext, QuestionnaireSubmit
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "outline" for Previous and Skip, "default" for Next and Submit | Forwarded to buttonVariants. |
size | "xs" | "sm" | "default" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "default" | Forwarded to buttonVariants. |
disabled | boolean | — | Force the control disabled. The engine already disables it when the step does not allow it. |
QuestionnaireActions, QuestionnaireTitle, QuestionnaireDescription, QuestionnaireChoices,
QuestionnaireChoiceDescription and QuestionnaireError add no props of their own — each accepts
everything the underlying element accepts, plus render where the engine exposes one.
Accessibility
QuestionnaireItemrenders a<fieldset>andQuestionnaireTitlerenders its<legend>, so a screen reader announces the question before every answer inside it. Descriptions and active errors are associated with the current item, and invalid items and answer controls exposearia-invalid.- Fixed choices are native radios and checkboxes, so arrow keys, Space and grouping all
behave the way the platform already taught the reader. The real control is an
opacity-0input stretched over the card, which is why the card itself wears theaccent/50 background tint — not a cue on something invisible — as the focus affordance. Its border never changes colour on focus (FOC-1, FOC-6, FOC-14). QuestionnaireInput, the free-text answer field, is text entry: focus darkens its border subtly (border-ring/50, easing in over 150ms) and paints no fill — FOC-14's text-entry exception (MK 2026-09-27). An invalid answer keeps its destructive border while focused.QuestionnaireProgressis a namedrole="progressbar"witharia-live="polite", so a step change is announced at a comfortable pace rather than interrupting (A11Y-3).QuestionnaireErrorisrole="alert"only while the item is invalid, andhiddenotherwise — an alert for destructive content rendered after mount, never static chrome. Those two are the only live regions in the component, which is A11Y-4's one-region rule met by the engine itself.- Navigation uses real buttons; inactive items and actions are
hiddenandinert. Successful navigation focuses the newly active item, and failed validation focuses an available answer control. - Every target clears the 24px floor: the choice card and the answer input are
min-h-11(44px), and the four navigation buttons aremin-h-11on touch widths, relaxing to theButtontier atsm:. - Always give
QuestionnaireInputan accessible name with a visible label,aria-labeloraria-labelledby. A placeholder is not a label.
No answer submitted yet.
| Contract | States tested |
|---|---|
| Behaviour | default, checked, disabled, invalid, active, skipped |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, live-region |
| Visual | default, hover, focus, checked, invalid, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/questionnaire.patch. Every hunk:
- FOC-1, FOC-6 (choice) —
QuestionnaireChoicedropshas-[>input:focus-visible]:ring-3andhas-[>input:focus-visible]:ring-ring/50, and FOC-14 replaceshas-[>input:focus-visible]:border-ringwith theaccent/50 background tint (has-[>input:focus-visible]:bg-linear-to-b from-accent/50 to-accent/50, laid as an image over the fill exactly asbase.csslays it). The real control is anopacity-0input stretched over the whole card, sobase.css's tint paints on something invisible — the same shapeinput-otp's hidden input has, and the same answer: the visible card wears the cue. - FOC-14 (choice) —
data-invalid:border-destructivestays as upstream wrote it: the invalid border holds in every state, focused or not, exactly as it does oninput,checkboxandselect. - FRM-4 (choice) —
data-disabled:pointer-events-noneis dropped, so a disabled choice stays hoverable and a Tooltip can explain it. The engine keeps the input's owndisabled, so nothing becomes answerable; upstream'sdata-disabled:cursor-not-allowedstays. - FOC-1, FOC-3, FOC-6, FOC-8, FOC-14, FRM-4 (input) —
QuestionnaireInputtakes the same treatmentinput.tsxtakes:outline-nonebecomesoutline-hidden(so the FOC-7 forced-colours block has an outline to repaint),focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50is removed — no ring and no glow; the focus cue is the text-entry border below —aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20becomesaria-invalid:border-destructivewith its dark counterpart, held in every state, anddisabled:pointer-events-noneis dropped. - FOC-14 text-entry exception (input, MK 2026-09-27) —
QuestionnaireInputaddsfocus:not-aria-invalid:border-ring/50withtransition-[color,background-color,border-color] duration-150 ease-out: a subtle darker border that eases in, and no fill (base.cssexcludes text inputs from the background tint). Thenot-aria-invalidguard keeps the destructive border. The choice card is not text entry and keeps the tint. - A11Y-13 —
QuestionnaireError'stext-destructivebecomestext-destructive-text. It is the identical pair toField's error copy, which the row names: the status fill used as text ink measures under the AA floor, and the family's-textink clears it. - A11Y-3, A11Y-4 — NO HUNK. Measured in
@shadcn/react@0.3.1:Progressrendersrole="progressbar"witharia-live="polite"andaria-label="Questionnaire progress"— A11Y-3's polite status by default — andErrorrendersrole="alert"only while the item is invalid andhiddenotherwise, which is A11Y-3's "alert only for destructive content rendered after mount" and never static chrome. One region per concern, so A11Y-4's single-region rule is met by the engine; auseAnnouncerbeside it would double-announce every step.questionnaire.test.tsxasserts both shapes. - A11Y-2 — audited, NO HUNK. Every target upstream ships is already at or over the floor: the
choice is
min-h-11(44px), the answer inputmin-h-11, and the four navigation buttonsmin-h-11, relaxed to theButtontier atsm:, which is 32px. Measured inquestionnaire.test.tsx. - DOC-1, DOC-2 —
cnis imported from@vegastack/design, and the rest of the diff is prettier's reflow plus theregistry-stamp.mjsprovenance header the three-copy model needs. - COL-24, INT-4 — hover and press come from the shared
state-layerutility, a translucent foreground layer (--state-hover,--state-pressed) over whatever fill is there, so the step reads the same on every surface in both themes; selected, open and highlighted fills arebg- state-selected/bg-state-hover. Operator, Regent, 2026-10-31. - FOC-15 — the choice card shows its radio's keyboard focus with the
layer-focustint, the same info-hue cue base.css paints. Operator, Regent, 2026-10-31.