Questionnaire

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

Question 1 of 3
What should the agent build next?

Choose a direction or describe another task.

About

Questionnaire is built on @sveltery/questionnaire, a Svelte package that takes its design from @shadcn/react/questionnaire. It presents one question at a time and manages answers, progress, validation and navigation on top of native form controls.

Installation

Install the dependencies

pnpm add cn class-variance-authority @sveltery/questionnaire

Copy the component

Copy the following files into src/lib/components/ui/questionnaire.

It imports the button component, so copy its folder too.

src/lib/components/ui/questionnaire/Questionnaire.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireProps } from './types.js';
  let { item = $bindable(), class: classProp, ...props }: QuestionnaireProps = $props();
</script>
<Primitive.Root data-slot="questionnaire" bind:item class={cn('gap-4 flex w-full min-w-0 flex-col', classProp)} {...props} />
src/lib/components/ui/questionnaire/QuestionnaireActions.svelte
<script lang="ts">
  import { cn } from 'cn';
  import type { QuestionnaireActionsProps } from './types.js';
  let { children, class: classProp, ...props }: QuestionnaireActionsProps = $props();
</script>
<div data-slot="questionnaire-actions" {...props} class={cn('gap-2 sm:min-h-8 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/questionnaire/QuestionnaireChoice.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import CheckIcon from '@lucide/svelte/icons/check';
  import type { QuestionnaireChoiceProps } from './types.js';
  let { checked = $bindable(), children, class: classProp, ...props }: QuestionnaireChoiceProps = $props();
</script>
<Primitive.Choice
  data-slot="questionnaire-choice"
  bind:checked
  class={cn(
    'border-input dark:bg-input/20 hover:bg-muted/50 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-2.5 rounded-lg border bg-transparent px-3 py-2.5 text-sm has-[>input:focus-visible]:ring-3 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start transition-colors outline-none select-none',
    'data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
    classProp,
  )}
  {...props}
>
  <Primitive.ChoiceInput
    data-slot="questionnaire-choice-input"
    class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
  />
  <span
    aria-hidden="true"
    data-slot="questionnaire-choice-indicator"
    class="border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 translate-y-[--spacing(0.45)] group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 rounded-[4px] pointer-events-none relative flex shrink-0 items-center justify-center border group-data-[type=radio]/questionnaire-choice:rounded-full"
  >
    <span
      data-slot="questionnaire-choice-indicator-dot"
      class="bg-primary-foreground size-2 hidden rounded-full group-data-[type=checkbox]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
    ></span>
    <CheckIcon
      data-slot="questionnaire-choice-indicator-check"
      class="size-3.5 hidden group-data-[type=radio]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
    />
  </span>
  <Primitive.ChoiceLabel
    data-slot="questionnaire-choice-label"
    class="gap-0.5 flex min-w-0 flex-1 flex-col leading-snug"
  >
    {@render children?.()}
  </Primitive.ChoiceLabel>
  <Primitive.ChoiceShortcut
    data-slot="questionnaire-choice-shortcut"
    class="border-input bg-background text-muted-foreground size-5 translate-y-[--spacing(0.45)] group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 items-center justify-center rounded-md border font-mono text-[0.625rem] font-medium leading-none pointer-events-none ms-auto hidden shrink-0 group-data-[shortcut]/questionnaire-choice:inline-flex"
  />
</Primitive.Choice>
src/lib/components/ui/questionnaire/QuestionnaireChoiceDescription.svelte
<script lang="ts">
  import { cn } from 'cn';
  import type { QuestionnaireChoiceDescriptionProps } from './types.js';
  let { children, class: classProp, ...props }: QuestionnaireChoiceDescriptionProps = $props();
</script>
<span data-slot="questionnaire-choice-description" {...props} class={cn('text-muted-foreground', classProp)}>
  {@render children?.()}
</span>
src/lib/components/ui/questionnaire/QuestionnaireChoices.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireChoicesProps } from './types.js';
  let { class: classProp, ...props }: QuestionnaireChoicesProps = $props();
</script>
<Primitive.Choices data-slot="questionnaire-choices" class={cn('gap-2 group/questionnaire-choices grid min-w-0', classProp)} {...props} />
src/lib/components/ui/questionnaire/QuestionnaireDescription.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireDescriptionProps } from './types.js';
  let { class: classProp, ...props }: QuestionnaireDescriptionProps = $props();
