Skip to content

shadcn.io is an independent project built on shadcn/ui

Components

Free

Questionnaire for Radix UI

Step through onboarding, a survey, or a quiz one question at a time. For React with Radix UI, when a later prompt depends on an earlier answer.

The current question is the only one on screen. The next one stays hidden until the person gets there.

Use it when people should answer in order, and a later question only makes sense after an earlier one. A short settings page is stacked Field groups instead. In React, Radix UI renders each question as a fieldset, so the title is the legend.

Preview width

Installation

Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

Anatomy

Questionnaire anatomy: progress, item, title, choice, previous, skip, next
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│   ├── QuestionnaireTitle
│   ├── QuestionnaireDescription
│   ├── QuestionnaireChoices
│   │   ├── QuestionnaireChoice
│   │   └── QuestionnaireInput
│   └── QuestionnaireError
└── QuestionnaireActions
    ├── QuestionnairePrevious
    ├── QuestionnaireSkip
    ├── QuestionnaireNext
    └── QuestionnaireSubmit

Usage

import type { FormEvent } from "react";
import {
  Questionnaire, QuestionnaireActions, QuestionnaireChoice,
  QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError,
  QuestionnaireInput, QuestionnaireItem, QuestionnaireNext,
  QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip,
  QuestionnaireSubmit, QuestionnaireTitle,
} from "@/components/ui/questionnaire";
const items = [
  { choices: [{ value: "delegation" }], name: "direction", required: true },
] as const;

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const answers = new FormData(event.currentTarget);
  console.log(answers.get("direction"));
}
<Questionnaire items={items} onSubmit={handleSubmit} className="max-w-full min-w-0">
  <QuestionnaireProgress />
  <QuestionnaireItem name="direction" required>
    <QuestionnaireTitle>What should we prototype next?</QuestionnaireTitle>
    <QuestionnaireDescription>Choose a direction.</QuestionnaireDescription>
    <QuestionnaireChoices>
      <QuestionnaireChoice value="delegation">Delegation</QuestionnaireChoice>
    </QuestionnaireChoices>
    <QuestionnaireError />
  </QuestionnaireItem>
  <QuestionnaireActions>
    <QuestionnairePrevious />
    <QuestionnaireSkip />
    <QuestionnaireNext />
    <QuestionnaireSubmit />
  </QuestionnaireActions>
</Questionnaire>

Demos

Multiple

multiple on the item turns the question into checkboxes, and the submit carries every checked value under one name. That fits a question like what to inspect, when more than one source is an honest answer. Leave the prop off when only one answer is valid, or people will tick a combination you cannot act on. Read the list back with getAll, since get returns only the first value.

Preview width

Freeform

QuestionnaireInput sits in the choice list so someone can type an answer the options missed. Use it when the list is a suggestion, a strategy or a feature name, not a closed set. The typed text submits under the same name as a chosen option. If an empty custom answer is not a real answer, keep the item required.

Preview width

Skip

QuestionnaireSkip stays available on an optional item and hides itself when the item is required. The constraints question can be passed, and onStatusChange reports skipped, which is not the same as never opening it. Use skip for a useful question that must not block the flow. Do not offer it on a question a later step will need.

Preview width

Shortcuts

shortcuts accepts letters or numbers, and each choice grows a key. The select above the question switches that mode, including off. Letters suit an internal tool where people live on the keyboard. Leave them off for a survey that will mostly be tapped, and use Cmd or Ctrl with Enter to move forward.

Preview width

Validation

required already holds Next until there is an answer, and QuestionnaireError can say so. This flow goes further: the two answers are checked together, and a public summary is rejected. item with onItemChange jumps back to the first bad question, and invalid marks it. Use that when one answer makes another illegal, not for a simple missing choice.

Preview width

Controlled

item and onItemChange keep the current question name in state, and the line above the form only prints that name. Take control when something outside the questionnaire has to know where the person is, or when submit should send them back to a specific question. defaultItem is enough if you only choose where it opens.

Preview width

Resume

defaultItem reopens on a later question, and defaultChecked plus defaultValue put the saved answers back. The reset button fires onReset, so a restored draft can be told apart from edits made just now. Use this when someone left a flow halfway and should not restart at the first question. Owning the current item the whole time is the controlled demo, not this one.

Preview width

Conditional

