HextaUI

Questionnaire

A step-by-step form that moves between questions with a sense of direction, shows real progress and answers from the keyboard.

Question 1 of 3
What best describes your work?

We'll tailor the examples you see first.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoiceDescription,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

const items = [
  {
    name: "role",
    required: true,
    prompt: "What best describes your work?",
    description: "We'll tailor the examples you see first.",
    choices: [
      {
        value: "design",
        label: "Design",
        description: "Interfaces, systems and prototypes.",
      },
      {
        value: "engineering",
        label: "Engineering",
        description: "Shipping and maintaining product code.",
      },
      {
        value: "both",
        label: "A bit of both",
        description: "Design engineering, the best kind.",
      },
    ],
  },
  {
    name: "interests",
    multiple: true,
    required: true,
    prompt: "Which components do you reach for most?",
    description: "Pick as many as you like.",
    choices: [
      { value: "forms", label: "Forms and inputs" },
      { value: "overlays", label: "Dialogs and sheets" },
      { value: "navigation", label: "Menus and navigation" },
      { value: "data", label: "Tables and data" },
    ],
  },
  {
    name: "source",
    prompt: "How did you find HextaUI?",
    description: "Optional. Skip it if you don't remember.",
    choices: [
      { value: "x", label: "X / Twitter" },
      { value: "github", label: "GitHub" },
      { value: "friend", label: "A friend" },
    ],
    input: { label: "Somewhere else", placeholder: "Somewhere else…" },
  },
] as const