</script>
<Primitive.Description data-slot="questionnaire-description" class={cn('text-sm text-pretty text-muted-foreground', classProp)} {...props} />
src/lib/components/ui/questionnaire/QuestionnaireError.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireErrorProps } from './types.js';
  let { class: classProp, ...props }: QuestionnaireErrorProps = $props();
</script>
<Primitive.Error data-slot="questionnaire-error" class={cn('mt-2 text-sm text-destructive', classProp)} {...props} />
src/lib/components/ui/questionnaire/QuestionnaireInput.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireInputProps } from './types.js';
  let { value = $bindable(), class: classProp, ...props }: QuestionnaireInputProps = $props();
</script>
<div data-slot="questionnaire-input-wrapper" class="w-full group/questionnaire-input relative min-w-0">
  <Primitive.Input
    data-slot="questionnaire-input"
    bind:value
    class={cn(
      'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base focus-visible:ring-3 aria-invalid:ring-3 md:text-sm min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
      'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
      classProp,
    )}
    {...props}
  />
</div>
src/lib/components/ui/questionnaire/QuestionnaireItem.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireItemProps } from './types.js';
  let { class: classProp, ...props }: QuestionnaireItemProps = $props();
</script>
<Primitive.Item data-slot="questionnaire-item" class={cn('flex flex-col gap-4 min-w-0 border-0 p-0 outline-none', classProp)} {...props} />
src/lib/components/ui/questionnaire/QuestionnaireNext.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import { buttonVariants } from '../button/index.js';
  import type { QuestionnaireNextProps } from './types.js';
  let { class: classProp, size = 'default', variant = 'default', ...props }: QuestionnaireNextProps = $props();
</script>
<Primitive.Next
  data-slot="questionnaire-next"
  data-size={size}
  data-variant={variant}
  class={cn(buttonVariants({ size, variant }), 'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0', classProp)}
  {...props}
/>
src/lib/components/ui/questionnaire/QuestionnairePrevious.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import { buttonVariants } from '../button/index.js';
  import type { QuestionnairePreviousProps } from './types.js';
  let { class: classProp, size = 'default', variant = 'outline', ...props }: QuestionnairePreviousProps = $props();
</script>
<Primitive.Previous
  data-slot="questionnaire-previous"
  data-size={size}
  data-variant={variant}
  class={cn(buttonVariants({ size, variant }), 'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0', classProp)}
  {...props}
/>
src/lib/components/ui/questionnaire/QuestionnaireProgress.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireProgressProps } from './types.js';
  let { class: classProp, ...props }: QuestionnaireProgressProps = $props();
</script>
<Primitive.Progress data-slot="questionnaire-progress" class={cn('text-xs min-h-[1lh] w-fit min-w-[14ch] font-medium text-muted-foreground tabular-nums', classProp)} {...props} />
src/lib/components/ui/questionnaire/QuestionnaireSkip.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import { buttonVariants } from '../button/index.js';
  import type { QuestionnaireSkipProps } from './types.js';
  let { class: classProp, size = 'default', variant = 'outline', ...props }: QuestionnaireSkipProps = $props();
</script>
<Primitive.Skip
  data-slot="questionnaire-skip"
  data-size={size}
  data-variant={variant}
  class={cn(buttonVariants({ size, variant }), 'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0', classProp)}
  {...props}
/>
src/lib/components/ui/questionnaire/QuestionnaireSubmit.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import { buttonVariants } from '../button/index.js';
  import type { QuestionnaireSubmitProps } from './types.js';
  let { class: classProp, size = 'default', variant = 'default', ...props }: QuestionnaireSubmitProps = $props();