The environment question stays disabled until Cloud is chosen, on the item and in the items list, so a local run never stops there. Use disabled when a follow-up only makes sense for one answer, and keep the choice in state so the list can change as they click. A question you always ask should stay enabled, even if you wish it were shorter.

Preview width

onStatusChange reports the current item as unanswered, and Next plus Submit set disabled from that. The opacity class tied to unanswered status makes the blocked button look blocked, not merely silent. Use this when the action should look unavailable until a choice exists. required already refuses to advance, and this is the visible version of that rule.

Preview width

Progress

QuestionnaireProgress can take a render function that receives the element props plus current and total. The bar here draws one segment per question and writes the count beside it. Use a custom render when the stock progress text is too quiet, or when that count has to live in a card action. The plain progress is enough on a short flow.

Preview width

Animated

Each item opts into a short fade and a rise while data-active, and motion-reduce turns that off. A little motion helps when a hard swap between questions feels like a glitch, but a long travel on every Next becomes the thing people wait on. Put the classes on the item, not the root, because the root does not enter and leave.

Preview width

Card

The questions live in a Card. The title passes render so it becomes a CardTitle, the description becomes a CardDescription, and progress sits in CardAction, with the buttons in the footer. Use a card when this flow is the page and should share a surface with the rest of the UI. A bare questionnaire is lighter when it already sits inside some other panel.

Preview width

Dialog

The flow opens inside a Dialog from a button, and a successful submit closes it. The question title is the dialog title, so the modal keeps a name instead of an empty one. Use a dialog when the questions are a side task, such as a clarification, and the page should stay put. A card on the page fits better when the flow is why anyone opened it.

Preview width

FAQ

When should I use a Questionnaire instead of a regular form?

Use a Questionnaire when people should answer one question at a time: onboarding, surveys, quizzes, or flows where the next question depends on the last answer. A regular form built with Field shows every field at once, which suits short forms and settings pages better.

How do I get the answers from a Questionnaire?

Questionnaire is a form, so handle onSubmit and read the answers with FormData, by each item's name. For an item with multiple, use formData.getAll(name), since get returns only the first answer; a skipped question submits no value at all.

How do I make a Questionnaire question required?

Set required on its QuestionnaireItem, and on the same item in items. Next and Submit then wait for an answer, QuestionnaireError says "Choose an answer to continue.", and QuestionnaireSkip hides itself on that question. The Validation demo above shows it.

How do I control which question the Radix UI Questionnaire shows?

Pass item, the current question's name, and onItemChange on Questionnaire, as in the Controlled demo. To only choose where it starts, pass defaultItem: the Resume demo uses it to reopen a saved questionnaire at the question people left off.

Does the Questionnaire support keyboard shortcuts?

Yes. Set shortcuts to letters or numbers, and each choice gets a key that picks it. Cmd or Ctrl with Enter moves to the next question, ArrowUp and ArrowDown move between answers, and ArrowLeft goes back, as the Shortcuts demo shows.

Is the Radix UI Questionnaire accessible?

Yes. Each question is a fieldset with its title as the legend, and only the active one can be reached, since the others are hidden and inert. QuestionnaireProgress is a progress bar that announces "Question 2 of 5", and errors are announced as alerts.

Is the Questionnaire available for Vue, Svelte or Solid?

Not yet: the Questionnaire is React only. It comes for each React library this site covers (Base UI, Radix UI, React Aria and Ark UI), with the same parts on each.

How do I install the Radix UI Questionnaire?

Run the command from the Installation section above. The shadcn CLI writes components/ui/questionnaire.tsx and the button it uses, which you own and can edit, such as a different style for QuestionnaireNext.

API Reference

Questionnaire

<form> data-slot="questionnaire"

PropType
item?string

Controlled active item name.

defaultItem?string

Initially active item name. Defaults to the first enabled item.

onItemChange?(item: string) → void

Called when navigation requests a different item.

Parameters

item – string

Returns
void
items?readonly QuestionnaireItemDefinition[]

Optional item data for server rendering and item order.

Type
readonly QuestionnaireItemDefinition[]
noValidate?boolean

Disables native constraint UI while preserving questionnaire validation.

Default
true
onReset?FormEventHandler<HTMLFormElement>

Native reset handler. Prevent default to stop questionnaire reset.

Type
FormEventHandler<HTMLFormElement>
onSubmit?FormEventHandler<HTMLFormElement>

Native form submission handler after every item validates.

Type
FormEventHandler<HTMLFormElement>
shortcuts?"letters" | "numbers"

