Skip to content

Questionnaire

One question at a time with choices, free text and previous and next.

Category
Inputs
Adoption
Not used yet
import { Questionnaire } from "@oration/canon/components/questionnaire";
packages/canon/src/components/questionnaire.tsx
import {  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

Next and Submit are the filled buttons and only one of them shows at a time. Previous and Skip are outline.

The Quiet Indigo Rule

Indigo marks selection only: a checked choice gets a 40% indigo edge, a Well Gray fill and an indigo indicator. Unchecked choices stay neutral.

The Tabular Figures Rule

The progress line, Question 2 of 4, is set in tabular figures so it doesn't shift as it counts.

Anatomy#

  1. Progress. Question 2 of 4 in 12px medium muted type. A progressbar that announces itself as it changes.
  2. Title. The question, as the item's <legend>, 16px medium.
  3. Description. Optional 14px muted help under the title.
  4. Choice. A row at least 44px tall with 10px corners and an input border. A native radio or checkbox is stretched invisibly over it.
  5. Indicator. A 16px circle (single choice) or 4px-cornered square (multiple), filled indigo with a dot or check when chosen.
  6. Shortcut key. With shortcuts, a 20px key on the trailing side showing A, B, C or 1, 2, 3.
  7. Actions. A grid: Previous on the start, Skip and then Next or Submit on the end. Buttons that don't apply are hidden.
  8. Input. Free text, 44px tall on touch screens and 32px from 640px up.
  9. Error. A red 14px line shown when Next is pressed on an unanswered question.
  10. Item. A <fieldset> per question. Only the active one is visible; the rest are hidden and inert.
  11. 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.

How many invoices do you pay a month?
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>    );}
States
StateTreatment
RestInput-colored border, transparent fill.
HoverThe choice fills with Well Gray at 50%.
Focus visibleWhen the hidden input has keyboard focus, the choice gets an indigo border and a 3px ring at 50%.
CheckedIndigo border at 40%, a Well Gray fill, and an indigo indicator with a dot or a check.
InvalidAfter Next on an unanswered question: red choice borders, the error line and aria-invalid on the item.
SkippedSkip clears the answer and moves on. A skipped question submits nothing.
DisabledA disabled choice or item drops to 50% and ignores the pointer.
First and lastPrevious is hidden on the first question; on the last, Next gives way to Submit.
RequiredSkip 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 onSubmit fires. Call event.preventDefault() and read answers with new FormData(event.currentTarget).
  • Each item's name is the field name and each choice's value its value. A multiple item 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 defaultItem or the first question. Pass item and onItemChange to control which question shows, for your own step list or for branching.
  • items declares 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#

Do. Ask one thing per question, with two to six answers that don't overlap.
Don't. Fold two questions into one, such as How do you pay suppliers and how often?
Do. Require only what you need to set things up, and let people skip the rest.
Don't. Make every question required. People pick anything to get past it, and the answers stop meaning anything.
Do. Write answers as parallel options in the person's words: ACH from our bank portal, Paper checks.
Don't. Mix answers of different kinds, or add Other without a way to say what.

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 QuestionnaireChoiceDescription rather 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 with aria-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.
Keyboard interactions
KeysAction
AWith 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.
EnterOn a chosen answer or filled text, goes to the next question or submits.
⌘EnterNext or submit from anywhere in the question. Ctrl+Enter off Apple platforms.
TabMoves to the visible action buttons.

Design tokens#

Design tokens
TokenUsed for
--inputChoice and input borders
--primaryChecked border at 40% and indicator fill
--mutedHover fill at 50% and checked fill
--ringFocus border and 3px ring at 50%
--destructiveInvalid borders and the error line
--radius-lg10px choice and input corners
buttonVariantsPrevious, 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>).

Props of Questionnaire
PropTypeDefaultDescription
defaultItemstringNo defaultName of the question to start on. Uncontrolled.
itemstringNo defaultName of the visible question. Controlled.
onItemChange(item: string) => voidNo defaultCalled when the visible question changes.
itemsreadonly { name: string; required?: boolean; disabled?: boolean; choices?: readonly { value: string; disabled?: boolean }[] }[]No defaultDeclares the questions, for shortcut order and dev warnings.
shortcuts"letters" | "numbers"No defaultAssigns keys to answers.
noValidatebooleantrueSet false to also run native constraint validation.
onSubmitReact.FormEventHandler<HTMLFormElement>No defaultFires once every question is valid.

QuestionnaireProgress

Other props spread onto @shadcn/react Questionnaire.Progress (<div>).

Props of QuestionnaireProgress
PropTypeDefaultDescription
childrenReact.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>).

Props of QuestionnaireItem
PropTypeDefaultDescription
nameRequiredstringNo defaultField name, and the id used by item.
requiredbooleanfalseHides Skip; an answer is needed.
multiplebooleanfalseCheckboxes instead of radios.
disabledbooleanfalseRemoves the question from the flow.
invalidbooleanfalseForces the invalid state, for server-side errors.
onStatusChange(status: "unanswered" | "answered" | "skipped") => voidNo defaultCalled 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>).

Props of QuestionnaireChoice
PropTypeDefaultDescription
valueRequiredstringNo defaultSubmitted value.
defaultCheckedbooleanfalseChosen at start. Uncontrolled.
checkedbooleanNo defaultChosen state. Controlled.
onChangeReact.ChangeEventHandler<HTMLInputElement>No defaultThe hidden input's change event.
disabledbooleanfalseDisables 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>).

Props of QuestionnaireInput
PropTypeDefaultDescription
type"text" | "email" | "number" | "tel" | "url" | "date" | …"text"Input type.
value / defaultValuestringNo defaultControlled or uncontrolled text.
disabledbooleanfalseDisables the field.

QuestionnaireError

Shown only while the question is invalid.

Other props spread onto @shadcn/react Questionnaire.Error (<p>).

Props of QuestionnaireError
PropTypeDefaultDescription
childrenReact.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>).

Props of QuestionnairePrevious / QuestionnaireSkip / QuestionnaireNext / QuestionnaireSubmit
PropTypeDefaultDescription
variantButton variant"outline" (Previous, Skip), "default" (Next, Submit)Button style.
sizeButton size"default"Button size. 44px tall below 640px regardless.
childrenReact.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.