Westy Design Systemv0.2.7
OverviewComponentsExamplesStorybook

Foundations

  • Typography
  • Colors
  • Spacing
  • Radius
  • Shadows
  • Icons

Components

  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Data Table
  • Date Picker
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Menubar
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Questionnaire
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sonner
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toggle
  • Toggle Group
  • Tooltip

Chat

  • Attachment
  • Bubble
  • Marker
  • Message
  • Message Scroller

Components

Questionnaire

A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions.

Playground

Question 1 of 2
What should we prototype next?

Choose a direction or write your own.

Choose an answer to continue.

How much detail should it include?

The last item is always required, so Skip never applies here.

Choose an answer to continue.

—shortcuts
—multiple
—required
—QuestionnaireInput
—QuestionnaireChoiceDescription
—QuestionnaireProgress
—dir
Usage
Questionnaire is a real 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.
Question 1 of 2
What should we prototype next?

Choose a direction or write your own.

Choose an answer to continue.

How much detail should it include?

Skip this if you are not sure yet.

Choose an answer or skip this question.

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>
Composition
Fourteen parts, one tree. Questionnaire owns everything about the questions themselves; the page, card, dialog, or drawer around it owns close and cancellation behavior, persistence, transport, and branching. Do not nest a Questionnaire inside another form.
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│   ├── QuestionnaireTitle
│   ├── QuestionnaireDescription
│   ├── QuestionnaireChoices
│   │   ├── QuestionnaireChoice
│   │   │   └── QuestionnaireChoiceDescription
│   │   └── QuestionnaireInput
│   └── QuestionnaireError
└── QuestionnaireActions
    ├── QuestionnairePrevious
    ├── QuestionnaireSkip
    ├── QuestionnaireNext
    └── QuestionnaireSubmit
Multiple selection
multiple 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").
What context should the agent inspect?

Select every source that may affect the implementation.

Choose an answer to continue.

<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>
Freeform answer
Drop a 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.
How should the agent approach this refactor?

Choose a strategy or write a more specific instruction.

Choose an answer to continue.

<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>
Explicit skip
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.
Question 1 of 3
What kind of change is this?

Choose the category that best describes the work.

Choose an answer to continue.

Are there any implementation constraints?

Answer if needed, or intentionally skip this question.

How should the work be reviewed?

Choose the checks the agent should complete before handoff.

Choose an answer to continue.

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
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.
What should the agent do next?

Use the displayed shortcut or navigate with the keyboard.

Choose an answer to continue.

<Questionnaire items={items} shortcuts="letters" onSubmit={handleSubmit}>
  {/* … */}
</Questionnaire>

<Questionnaire items={items} shortcuts="numbers" onSubmit={handleSubmit}>
  {/* … */}
</Questionnaire>
Custom validation
Built-in validation only knows whether a required item was answered. For a rule that spans items, keep the active item in host state, validate on submit, then set 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.
How much detail should the answer include?

Choose the response depth.

1 / 2

Choose an answer to continue.

Who will read the answer?

Public answers require complete context.

1 / 2

Choose an answer to continue.

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>
Controlled
Pair 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

Question 1 of 3
What may the agent change?

The host stores the active checkpoint while Questionnaire navigates.

Choose an answer to continue.

Which verification level should it use?

Choose an answer to continue.

What should the agent return when finished?

Choose an answer to continue.

const [item, setItem] = React.useState("scope")

<Questionnaire
  item={item}
  items={items}
  onItemChange={setItem}
  onSubmit={handleSubmit}
>
  <QuestionnaireProgress />
  {/* … */}
</Questionnaire>
Resume
A saved draft restores with 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.
Question 2 of 3
What kind of migration is this?

This answer was saved during the previous session.

Choose an answer to continue.

How should the migration be verified?

These checks were selected during the previous session.

Choose an answer to continue.

Anything else the agent should remember?

This note was saved with the draft.

<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>
Conditional items
Branching is 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.
Question 1 of 2
Where should the agent run?

Cloud runs add an environment question to this flow.

Choose an answer to continue.

Which cloud environment should it use?

Choose an answer to continue.

When should the agent request approval?

Choose an answer to continue.

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>
Navigation state
Actions stay enabled by default, so pressing Next on an empty item reveals the error instead of doing nothing. When you would rather block the move, track 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.
Question 1 of 2
What may the agent modify?

Next is intentionally disabled until an answer is selected.

Choose an answer to continue.

What must pass before completion?

Choose an answer to continue.

<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>
Custom progress
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.
Checkpoint 1 of 4
How large is the change?

Choose an answer to continue.

How should commits be organized?

Choose an answer to continue.

Which tests should run?