Assigns scoped answer shortcuts in definition or rendered DOM order.

Type
"letters" | "numbers"
className?string

Merged with the part's classes.

AttributeType
data-currentstring

One-based active item position.

data-first?string

Present on the first item.

data-last?string

Present on the last item.

data-shortcuts?"letters" | "numbers"

"letters" or "numbers", when enabled.

Type
"letters" | "numbers"
data-totalstring

Number of enabled items.

QuestionnaireActions

<div> data-slot="questionnaire-actions"

PropType
className?string

Merged with the part's classes.

QuestionnaireChoice

<label> data-slot="questionnaire-choice"

PropType
valuestring

Native answer value.

checked?boolean

Controlled checked state.

defaultChecked?boolean

Initial checked state.

Default
false
disabled?boolean

Disables the native answer control.

Default
false
onChange?React.ChangeEventHandler<HTMLInputElement>

Native input change handler.

Type
React.ChangeEventHandler<HTMLInputElement>
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with choice state. Its function's state has checked, disabled, invalid, shortcut and type.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<label>
className?string

Merged with the part's classes.

AttributeType
data-checked?string

Present when selected.

data-disabled?string

Present when disabled.

data-invalid?string

Present when its item is invalid.

data-shortcutstring

Assigned letter or number.

data-type"radio" | "checkbox"

"radio" or "checkbox".

Type
"radio" | "checkbox"
data-unchecked?string

Present when not selected.

QuestionnaireChoiceDescription

<span> data-slot="questionnaire-choice-description"

PropType
className?string

Merged with the part's classes.

QuestionnaireChoices

<div> data-slot="questionnaire-choices"

PropType
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target. Its function's state has shortcuts.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<div>
className?string

Merged with the part's classes.

AttributeType
data-shortcuts?"letters" | "numbers"

"letters" or "numbers", when enabled.

Type
"letters" | "numbers"

QuestionnaireDescription

<p> data-slot="questionnaire-description"

PropType
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<p>
className?string

Merged with the part's classes.

QuestionnaireError

<p> data-slot="questionnaire-error"

PropType
children?React.ReactNode

Custom validation message. Defaults to the contextual message.

render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with invalid state. Its function's state has invalid.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<p>
className?string

Merged with the part's classes.

AttributeType
data-invalid?string

Present while the error is active.

QuestionnaireInput

<div> data-slot="questionnaire-input"

PropType
defaultValue?string | number | readonly string[]

Initial native input value.

Type
string | number | readonly string[]
disabled?boolean

Disables the input.

Default
false
onChange?ChangeEventHandler<HTMLInputElement>

Native input change handler.

Type
ChangeEventHandler<HTMLInputElement>
type?"date" | "datetime-local" | "email" | "month" | "number" | "password" | "search" | "tel" | "text" | "time" | "url" | "week"

A text-entry type such as "email", "number", or "date".

Type
"date" | "datetime-local" | "email" | "month" | "number" | "password" | "search" | "tel" | "text" | "time" | "url" | "week"
Default
"text"
value?string | number | readonly string[]

Controlled native input value.

Type
string | number | readonly string[]
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with input state. Its function's state has disabled, filled and invalid.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<input>
className?string

Merged with the part's classes.

AttributeType
data-disabled?string

Present when disabled.

data-empty?string

Present when the input has no answer text.

data-filled?string

Present when the input has non-empty text.

data-invalid?string

Present when its item is invalid.

QuestionnaireItem

<fieldset> data-slot="questionnaire-item"

PropType
namestring

Unique item identifier and native form field name.

disabled?boolean

Omits the item from progress and navigation.

Default
false
invalid?boolean

Marks the item invalid from an external validator.

Default
false
multiple?boolean

Renders fixed choices as checkboxes.

Default
false
onStatusChange?(status: QuestionnaireItemStatus) → void

Observes "unanswered", "answered", and "skipped" changes.

Type
(status: QuestionnaireItemStatus) → void
Parameters

status – QuestionnaireItemStatus

Returns
void
required?boolean

Requires an answer and prevents Skip.

Default
false
className?string

Merged with the part's classes.

AttributeType
data-active?string

Present when active.

data-disabled?string

Present when disabled.

data-invalid?string

Present after failed validation.

data-multiple?string

Present when multiple.

data-required?string

Present when required.

data-status"unanswered" | "answered" | "skipped"