</script>
<Primitive.Submit
  data-slot="questionnaire-submit"
  data-size={size}
  data-variant={variant}
  class={cn(buttonVariants({ size, variant }), 'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0', classProp)}
  {...props}
/>
src/lib/components/ui/questionnaire/QuestionnaireTitle.svelte
<script lang="ts">
  import * as Primitive from '@sveltery/questionnaire';
  import { cn } from 'cn';
  import type { QuestionnaireTitleProps } from './types.js';
  let { class: classProp, ...props }: QuestionnaireTitleProps = $props();
</script>
<Primitive.Title data-slot="questionnaire-title" class={cn('text-base leading-snug font-medium [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4 text-pretty', classProp)} {...props} />
src/lib/components/ui/questionnaire/types.ts
import type { ComponentProps } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type * as Primitive from '@sveltery/questionnaire';
import type { ButtonVariants } from '../button/index.js';

type NavigationProps = Pick<ButtonVariants, 'size' | 'variant'>;

export type QuestionnaireProps = ComponentProps<typeof Primitive.Root>;
export type QuestionnaireProgressProps = ComponentProps<typeof Primitive.Progress>;
export type QuestionnaireItemProps = ComponentProps<typeof Primitive.Item>;
export type QuestionnaireTitleProps = ComponentProps<typeof Primitive.Title>;
export type QuestionnaireDescriptionProps = ComponentProps<typeof Primitive.Description>;
export type QuestionnaireChoicesProps = ComponentProps<typeof Primitive.Choices>;
export type QuestionnaireChoiceProps = ComponentProps<typeof Primitive.Choice>;
export type QuestionnaireChoiceDescriptionProps = HTMLAttributes<HTMLSpanElement>;
export type QuestionnaireInputProps = ComponentProps<typeof Primitive.Input>;
export type QuestionnaireErrorProps = ComponentProps<typeof Primitive.Error>;
export type QuestionnaireActionsProps = HTMLAttributes<HTMLDivElement>;
export type QuestionnairePreviousProps = ComponentProps<typeof Primitive.Previous> & NavigationProps;
export type QuestionnaireSkipProps = ComponentProps<typeof Primitive.Skip> & NavigationProps;
export type QuestionnaireNextProps = ComponentProps<typeof Primitive.Next> & NavigationProps;
export type QuestionnaireSubmitProps = ComponentProps<typeof Primitive.Submit> & NavigationProps;
export type {
  QuestionnaireChoiceDefinition,
  QuestionnaireInputType,
  QuestionnaireItemDefinition,
  QuestionnaireItemStatus,
  QuestionnaireShortcutMode,
} from '@sveltery/questionnaire';
src/lib/components/ui/questionnaire/index.ts
export { default as Questionnaire } from './Questionnaire.svelte';
export { default as QuestionnaireProgress } from './QuestionnaireProgress.svelte';
export { default as QuestionnaireItem } from './QuestionnaireItem.svelte';
export { default as QuestionnaireTitle } from './QuestionnaireTitle.svelte';
export { default as QuestionnaireDescription } from './QuestionnaireDescription.svelte';
export { default as QuestionnaireChoices } from './QuestionnaireChoices.svelte';
export { default as QuestionnaireChoice } from './QuestionnaireChoice.svelte';
export { default as QuestionnaireChoiceDescription } from './QuestionnaireChoiceDescription.svelte';
export { default as QuestionnaireInput } from './QuestionnaireInput.svelte';
export { default as QuestionnaireError } from './QuestionnaireError.svelte';
export { default as QuestionnaireActions } from './QuestionnaireActions.svelte';
export { default as QuestionnairePrevious } from './QuestionnairePrevious.svelte';
export { default as QuestionnaireSkip } from './QuestionnaireSkip.svelte';
export { default as QuestionnaireNext } from './QuestionnaireNext.svelte';
export { default as QuestionnaireSubmit } from './QuestionnaireSubmit.svelte';
export type {
  QuestionnaireProps,
  QuestionnaireProgressProps,
  QuestionnaireItemProps,
  QuestionnaireTitleProps,
  QuestionnaireDescriptionProps,
  QuestionnaireChoicesProps,
  QuestionnaireChoiceProps,
  QuestionnaireChoiceDescriptionProps,
  QuestionnaireInputProps,
  QuestionnaireErrorProps,
  QuestionnaireActionsProps,
  QuestionnairePreviousProps,
  QuestionnaireSkipProps,
  QuestionnaireNextProps,
  QuestionnaireSubmitProps,
  QuestionnaireChoiceDefinition,
  QuestionnaireInputType,
  QuestionnaireItemDefinition,
  QuestionnaireItemStatus,
  QuestionnaireShortcutMode,
} from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import {
    Questionnaire,
    QuestionnaireActions,
    QuestionnaireChoice,
    QuestionnaireChoices,
    QuestionnaireDescription,
    QuestionnaireError,
    QuestionnaireInput,
    QuestionnaireItem,
    QuestionnaireNext,
    QuestionnairePrevious,
    QuestionnaireProgress,
    QuestionnaireSkip,
    QuestionnaireSubmit,
    QuestionnaireTitle,
  } from '$lib/components/ui/questionnaire';
