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
Add Questionnaire from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/questionnaireThe 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's own border — not an outline on something invisible — is the focus affordance (FOC-1, FOC-6). 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/50and keepshas-[>input:focus-visible]:border-ring. The real control is anopacity-0input stretched over the whole card, sobase.css's outline paints on something invisible — the same shapeinput-otp's hidden input has, and the same answer: the visible card's border is the affordance. - FOC-5 (choice) —
data-invalid:border-destructivebecomesnot-has-[>input:focus-visible]:data-invalid:border-destructive, so focus outranks the invalid tint 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-5, 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/50becomesfocus:border-ring/70— the 70% text-entry tint, on:focusso it fires for mouse and keyboard alike —aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20becomesnot-focus:aria-invalid:border-destructivewith its dark counterpart, anddisabled:pointer-events-noneis dropped. - 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.