"unanswered", "answered" or "skipped".

Type
"unanswered" | "answered" | "skipped"

QuestionnaireNext

<button> data-slot="questionnaire-next"

PropType
size?"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"

The size.

Type
"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"
Default
"md"
variant?"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"

The visual style.

Type
"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"
Default
"default"
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with navigation state. Its function's state has disabled, shortcut, status and visible.

Type
ReactElement | ((props: object, state: object) => ReactElement)
className?string

Merged with the part's classes.

AttributeType
data-disabled?string

Present when disabled.

data-hidden?string

Present when the action does not apply.

data-shortcut?"Enter"

"Enter", on an enabled, visible Next or Submit.

data-size?string

Mirrors size.

data-status"unanswered" | "answered" | "skipped"

Active item: "unanswered", "answered", or "skipped".

Type
"unanswered" | "answered" | "skipped"
data-variant?string

Mirrors variant.

data-visible?string

Present when the action applies to the active item.

QuestionnairePrevious

<button> data-slot="questionnaire-previous"

PropType
size?"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"

The size.

Type
"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"
Default
"md"
variant?"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"

Button's default: default.

Type
"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"
Default
"outline"
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with navigation state. Its function's state has disabled, shortcut, status and visible.

Type
ReactElement | ((props: object, state: object) => ReactElement)
className?string

Merged with the part's classes.

AttributeType
data-disabled?string

Present when disabled.

data-hidden?string

Present when the action does not apply.

data-shortcut?"Enter"

"Enter", on an enabled, visible Next or Submit.

data-size?string

Mirrors size.

data-status"unanswered" | "answered" | "skipped"

Active item: "unanswered", "answered", or "skipped".

Type
"unanswered" | "answered" | "skipped"
data-variant?string

Mirrors variant.

data-visible?string

Present when the action applies to the active item.

QuestionnaireProgress

<div> data-slot="questionnaire-progress"

PropType
aria-label?string

Accessible progressbar name.

Default
Questionnaire progress
children?React.ReactNode

Custom progress content.

Default
Question {current} of {total}
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with progress state. Its function's state has current, first, last and total.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<div>
className?string

Merged with the part's classes.

AttributeType
data-currentstring

One-based active item position.

data-first?string

Present on the first item.

data-last?string

Present on the last item.

data-totalstring

Number of enabled items.

QuestionnaireSkip

<button> data-slot="questionnaire-skip"

PropType
size?"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"

The size.

Type
"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"
Default
"md"
variant?"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"

Button's default: default.

Type
"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"
Default
"outline"
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with navigation state. Its function's state has disabled, shortcut, status and visible.

Type
ReactElement | ((props: object, state: object) => ReactElement)
className?string

Merged with the part's classes.

AttributeType
data-disabled?string

Present when disabled.

data-hidden?string

Present when the action does not apply.

data-shortcut?"Enter"

"Enter", on an enabled, visible Next or Submit.

data-size?string

Mirrors size.

data-status"unanswered" | "answered" | "skipped"

Active item: "unanswered", "answered", or "skipped".

Type
"unanswered" | "answered" | "skipped"
data-variant?string

Mirrors variant.

data-visible?string

Present when the action applies to the active item.

QuestionnaireSubmit

<button> data-slot="questionnaire-submit"

PropType
size?"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"

The size.

Type
"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"
Default
"md"
variant?"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"

The visual style.

Type
"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"
Default
"default"
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target with navigation state. Its function's state has disabled, shortcut, status and visible.

Type
ReactElement | ((props: object, state: object) => ReactElement)
className?string

Merged with the part's classes.

AttributeType
data-disabled?string

Present when disabled.

data-hidden?string

Present when the action does not apply.

data-shortcut?"Enter"

"Enter", on an enabled, visible Next or Submit.

data-size?string

Mirrors size.

data-status"unanswered" | "answered" | "skipped"

Active item: "unanswered", "answered", or "skipped".

Type
"unanswered" | "answered" | "skipped"
data-variant?string

Mirrors variant.

data-visible?string

Present when the action applies to the active item.

QuestionnaireTitle

<legend> data-slot="questionnaire-title"

PropType
render?ReactElement | ((props: object, state: object) => ReactElement)

Custom render target. Pair its id with the item’s aria-labelledby.

Type
ReactElement | ((props: object, state: object) => ReactElement)
Default
<legend>
className?string

Merged with the part's classes.