export function QuestionnaireDemo() {
  const [answers, setAnswers] = React.useState<[string, string][] | null>(null)

  if (answers) {
    return (
      <div className="flex w-full max-w-md flex-col items-start gap-4 rounded-xl border p-5">
        <div className="flex flex-col gap-1">
          <p className="font-medium">Thanks, that helps a lot.</p>
          <p className="text-sm text-muted-foreground">
            Here&apos;s what the form sent:
          </p>
        </div>
        <ul className="flex w-full flex-col gap-1 font-mono text-xs">
          {answers.map(([name, value], index) => (
            <li key={index} className="flex justify-between gap-4">
              <span className="text-muted-foreground">{name}</span>
              <span className="truncate">{value}</span>
            </li>
          ))}
        </ul>
        <Button variant="outline" size="sm" onClick={() => setAnswers(null)}>
          Start over
        </Button>
      </div>
    )
  }

  return (
    <div className="w-full max-w-md rounded-xl border p-5">
      <Questionnaire
        items={items}
        shortcuts="letters"
        onSubmit={(event) => {
          event.preventDefault()
          const data = new FormData(event.currentTarget)
          setAnswers(
            Array.from(data.entries(), ([name, value]) => [name, String(value)])
          )
        }}
      >
        <QuestionnaireProgress />
        {items.map((item) => (
          <QuestionnaireItem
            key={item.name}
            name={item.name}
            required={"required" in item && item.required}
            multiple={"multiple" in item && item.multiple}
          >
            <QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
            <QuestionnaireDescription>
              {item.description}
            </QuestionnaireDescription>
            <QuestionnaireChoices>
              {item.choices.map((choice) => (
                <QuestionnaireChoice key={choice.value} value={choice.value}>
                  <span className="font-medium">{choice.label}</span>
                  {"description" in choice ? (
                    <QuestionnaireChoiceDescription>
                      {choice.description}
                    </QuestionnaireChoiceDescription>
                  ) : null}
                </QuestionnaireChoice>
              ))}
              {"input" in item ? (
                <QuestionnaireInput
                  aria-label={item.input.label}
                  placeholder={item.input.placeholder}
                />
              ) : null}
            </QuestionnaireChoices>
            <QuestionnaireError />
          </QuestionnaireItem>
        ))}
        <QuestionnaireActions>
          <QuestionnairePrevious />
          <QuestionnaireSkip />
          <QuestionnaireNext />
          <QuestionnaireSubmit />
        </QuestionnaireActions>
      </Questionnaire>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/questionnaire.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"
<Questionnaire onSubmit={handleSubmit}>
  <QuestionnaireProgress />
  <QuestionnaireItem name="role" required>
    <QuestionnaireTitle>What best describes your work?</QuestionnaireTitle>
    <QuestionnaireChoices>
      <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
      <QuestionnaireChoice value="engineering">Engineering</QuestionnaireChoice>
    </QuestionnaireChoices>
  </QuestionnaireItem>
  <QuestionnaireActions>
    <QuestionnairePrevious />
    <QuestionnaireNext />
    <QuestionnaireSubmit />
  </QuestionnaireActions>
</Questionnaire>

Answers submit as a regular form, keyed by each item's name. Questions move toward the direction you go, the card eases to each question's height, and the progress fills only for questions that were answered or skipped.

Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│   ├── QuestionnaireTitle
│   ├── QuestionnaireDescription
│   ├── QuestionnaireChoices
│   │   ├── QuestionnaireChoice
│   │   │   └── QuestionnaireChoiceDescription
│   │   └── QuestionnaireInput
│   └── QuestionnaireError
└── QuestionnaireActions
    ├── QuestionnairePrevious
    ├── QuestionnaireSkip
    ├── QuestionnaireNext
    └── QuestionnaireSubmit

Lift transition and bar

transition="lift" moves questions up and down like a stack. variant="bar" on the progress shows one continuous bar.

"use client"

import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

const items = [
  {
    name: "team",
    prompt: "How big is your team?",
    choices: ["Just me", "2–10", "11–50", "More than 50"],
  },
  {
    name: "stage",
    prompt: "Where is your product today?",
    choices: ["Idea", "In development", "Launched"],
  },
  {
    name: "timeline",
    prompt: "When do you want to ship?",
    choices: ["This month", "This quarter", "No rush"],
  },
]

export function QuestionnaireLift() {
  return (
    <div className="w-full max-w-md rounded-xl border p-5">
      <Questionnaire
        transition="lift"
        onSubmit={(event) => event.preventDefault()}
      >
        <QuestionnaireProgress variant="bar" />
        {items.map((item) => (
          <QuestionnaireItem key={item.name} name={item.name} required>
            <QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
            <QuestionnaireChoices>
              {item.choices.map((choice) => (
                <QuestionnaireChoice key={choice} value={choice}>
                  {choice}
                </QuestionnaireChoice>
              ))}
            </QuestionnaireChoices>
          </QuestionnaireItem>
        ))}
        <QuestionnaireActions>
          <QuestionnairePrevious />
          <QuestionnaireNext />
          <QuestionnaireSubmit />
        </QuestionnaireActions>
      </Questionnaire>
    </div>
  )
}

Auto-advance

With autoAdvance, picking a single answer moves on after a short pause, so you can answer with 1–5 alone. Changing an earlier answer never jumps ahead, and the last question never submits by itself.

"use client"

import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

const scale = ["Not at all", "Slightly", "Moderately", "Very", "Extremely"]

const items = [
  { name: "useful", prompt: "How useful was today's session?" },
  { name: "pace", prompt: "How comfortable was the pace?" },
  { name: "recommend", prompt: "How likely are you to come back?" },
]

export function QuestionnaireAutoAdvance() {
  return (
    <div className="w-full max-w-md rounded-xl border p-5">
      <Questionnaire
        autoAdvance
        shortcuts="numbers"
        onSubmit={(event) => event.preventDefault()}
      >
        <QuestionnaireProgress />
        {items.map((item) => (
          <QuestionnaireItem key={item.name} name={item.name} required>
            <QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
            <QuestionnaireChoices>
              {scale.map((label) => (
                <QuestionnaireChoice key={label} value={label}>
                  {label}
                </QuestionnaireChoice>
              ))}
            </QuestionnaireChoices>
          </QuestionnaireItem>
        ))}
        <QuestionnaireActions>
          <QuestionnairePrevious />
          <QuestionnaireNext />
          <QuestionnaireSubmit />
        </QuestionnaireActions>
      </Questionnaire>
    </div>
  )
}

Conditional questions

Disable an item to leave it out. It drops from the progress and the flow until it applies.

"use client"

import * as React from "react"

import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

export function QuestionnaireConditional() {
  const [usesFramework, setUsesFramework] = React.useState<string | null>(null)

  return (
    <div className="w-full max-w-md rounded-xl border p-5">
      <Questionnaire onSubmit={(event) => event.preventDefault()}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="framework" required>
          <QuestionnaireTitle>Do you use a React framework?</QuestionnaireTitle>
          <QuestionnaireChoices>
            {["Yes", "No"].map((value) => (
              <QuestionnaireChoice
                key={value}
                value={value}
                onChange={(event) => setUsesFramework(event.target.value)}
              >
                {value}
              </QuestionnaireChoice>
            ))}
          </QuestionnaireChoices>
        </QuestionnaireItem>
        <QuestionnaireItem
          name="which"
          required
          disabled={usesFramework !== "Yes"}
        >
          <QuestionnaireTitle>Which one?</QuestionnaireTitle>
          <QuestionnaireDescription>
            Only asked when you use one. The progress counts it only then.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            {["Next.js", "React Router", "TanStack Start"].map((value) => (
              <QuestionnaireChoice key={value} value={value}>
                {value}
              </QuestionnaireChoice>
            ))}
          </QuestionnaireChoices>
        </QuestionnaireItem>
        <QuestionnaireItem name="email">
          <QuestionnaireTitle>
            Where can we send the results?
          </QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireInput
              type="email"
              aria-label="Email"
              placeholder="[email protected]"
            />
          </QuestionnaireChoices>
        </QuestionnaireItem>
        <QuestionnaireActions>
          <QuestionnairePrevious />
          <QuestionnaireNext />
          <QuestionnaireSubmit />
        </QuestionnaireActions>
      </Questionnaire>
    </div>
  )
}

Right to left

The slide follows the reading direction.

"use client"

import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

const items = [
  {
    name: "language",
    prompt: "ما اللغة التي تفضلها؟",
    choices: ["العربية", "الإنجليزية", "كلاهما"],
  },
  {
    name: "theme",
    prompt: "أي مظهر تستخدم؟",
    choices: ["فاتح", "داكن", "حسب النظام"],
  },
]

export function QuestionnaireRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md rounded-xl border p-5">
      <Questionnaire onSubmit={(event) => event.preventDefault()}>
        <QuestionnaireProgress />
        {items.map((item) => (
          <QuestionnaireItem key={item.name} name={item.name} required>
            <QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
            <QuestionnaireChoices>
              {item.choices.map((choice) => (
                <QuestionnaireChoice key={choice} value={choice}>
                  {choice}
                </QuestionnaireChoice>
              ))}
            </QuestionnaireChoices>
          </QuestionnaireItem>
        ))}
        <QuestionnaireActions>
          <QuestionnairePrevious>السابق</QuestionnairePrevious>
          <QuestionnaireNext>التالي</QuestionnaireNext>
          <QuestionnaireSubmit>إرسال</QuestionnaireSubmit>
        </QuestionnaireActions>
      </Questionnaire>
    </div>
  )
}
KeyAction
↑↓Moves between the answers, including the text field. Moving doesn't pick.
SpacePicks the focused answer, or toggles it in multiple choice.
EnterContinues. On an unpicked single answer it picks it first. Submits on the last question.
←→Previous or next question, mirrored in right-to-left. Next shakes when an answer is needed. Inside the text field they move the cursor.
HomeEndFirst or last answer.
A1Picks an answer by its key when shortcuts is set. Key caps brighten once the questionnaire has focus; click anywhere in it to start.
EscLeaves the text field so letter shortcuts work again.
⌘EnterSubmits from anywhere.
  • Each QuestionnaireItem is a <fieldset> whose title is its <legend>. Inactive questions are hidden and inert, including the one animating out.
  • The progress is a progressbar that announces “Question 2 of 5”; the segments and spinning number are decorative.
  • Errors use role="alert" and the invalid answers get aria-invalid.
  • With reduced motion, questions switch instantly and nothing shakes.
