Questionnaire
One question at a time with choices, free text and previous and next.
- Status
- Experimental
- Level
- Organism
- Category
- Inputs
- Adoption
- Not used yet
import { Questionnaire } from "@oration/canon/components/questionnaire";packages/canon/src/components/questionnaire.tsximport { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@oration/canon/components/questionnaire";import { toast } from "@oration/canon/components/toast";export function Hero() { return ( <Questionnaire shortcuts="letters" className="w-full max-w-md text-left" onSubmit={(event) => { event.preventDefault(); const form = event.currentTarget; const answers = new FormData(form); toast.add({ type: "success", title: "Setup saved", description: `Approvals go to ${answers.get("approver")}. Your first payment run is next.`, }); form.reset(); }} > <QuestionnaireProgress /> <QuestionnaireItem name="method" required> <QuestionnaireTitle> How do you pay suppliers today? </QuestionnaireTitle> <QuestionnaireDescription> We'll set up your first payment run to match. </QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="ach"> ACH from our bank portal <QuestionnaireChoiceDescription> Uploaded as a NACHA file or keyed in by hand </QuestionnaireChoiceDescription> </QuestionnaireChoice> <QuestionnaireChoice value="checks"> Paper checks </QuestionnaireChoice> <QuestionnaireChoice value="cards"> Virtual cards </QuestionnaireChoice> <QuestionnaireChoice value="mixed"> A mix of these </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireItem name="first" multiple> <QuestionnaireTitle> What should Cedarline handle first? </QuestionnaireTitle> <QuestionnaireDescription> Pick any that apply. </QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="capture"> Reading invoices from email </QuestionnaireChoice> <QuestionnaireChoice value="w9"> Collecting W-9s </QuestionnaireChoice> <QuestionnaireChoice value="remittance"> Sending remittance advice </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireItem name="approver" required> <QuestionnaireTitle> Who approves payment runs? </QuestionnaireTitle> <QuestionnaireDescription> They get an email before each run goes out. </QuestionnaireDescription> <QuestionnaireInput aria-label="Approver's name" placeholder="Maya Okafor" /> <QuestionnaireError> Enter a name to continue. </QuestionnaireError> </QuestionnaireItem> <QuestionnaireActions> <QuestionnairePrevious /> <QuestionnaireSkip /> <QuestionnaireNext /> <QuestionnaireSubmit>Finish setup</QuestionnaireSubmit> </QuestionnaireActions> </Questionnaire> );}Usage#
Questionnaire asks one question at a time inside a form: radio or checkbox choices, free text, a progress line, and Previous, Skip, Next and Submit buttons that appear only when they apply. The headless @shadcn/react questionnaire owns navigation, validation, shortcuts and progress; Canon's wrapper adds the styling. It isn't used in the product yet. The thing people trip on is Next on an optional question: it still asks for an answer, and only Skip moves past it empty.
When to use
- For setup or onboarding that is a short run of questions, each easy to answer on its own: how you pay suppliers, which ERP you use, who approves runs.
- For a survey such as a follow-up after a supplier dispute, one question per step.
- When speed matters: letter or number shortcuts pick an answer and Enter moves on.
- When some questions are optional, so people can skip what doesn't apply.
When not to use
- For settings people change in any order and come back to. Use Settings section
- For a multi-page flow with its own steps, such as connecting a bank account. Use Stepper
- For one choice inside a regular form. Use Radio group
- For choosing between a few rich options with descriptions on one screen. Use Choice card
- For a form people fill in once with all its fields visible. Use Forms and validation
The One Filled Button Rule
The Quiet Indigo Rule
The Tabular Figures Rule
Anatomy#
- Progress. Question 2 of 4 in 12px medium muted type. A progressbar that announces itself as it changes.
- Title. The question, as the item's
<legend>, 16px medium. - Description. Optional 14px muted help under the title.
- Choice. A row at least 44px tall with 10px corners and an input border. A native radio or checkbox is stretched invisibly over it.
- Indicator. A 16px circle (single choice) or 4px-cornered square (multiple), filled indigo with a dot or check when chosen.
- Shortcut key. With
shortcuts, a 20px key on the trailing side showing A, B, C or 1, 2, 3. - Actions. A grid: Previous on the start, Skip and then Next or Submit on the end. Buttons that don't apply are hidden.
- Input. Free text, 44px tall on touch screens and 32px from 640px up.
- Error. A red 14px line shown when Next is pressed on an unanswered question.
- Item. A
<fieldset>per question. Only the active one is visible; the rest are hidden and inert. - Root. A
<form noValidate>around the items and the actions.
Examples#
Multiple choice, optional
multiple turns the answers into checkboxes and every checked value submits under the item's name. The question isn't required, so Skip shows; one disabled answer explains why with a description.
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireItem, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@oration/canon/components/questionnaire";import { toast } from "@oration/canon/components/toast";export function MultipleChoice() { return ( <Questionnaire className="w-full max-w-lg" onSubmit={(event) => { event.preventDefault(); const channels = new FormData(event.currentTarget).getAll( "channels", ); toast.add({ title: channels.length ? `Remittance advice goes to ${channels.length} ${channels.length === 1 ? "place" : "places"}` : "Kept the current remittance settings", }); event.currentTarget.reset(); }} > <QuestionnaireItem name="channels" multiple> <QuestionnaireTitle> Where should remittance advice go? </QuestionnaireTitle> <QuestionnaireDescription> Pick any that apply, or skip to keep what you have. </QuestionnaireDescription> <QuestionnaireChoices className="sm:grid-cols-2"> <QuestionnaireChoice value="email"> Supplier's AR email </QuestionnaireChoice> <QuestionnaireChoice value="portal"> Supplier portal </QuestionnaireChoice> <QuestionnaireChoice value="edi"> EDI 820 file </QuestionnaireChoice> <QuestionnaireChoice value="mail" disabled> Printed and mailed <QuestionnaireChoiceDescription> Needs the Scale plan </QuestionnaireChoiceDescription> </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnaireSkip /> <QuestionnaireSubmit>Save</QuestionnaireSubmit> </QuestionnaireActions> </Questionnaire> );}Free text, required
A required question with QuestionnaireInput. Press Save PO with the field empty to see the error; type a number and Enter submits.
import { Questionnaire, QuestionnaireActions, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireSubmit, QuestionnaireTitle,} from "@oration/canon/components/questionnaire";import { toast } from "@oration/canon/components/toast";export function FreeTextRequired() { return ( <Questionnaire className="w-full max-w-md" onSubmit={(event) => { event.preventDefault(); const po = new FormData(event.currentTarget).get("po"); toast.add({ type: "success", title: "PO saved", description: `Northwind Freight invoices will reference ${po}.`, }); event.currentTarget.reset(); }} > <QuestionnaireItem name="po" required> <QuestionnaireTitle> Which PO should Northwind Freight invoices reference? </QuestionnaireTitle> <QuestionnaireDescription> Use the blanket PO for freight. It's under Purchasing in your ERP. </QuestionnaireDescription> <QuestionnaireInput aria-label="PO number" placeholder="PO-88213" className="font-mono" /> <QuestionnaireError> Enter the PO number to continue. </QuestionnaireError> </QuestionnaireItem> <QuestionnaireActions> <QuestionnaireSubmit>Save PO</QuestionnaireSubmit> </QuestionnaireActions> </Questionnaire> );}Controlled, with your own step list
item and onItemChange let a step list jump between questions. Number keys pick answers. Submitting from the last step jumps back to any required question still unanswered.
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireError, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@oration/canon/components/questionnaire";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Controlled() { const steps = [ { name: "volume", label: "Monthly volume" }, { name: "erp", label: "ERP" }, { name: "terms", label: "Payment terms" }, ]; const [item, setItem] = React.useState("volume"); return ( <div className="grid w-full max-w-2xl gap-6 sm:grid-cols-[10rem_minmax(0,1fr)]"> <nav aria-label="Setup questions"> <ol className="flex flex-col gap-0.5"> {steps.map((step, index) => ( <li key={step.name}> <button type="button" aria-current={ item === step.name ? "step" : undefined } onClick={() => setItem(step.name)} className={cn( "w-full rounded-lg px-2.5 py-1.5 text-left text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", item === step.name ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground", )} > <span className="tabular-nums"> {index + 1}. </span>{" "} {step.label} </button> </li> ))} </ol> </nav> <Questionnaire item={item} onItemChange={setItem} shortcuts="numbers" onSubmit={(event) => { event.preventDefault(); toast.add({ type: "success", title: "Answers saved", description: "We'll tune approvals to your volume and terms.", }); event.currentTarget.reset(); }} > <QuestionnaireProgress /> <QuestionnaireItem name="volume" required> <QuestionnaireTitle> How many invoices do you pay a month? </QuestionnaireTitle> <QuestionnaireChoices> <QuestionnaireChoice value="under-500"> Under 500 </QuestionnaireChoice> <QuestionnaireChoice value="500-2000"> 500 to 2,000 </QuestionnaireChoice> <QuestionnaireChoice value="over-2000"> Over 2,000 </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireItem name="erp" required> <QuestionnaireTitle> Which ERP do you use? </QuestionnaireTitle> <QuestionnaireChoices> <QuestionnaireChoice value="netsuite"> NetSuite </QuestionnaireChoice> <QuestionnaireChoice value="sage"> Sage Intacct </QuestionnaireChoice> <QuestionnaireChoice value="qbo"> QuickBooks Online </QuestionnaireChoice> <QuestionnaireChoice value="none"> We don't use one </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireItem name="terms"> <QuestionnaireTitle> What terms do most suppliers give you? </QuestionnaireTitle> <QuestionnaireChoices> <QuestionnaireChoice value="net-15"> Net 15 </QuestionnaireChoice> <QuestionnaireChoice value="net-30"> Net 30 </QuestionnaireChoice> <QuestionnaireChoice value="net-60"> Net 60 </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnairePrevious /> <QuestionnaireSkip /> <QuestionnaireNext /> <QuestionnaireSubmit>Save answers</QuestionnaireSubmit> </QuestionnaireActions> </Questionnaire> </div> );}States#
import { Questionnaire, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireItem, QuestionnaireTitle,} from "@oration/canon/components/questionnaire";import { cn } from "@oration/canon/lib/utils";export function ChoiceStates() { const states = [ { label: "Rest", className: "" }, { label: "Hover", className: "bg-muted/50" }, { label: "Focus", className: "border-ring ring-3 ring-ring/50" }, ]; return ( <Questionnaire className="w-full"> <QuestionnaireItem name="states" multiple> <QuestionnaireTitle className="sr-only"> Choice states </QuestionnaireTitle> <QuestionnaireChoices className="sm:grid-cols-3"> {states.map((state) => ( <QuestionnaireChoice key={state.label} value={state.label} className={cn( "pointer-events-none", state.className, )} > {state.label} </QuestionnaireChoice> ))} <QuestionnaireChoice value="checked" defaultChecked className="pointer-events-none" > Checked </QuestionnaireChoice> <QuestionnaireChoice value="disabled" disabled> Disabled </QuestionnaireChoice> <QuestionnaireChoice value="described" defaultChecked className="pointer-events-none" > Checked with a description <QuestionnaireChoiceDescription> A second, muted line </QuestionnaireChoiceDescription> </QuestionnaireChoice> </QuestionnaireChoices> </QuestionnaireItem> </Questionnaire> );}| State | Treatment |
|---|---|
| Rest | Input-colored border, transparent fill. |
| Hover | The choice fills with Well Gray at 50%. |
| Focus visible | When the hidden input has keyboard focus, the choice gets an indigo border and a 3px ring at 50%. |
| Checked | Indigo border at 40%, a Well Gray fill, and an indigo indicator with a dot or a check. |
| Invalid | After Next on an unanswered question: red choice borders, the error line and aria-invalid on the item. |
| Skipped | Skip clears the answer and moves on. A skipped question submits nothing. |
| Disabled | A disabled choice or item drops to 50% and ignores the pointer. |
| First and last | Previous is hidden on the first question; on the last, Next gives way to Submit. |
| Required | Skip is hidden, so the question must be answered. |
Behavior#
- Next validates the active question. Answered or skipped passes; unanswered shows the error and focuses the first answer. Required questions hide Skip.
- Moving to another question focuses that question's fieldset, so screen readers announce the new legend.
- Submit validates every question. If one fails it jumps there and focuses it; otherwise
onSubmitfires. Callevent.preventDefault()and read answers withnew FormData(event.currentTarget). - Each item's
nameis the field name and each choice'svalueits value. Amultipleitem submits every checked value under one name. - Resetting the form, with
form.reset()or a reset button, clears every answer and returns to the first question. - Uncontrolled by default, starting at
defaultItemor the first question. PassitemandonItemChangeto control which question shows, for your own step list or for branching. itemsdeclares the questions as{ name, required?, disabled?, choices? }. It fixes the shortcut order and, in development, warns when the markup and the list disagree.shortcuts="letters"assigns A to Z and"numbers"1 to 9 to the enabled choices, in order. Pressing one picks that choice.
Do and don't#
Content#
- Titles are questions, in sentence case, ending with a question mark: How do you pay suppliers today?
- Descriptions say why you're asking or how the answer is used: We'll set up your first payment run to match.
- Answers are short and parallel. Put detail in a
QuestionnaireChoiceDescriptionrather than a longer label. - Rename Submit for what it does: Finish setup, Send answers.
- Error messages say what to do: Choose an answer to continue., or for optional questions Choose an answer or skip this question.
Accessibility#
- Each question is a
<fieldset>with its title as the<legend>, so answers are announced with the question. - Answers are native radios or checkboxes stretched over the row, so they behave like any radio group or checkbox list; the drawn indicator is hidden from assistive tech.
- Progress is a progressbar whose value text is Question 2 of 4, announced politely as it changes.
- When shown, the error has
role="alert"and is linked to the question witharia-describedby. - Hidden questions and buttons are
inert, so Tab and screen readers skip them. - Shortcuts are exposed with
aria-keyshortcuts. Choices are at least 44px tall on touch screens. - Give a free-text input its own
aria-label; the legend names the group, not the field.
| Keys | Action |
|---|---|
| A | With shortcuts="letters", picks that answer. "numbers" uses 1 to 9. |
| ↑↓ | Moves between answers. On radios, also selects. |
| ←→ | Previous question, and next once this one is answered. Not inside a text field or on a radio. |
| Enter | On a chosen answer or filled text, goes to the next question or submits. |
| ⌘Enter | Next or submit from anywhere in the question. Ctrl+Enter off Apple platforms. |
| Tab | Moves to the visible action buttons. |
Design tokens#
| Token | Used for |
|---|---|
--input | Choice and input borders |
--primary | Checked border at 40% and indicator fill |
--muted | Hover fill at 50% and checked fill |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid borders and the error line |
--radius-lg | 10px choice and input corners |
buttonVariants | Previous, Skip, Next and Submit |
API reference#
Questionnaire
The form. Rest of the props spread onto it.
Other props spread onto @shadcn/react Questionnaire.Root (<form>).
| Prop | Type | Default | Description |
|---|---|---|---|
defaultItem | string | No default | Name of the question to start on. Uncontrolled. |
item | string | No default | Name of the visible question. Controlled. |
onItemChange | (item: string) => void | No default | Called when the visible question changes. |
items | readonly { name: string; required?: boolean; disabled?: boolean; choices?: readonly { value: string; disabled?: boolean }[] }[] | No default | Declares the questions, for shortcut order and dev warnings. |
shortcuts | "letters" | "numbers" | No default | Assigns keys to answers. |
noValidate | boolean | true | Set false to also run native constraint validation. |
onSubmit | React.FormEventHandler<HTMLFormElement> | No default | Fires once every question is valid. |
QuestionnaireProgress
Other props spread onto @shadcn/react Questionnaire.Progress (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | "Question {n} of {total}" | Replaces the visible text. The value text stays the default. |
QuestionnaireItem
One question.
Other props spread onto @shadcn/react Questionnaire.Item (<fieldset>).
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | string | No default | Field name, and the id used by item. |
required | boolean | false | Hides Skip; an answer is needed. |
multiple | boolean | false | Checkboxes instead of radios. |
disabled | boolean | false | Removes the question from the flow. |
invalid | boolean | false | Forces the invalid state, for server-side errors. |
onStatusChange | (status: "unanswered" | "answered" | "skipped") => void | No default | Called when the answer status changes. |
QuestionnaireTitle
The question text.
Other props spread onto @shadcn/react Questionnaire.Title (<legend>).
No props of its own.
QuestionnaireDescription
Help under the title, added to the item's description.
Other props spread onto @shadcn/react Questionnaire.Description (<p>).
No props of its own.
QuestionnaireChoices
The grid of answers, 8px apart.
Other props spread onto @shadcn/react Questionnaire.Choices (<div>).
No props of its own.
QuestionnaireChoice
One answer. Children are its label.
Other props spread onto @shadcn/react Questionnaire.Choice (<label>).
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | Submitted value. |
defaultChecked | boolean | false | Chosen at start. Uncontrolled. |
checked | boolean | No default | Chosen state. Controlled. |
onChange | React.ChangeEventHandler<HTMLInputElement> | No default | The hidden input's change event. |
disabled | boolean | false | Disables this answer. |
QuestionnaireChoiceDescription
A muted second line inside a choice.
Other props spread onto <span>.
No props of its own.
QuestionnaireInput
Free-text answer.
Other props spread onto @shadcn/react Questionnaire.Input (<input>).
| Prop | Type | Default | Description |
|---|---|---|---|
type | "text" | "email" | "number" | "tel" | "url" | "date" | … | "text" | Input type. |
value / defaultValue | string | No default | Controlled or uncontrolled text. |
disabled | boolean | false | Disables the field. |
QuestionnaireError
Shown only while the question is invalid.
Other props spread onto @shadcn/react Questionnaire.Error (<p>).
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | "Choose an answer to continue." (required), "Choose an answer or skip this question." | The message. |
QuestionnaireActions
The three-column grid for the buttons.
Other props spread onto <div>.
No props of its own.
QuestionnairePrevious / QuestionnaireSkip / QuestionnaireNext / QuestionnaireSubmit
Navigation buttons styled with buttonVariants. Each hides itself when it doesn't apply.
Other props spread onto @shadcn/react Questionnaire.Previous, Skip, Next, Submit (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
variant | Button variant | "outline" (Previous, Skip), "default" (Next, Submit) | Button style. |
size | Button size | "default" | Button size. 44px tall below 640px regardless. |
children | React.ReactNode | "Previous", "Skip", "Next", "Submit" | The label. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Not used anywhere in the product yet, and marked experimental.
The shortcut keys are set in Geist Mono at 10px. The Machine Mono Rule keeps keyboard keys in Geist Sans, The Thirteen-Fourteen Rule puts nothing under 11px, and Kbd already draws keys.
Choices and the input use a 3px focus ring at 50%, where every other control uses 40%.
Built-in strings are English and fixed in places: the progress value text is always Question {n} of {total} even when you replace the visible text.