</script>
<script lang="ts">
  const items = [
    {
      name: 'direction',
      required: true,
      prompt: 'What should we prototype next?',
      description: 'Choose a direction or write your own.',
      choices: [
        { value: 'delegation', label: 'Delegation', description: 'Show how work moves to a specialist.' },
        { value: 'questions', label: 'Question prompts', description: 'Show choices while the interface waits.' },
        { value: 'both', label: 'Both together' },
      ],
      input: { label: 'Another answer', placeholder: 'Type another answer…' },
    },
    {
      name: 'detail',
      required: false,
      prompt: 'How much detail should it include?',
      description: 'Skip this if you are not sure yet.',
      choices: [
        { value: 'focused', label: 'Focused' },
        { value: 'complete', label: 'Complete flow' },
      ],
    },
  ] as const;
</script>

Define the collection once: pass it to Questionnaire for server-rendered progress, actions, and shortcuts, then loop over it to render the parts.

<Questionnaire {items} onsubmit={handleSubmit}>
  <QuestionnaireProgress />
  {#each items as question (question.name)}
    <QuestionnaireItem name={question.name} required={question.required}>
      <QuestionnaireTitle>{question.prompt}</QuestionnaireTitle>
      <QuestionnaireDescription>{question.description}</QuestionnaireDescription>
      <QuestionnaireChoices>
        {#each question.choices as choice (choice.value)}
          <QuestionnaireChoice value={choice.value}>
            <span class="font-medium">{choice.label}</span>
            {#if 'description' in choice}
              <span class="text-muted-foreground">{choice.description}</span>
            {/if}
          </QuestionnaireChoice>
        {/each}
        {#if 'input' in question}
          <QuestionnaireInput aria-label={question.input.label} placeholder={question.input.placeholder} />
        {/if}
      </QuestionnaireChoices>
      <QuestionnaireError />
    </QuestionnaireItem>
  {/each}
  <QuestionnaireActions>
    <QuestionnairePrevious />
    <QuestionnaireSkip />
    <QuestionnaireNext />
    <QuestionnaireSubmit />
  </QuestionnaireActions>
</Questionnaire>
<script lang="ts">
  function handleSubmit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
    event.preventDefault();
    const answers = new FormData(event.currentTarget);
    // answers.get('direction'), answers.getAll(...) for multiple items.
  }
</script>

Composition

Use the following composition to build a Questionnaire:

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

Questionnaire owns the ordered items, active item, answer state, validation, progress, and navigation. The containing page, card, dialog, or drawer owns close and cancellation behavior, persistence, transport, and branching.

Server Rendering

Pass items to server-render the active item, progress, actions, and answer shortcuts. The API reference below describes the complete behavior.

Multiple Selection

Use multiple for an item that accepts more than one fixed answer. Read the answers with FormData.getAll().

What context should the agent inspect?

Select every source that may affect the implementation.

Freeform Answer

Compose QuestionnaireInput with fixed choices when the user can provide another answer.

How should the agent approach this refactor?

Choose a strategy or write a more specific instruction.

Explicit Skip

Add QuestionnaireSkip when an optional item may be intentionally left unanswered. Use onStatusChange on the item when your application needs to distinguish a skipped answer from a missing one. A skipped item is absent from FormData.

Question 1 of 3
What kind of change is this?

Choose the category that best describes the work.

Shortcuts

Assign a letter or number key to each answer with shortcuts. "letters" assigns A through Z and "numbers" assigns 1 through 9. Disabled choices are skipped, and selecting an answer by shortcut does not advance to the next item.

What should the agent do next?

Use the displayed shortcut or navigate with the keyboard.

Disabled Choice

Set disabled on a QuestionnaireChoice to make one answer unavailable. A disabled choice cannot be selected, is skipped by shortcuts and is left out of FormData. Use QuestionnaireChoiceDescription for the muted line under a choice label.

Choose a plan

Enterprise is not available on your account.

No Description

QuestionnaireDescription is optional. Leave it out when the title is enough and the item still keeps its progress, choices and shortcuts.

Question 1 of 1
What should the agent do next?

Validation

Questionnaire validates the active item before moving forward and validates all enabled items when the form submits.

  • A required item is valid after it has an answer.
  • An optional item is valid after it has an answer or is explicitly skipped.
  • Disabled items and answers are ignored.

required does not add visible "Required" text. Say it in the QuestionnaireTitle or QuestionnaireDescription. When validation fails, Questionnaire keeps or opens the invalid item and focuses an answer. QuestionnaireError stays hidden until its item is invalid, and its children replace the default message.

<QuestionnaireItem name="scope" required>
  <QuestionnaireTitle>What should the project include? (Required)</QuestionnaireTitle>
  <QuestionnaireChoices>…</QuestionnaireChoices>
  <QuestionnaireError>Please choose a project scope.</QuestionnaireError>
</QuestionnaireItem>

Custom Validation

Combine bind:item with your own check to return to an invalid item and present its error. Set invalid on the item, render the message in QuestionnaireError, and move item to the first invalid item.

How much detail should the answer include?

Choose the response depth.

1 / 2

Controlled

Bind item to control the active item from host state, such as returning to an invalid step. onItemChange runs whenever navigation requests a different item.

Current checkpoint: Change scope

Question 1 of 3
What may the agent change?

The host stores the active checkpoint while Questionnaire navigates.

Resume

Restore a saved active item and default answers with defaultItem, defaultChecked and defaultValue, then reset changes back to that saved state with a type="reset" button.

Question 2 of 3
How should the migration be verified?

These checks were selected during the previous session.

Conditional Items

Disable items that do not apply to the user's earlier answers. Disabled items are excluded from progress, navigation, validation, and submission.

Question 1 of 2
Where should the agent run?

Cloud runs add an environment question to this flow.

Navigation actions stay enabled by default so activating Next or Submit can show a validation error. Read item status to opt into disabled navigation and custom action styling. data-status carries the active item's status, so you can restyle an action without reading it in script.

Question 1 of 2
What may the agent modify?

Next is intentionally disabled until an answer is selected.

<QuestionnaireNext class="data-[status=unanswered]:opacity-50" disabled={unanswered}>Next</QuestionnaireNext>

Custom Progress

QuestionnaireProgress renders Question {current} of {total} by default. Use the render snippet to change its element or format, and pass a localized aria-label when needed.

Checkpoint 1 of 4
How large is the change?
<QuestionnaireProgress aria-label="Setup progress">
  {#snippet render(props, state)}
    <output {...props}>Checkpoint {state.current} of {state.total}</output>
  {/snippet}
</QuestionnaireProgress>

Animated Items

Animate the active item while keeping progress and navigation stationary. Inactive items hide immediately and stay mounted, so a transition: directive never runs. Animate the entry from onItemChange instead, here with a Tween that skips the motion when the user prefers reduced motion.

Question 1 of 3
What should the agent do?

Choose the task for this run.

Card

Compose Questionnaire with Card slots while keeping the question title and description semantic. Render QuestionnaireTitle and QuestionnaireDescription through CardTitle and CardDescription, give the title an id, and connect it with the item's aria-labelledby since it replaces the default legend.

What should the agent work on?
Choose the task that should be handled next.
Question 1 of 2
<QuestionnaireItem aria-labelledby={titleId} name="task" required>
  <CardHeader>
    <QuestionnaireTitle id={titleId}>
      {#snippet render(props, _state, children)}
        <CardTitle {...props}>{@render children?.()}</CardTitle>
      {/snippet}
      What should the agent work on?
    </QuestionnaireTitle>
    …
  </CardHeader>
</QuestionnaireItem>

Dialog

Compose Questionnaire inside a Dialog while keeping cancellation and dismissal host-owned. Closing the dialog cancels the flow, while QuestionnaireSkip records an intentional unanswered item.

Custom render targets

Use the render snippet to replace a part's default element. It receives the element props, the render state and the part's children.

<QuestionnaireNext>
  {#snippet render(props, state, children)}
    <MyButton {...props}>{@render children?.()}</MyButton>
  {/snippet}
  Next
</QuestionnaireNext>

Questionnaire and QuestionnaireItem always render a form and a fieldset. If QuestionnaireTitle no longer renders a legend, give it an id and pass that ID to the item with aria-labelledby.

Native forms

Questionnaire always renders a native form and supports onsubmit, onreset, action, and the other form props. Do not nest a Questionnaire inside another form. Answers serialize through native controls: FormData.get(itemName) reads a single answer and FormData.getAll(itemName) reads multiple answers.

novalidate is true by default so validation uses QuestionnaireError instead of the browser's constraint validation UI. form.reset() restores the initial item, default answers, skip state, and validation state.

Keyboard

Questionnaire builds on native radio, checkbox, input, and button behavior.

KeyBehavior
TabMoves focus between answer controls and visible actions.
Shift + TabMoves focus to the previous control or action.
ArrowUpMoves to the previous answer from the item, a fixed answer, or an empty text-like freeform input. Native radios also select it.
ArrowDownMoves to the next answer from the item, a fixed answer, or an empty text-like freeform input. Native radios also select it.
ArrowLeftMoves to the previous item when focus is outside a radio or text entry control.
ArrowRightMoves to the next item when the active item is answered or skipped and focus is outside a radio or text entry control.
SpaceSelects a radio, toggles a checkbox, or activates a focused action.
EnterContinues from a selected choice or selected, filled input; activates a focused action.
Command/Ctrl + EnterValidates and continues from anywhere inside the questionnaire, or submits the final item.

Shortcuts and arrow navigation pause while you type in a text field. Calling preventDefault() in the root onkeydown handler turns off questionnaire key handling.

Navigation actions stay enabled by default so an attempted action can reveal validation feedback. See Navigation State to disable or restyle an unanswered action.

Accessibility

QuestionnaireItem renders a fieldset, and QuestionnaireTitle renders its legend. Descriptions and active errors are associated with the current item, and invalid items and answer controls expose aria-invalid.

  • QuestionnaireTitle renders the fieldset legend by default. If it uses a custom render target, connect its id to the item with aria-labelledby.
  • QuestionnaireDescription and the active QuestionnaireError are connected with aria-describedby.
  • Fixed choices use native radios and checkboxes.
  • Progress is exposed as a named progressbar with current, minimum, maximum, and text values.
  • Navigation uses real buttons. Assigned shortcut keys and available navigation are exposed with aria-keyshortcuts.
  • Inactive items and actions are hidden and inert.
  • Successful navigation focuses the newly active item; failed validation focuses an available answer control.
  • Disabled items are omitted from progress and navigation.

Always give QuestionnaireInput an accessible name with a visible label, aria-label, or aria-labelledby. A placeholder is not a label. Use an explicit id with a visible label:

<Label for="other-answer">Other answer</Label>
<QuestionnaireInput id="other-answer" placeholder="Type another answer…" />

When the design has no visible label, use aria-label:

<QuestionnaireInput aria-label="Other answer" placeholder="Type another answer…" />

Examples

Standalone
Question 1 of 3
What should we prototype next?

Choose one direction or write another answer.

Card
What should we prototype next?
Choose one direction or write another answer.
Question 1 of 3
Dialog
No description
Question 1 of 1
What should the agent do next?
Disabled
Choose a plan

Enterprise is not available on your account.

API Reference

Every part accepts the native attributes of its element and a class. Navigation parts also accept the Button size and variant props. QuestionnaireActions is a styled-only layout div, and QuestionnaireChoiceDescription is a styled span for text under a choice label.

The styled QuestionnaireChoice composes the native input, the visual indicator, the label and the shortcut hint for you. Every part also takes a render snippet. It receives (props, state), or (props, state, children) for a part with children, and the render states are listed per part below.

Data attributes

Use these attributes for styling. Boolean attributes are present when true and absent when false.

Questionnaire and QuestionnaireProgress

Data attributeValue
data-currentOne-based active item position.
data-totalNumber of enabled items.
data-firstPresent on the first item.
data-lastPresent on the last item.
data-shortcuts"letters" | "numbers" on Questionnaire when enabled.

QuestionnaireItem

Data attributeValue
data-activePresent when active.
data-status"unanswered" | "answered" | "skipped"
data-requiredPresent when required.
data-multiplePresent when multiple.
data-disabledPresent when disabled.
data-invalidPresent after failed validation.

QuestionnaireChoice

Data attributeValue
data-type"radio" | "checkbox"
data-checkedPresent when selected.
data-uncheckedPresent when not selected.
data-disabledPresent when disabled.
data-invalidPresent when its item is invalid.
data-shortcutAssigned letter or number.

QuestionnaireChoices

Data attributeValue
data-shortcuts"letters" | "numbers" when enabled.

QuestionnaireInput

Data attributeValue
data-filledPresent when the input has non-empty text.
data-emptyPresent when the input has no answer text.
data-disabledPresent when disabled.
data-invalidPresent when its item is invalid.

QuestionnaireError

Data attributeValue
data-invalidPresent while the error is active.

QuestionnairePrevious, QuestionnaireSkip, QuestionnaireNext and QuestionnaireSubmit

Data attributeValue
data-visiblePresent when the action applies to the active item.
data-hiddenPresent when the action does not apply.
data-disabledPresent when disabled.
data-shortcut"Enter" on an enabled, visible Next or Submit.
data-statusActive item: "unanswered", "answered", or "skipped".

QuestionnaireTitle and QuestionnaireDescription have no state attributes.

Questionnaire

The form and coordination root.

PropTypeDefaultDescription
itemstringBindable. The active item name.
defaultItemstringfirst enabled itemInitially active item name.
itemsreadonly QuestionnaireItemDefinition[]Optional item data for server rendering and item order.
onItemChange(item: string) => voidCalled when navigation requests a different item.
shortcuts'letters' \| 'numbers'Assigns scoped answer shortcuts in definition or rendered DOM order.
novalidatebooleantrueDisables native constraint UI, while preserving questionnaire validation.
onsubmit(event: SubmitEvent) => voidNative form submission handler after every item validates.
onreset(event: Event) => voidNative reset handler. Prevent default to stop questionnaire reset.

Questionnaire exposes current, total, first, last, and shortcuts through data attributes. The optional collection types are exported with the component:

type QuestionnaireChoiceDefinition = {
  value: string;
  disabled?: boolean;
};

type QuestionnaireItemDefinition = {
  name: string;
  required?: boolean;
  disabled?: boolean;
  choices?: readonly QuestionnaireChoiceDefinition[];
};

QuestionnaireProgress

Position within the enabled item collection.

PropTypeDefaultDescription
childrenSnippetQuestion {current} of {total}Custom progress content.
aria-labelstringQuestionnaire progressAccessible progressbar name.
renderSnippet<[props, state, children]><div>Custom render target with progress state.

The render state contains current, total, first, and last.

QuestionnaireItem

One questionnaire step. Item names must be unique within a Questionnaire. The name belongs to the answer controls, not the fieldset.

PropTypeDefaultDescription
namestringrequiredUnique item identifier and native form field name.
invalidbooleanfalseMarks the item invalid from an external validator.
requiredbooleanfalseRequires an answer and prevents Skip.
multiplebooleanfalseRenders fixed choices as checkboxes.
disabledbooleanfalseOmits the item from progress and navigation.
onStatusChange(status: QuestionnaireItemStatus) => voidObserves "unanswered", "answered", and "skipped" changes.

The render state contains active, disabled, invalid, multiple, required, and status.

QuestionnaireTitle

The item title. Renders a semantic legend by default.

PropTypeDefaultDescription
renderSnippet<[props, state, children]><legend>Custom render target. Pair its id with the item's aria-labelledby.

QuestionnaireDescription

Supporting text connected to the item with aria-describedby.

PropTypeDefaultDescription
renderSnippet<[props, state, children]><p>Custom render target.

QuestionnaireChoices

A layout container for fixed and freeform answers.

PropTypeDefaultDescription
renderSnippet<[props, state, children]><div>Custom render target.

The render state contains shortcuts.

QuestionnaireChoice

A fixed answer. The default <label> keeps the whole row associated with its native control.

PropTypeDefaultDescription
valuestringrequiredNative answer value.
checkedbooleanBindable. The checked state.
defaultCheckedbooleanfalseInitial checked state.
disabledbooleanfalseDisables the native answer control.
onchange(event: Event) => voidNative input change handler.
renderSnippet<[props, state, children]><label>Custom render target with choice state.

The render state contains checked, disabled, invalid, shortcut, and type.

QuestionnaireInput

A freeform answer. Its native name is managed by the containing item.

PropTypeDefaultDescription
valuestringBindable. The input value.
defaultValuestringInitial input value.
disabledbooleanfalseDisables the input.
oninput(event: Event) => voidNative input handler.
onchange(event: Event) => voidNative change handler.
typeQuestionnaireInputType'text'A text-entry type such as 'email', 'number', or 'date'.
renderSnippet<[props, state]><input>Custom render target with input state.

The render state contains disabled, filled, and invalid.

QuestionnaireError

The validation message. It is hidden until its item fails validation.

PropTypeDefaultDescription
childrenSnippetcontextual messageCustom validation message.
renderSnippet<[props, state, children]><p>Custom render target with invalid state.

QuestionnairePrevious, QuestionnaireSkip, QuestionnaireNext, and QuestionnaireSubmit render native buttons and stay mounted while their visibility changes.

PartDefault typeVisible whenDisabled when
QuestionnairePreviousbuttonNot on the first item.You set disabled.
QuestionnaireSkipbuttonActive item is optional.You set disabled.
QuestionnaireNextbuttonNot on the last item.You set disabled.
QuestionnaireSubmitsubmitOn the last item.You set disabled.

Each accepts the native button attributes and:

PropTypeDefaultDescription
sizeButtonProps['size']'default'Button size.
variantButtonProps['variant']'outline' for Previous and Skip, 'default' for Next and SubmitButton variant.
childrenSnippetThe part's nameButton label.
renderSnippet<[props, state, children]><button>Custom render target with navigation state.

The render state contains visible, disabled, shortcut, and the active item's status.