PropTypeDefault
transition
"slide" | "lift""slide"
autoAdvanceMoves on 350ms after a single answer is picked for the first time.
booleanfalse
shortcutsAdds a key shortcut to every answer.
"letters" | "numbers"–
item
string–
defaultItem
string–
onItemChange
(item: string) => void–
AttributeDescription
data-slot="questionnaire"The form, with data-transition.
data-current / data-totalThe current position and number of questions.
data-first / data-lastPresent on the first or last question.
PropTypeDefault
variant
"segments" | "bar""segments"
AttributeDescription
data-slot="questionnaire-progress-segment"One per question, with data-status, data-active and data-invalid.
PropTypeDefault
nameThe form field name.
string–
required
booleanfalse
multipleCheckboxes instead of radios.
booleanfalse
disabledLeaves the question out of the flow.
booleanfalse
onStatusChange
(status) => void–
AttributeDescription
data-status"unanswered", "answered" or "skipped".
data-activeThe current question.
data-invalidNeeds an answer.
data-leavingThe question animating out.
PropTypeDefault
value
string–
defaultChecked
boolean–
disabled
booleanfalse
render
ReactElement | (props, state) => ReactElement<label>
AttributeDescription
data-checked / data-uncheckedWhether the answer is picked.
data-type"radio" or "checkbox".
data-shortcutThe answer's key.

A free-text answer that sits with the choices. Typing clears the picked choice. Give it a label or aria-label.

PropTypeDefault
variant
Button variant"ghost", or "default" for Next and Submit
size
Button size"default"

QuestionnaireNext and QuestionnaireSubmit share a slot, so the last question swaps Next for Submit without moving anything.