Choose an answer to continue.

How should the work be delivered?

Choose an answer to continue.

<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>
  )}
/>
Animated items
Inactive items are hidden and inert, so the entrance animation belongs on 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.
Question 1 of 3
What should the agent do?

Choose the task for this run.

Choose an answer to continue.

How should the work be reviewed?

Select the verification depth.

Choose an answer to continue.

How should the result be delivered?

Choose the final handoff format.

Choose an answer to continue.

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>
Card
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.
What should the agent work on?
Choose the task that should be handled next.
Question 1 of 2

Choose an answer to continue.

What should the final handoff include?
Pick the level of detail needed for review.
Question 1 of 2

Choose an answer to continue.

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>
Dialog
Inside a dialog the split is the same: Questionnaire advances and submits, the dialog owns cancel and dismiss. Put the root inside 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>
Keyboard navigation
Everything below comes from native radios, checkboxes, inputs, and buttons — Questionnaire only adds the item-to-item moves and the shortcut keys. Shortcuts and arrow navigation pause while you type in a text field, and preventing the root onKeyDown turns questionnaire key handling off entirely.
KeyBehavior
TabMoves focus between answer controls and visible actions.
ShiftTabMoves 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.
SpaceSelects a radio, toggles a checkbox, or activates a focused action.
EnterContinues from a selected choice or a filled input; activates a focused action.
⌘EnterValidates and continues from anywhere inside the questionnaire, or submits the final item.
Accessibility
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>
API Reference
The styled parts inherit the props of the headless @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.
PartPropDefault
Questionnaireitem?: string—
QuestionnairedefaultItem?: stringfirst item
Questionnaireitems?: readonly QuestionnaireItemDefinition[]—
QuestionnaireonItemChange?: (item: string) => void—
Questionnaireshortcuts?: "letters" | "numbers"—
QuestionnairenoValidate?: booleantrue
QuestionnaireProgresschildren?: ReactNodeQuestion {current} of {total}
QuestionnaireProgressrender?: ReactElement | (props, state) => ReactElement<div>
QuestionnaireItemname: stringrequired
QuestionnaireItemrequired?: booleanfalse
QuestionnaireItemmultiple?: booleanfalse
QuestionnaireItemdisabled?: booleanfalse
QuestionnaireIteminvalid?: booleanfalse
QuestionnaireItemonStatusChange?: (status: QuestionnaireItemStatus) => void—
QuestionnaireChoicevalue: stringrequired
QuestionnaireChoicechecked?: boolean · defaultChecked?: booleanfalse
QuestionnaireInputtype?: QuestionnaireInputType"text"
QuestionnaireErrorchildren?: ReactNodecontextual message
Previous · Skip · Next · Submitsize?: Button size · variant?: Button variant"default"

Tokens used

18 design tokens consumed by the Questionnaire component.

  • --primary

    Primary

    data-checked:border-primary/40group-data-checked/questionnaire-choice:border-primarygroup-data-checked/questionnaire-choice:bg-primaryselection:bg-primary
  • --primary-foreground

    Primary Foreground

    group-data-checked/questionnaire-choice:text-primary-foregroundbg-primary-foregroundselection:text-primary-foreground
  • --muted

    Muted

    hover:bg-muted/50data-checked:bg-muted
  • --muted-foreground

    Muted Foreground

    text-muted-foregroundplaceholder:text-muted-foreground
  • --background

    Background

    bg-background
  • --input

    Input

    border-inputdark:bg-input/20dark:bg-input/30
  • --ring

    Ring

    has-[>input:focus-visible]:border-ringhas-[>input:focus-visible]:ring-ring/50focus-visible:border-ringfocus-visible:ring-ring/50
  • --destructive

    Destructive

    text-destructivedata-invalid:border-destructivearia-invalid:ring-destructive/20dark:aria-invalid:ring-destructive/40
  • --radius-md

    Radius Medium

    rounded-md
  • --radius-sm

    Radius Small

    rounded-[4px]
  • --shadow-xs

    Shadow Extra Small

    shadow-xs
  • --font-heading

    Heading font (GT Alpina)

    font-heading
  • --font-mono

    Mono font (Geist Mono)

    font-mono
  • --text-base

    Text Base (16px)

    text-base
  • --text-sm

    Text Small (14px)

    text-smmd:text-sm
  • --text-xs

    Text Extra Small (12px)

    text-xs
  • --font-weight-medium

    Font Weight Medium (500)

    font-medium
  • --spacing

    Spacing unit (4px)

    gap-6gap-5gap-3gap-2px-4py-3.5px-2.5py-1min-h-11size-4size-5size-2size-3.5translate-y-0.5