Questionnaire

A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions. Each question slides out before the next one slides in, while the height glides to the new one's.

Question 1 of 3
What should the agent build next?

Choose a direction or describe another task.

"use client"

import * as React from "react"
import { Questionnaire as QuestionnairePrimitive } from "@animate-ui/react/primitives/questionnaire"
import { cn } from "cn"

import { Toaster, createToastManager } from "@/demos/base-ui/toast"
import { buttonVariants, type Button } from "@/components/ui/button"
import { Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"

function Questionnaire({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Root>) {
  return (
    <QuestionnairePrimitive.Root
      data-slot="questionnaire"
      className={cn("gap-4 flex w-full min-w-0 flex-col", className)}
      {...props}
    />
  )
}

function QuestionnaireProgress({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
  return (
    <QuestionnairePrimitive.Progress
      data-slot="questionnaire-progress"
      className={cn(
        "text-xs min-h-[1lh] w-fit min-w-[14ch] font-medium text-muted-foreground tabular-nums",
        className
      )}
      {...props}
    />
  )
}

function QuestionnaireItem({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
  return (
    <QuestionnairePrimitive.Item
      data-slot="questionnaire-item"
      className={cn(
        "flex flex-col gap-4 min-w-0 border-0 p-0 outline-none",
        className
      )}
      {...props}
    />
  )
}

function QuestionnaireTitle({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
  return (
    <QuestionnairePrimitive.Title
      data-slot="questionnaire-title"
      className={cn(
        "text-base leading-snug font-medium [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4 text-pretty",
        className
      )}
      {...props}
    />
  )
}

function QuestionnaireDescription({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
  return (
    <QuestionnairePrimitive.Description
      data-slot="questionnaire-description"
      className={cn(
        "text-muted-foreground text-sm text-pretty text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function QuestionnaireChoices({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
  return (
    <QuestionnairePrimitive.Choices
      data-slot="questionnaire-choices"
      className={cn(
        "gap-2 group/questionnaire-choices grid min-w-0",
        className
      )}
      {...props}
    />
  )
}

function QuestionnaireChoice({
  children,
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
  return (
    <QuestionnairePrimitive.Choice
      data-slot="questionnaire-choice"
      className={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",
        className
      )}
      {...props}
    >
      <QuestionnairePrimitive.ChoiceInput
        data-slot="questionnaire-choice-input"
        className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
      />
      <span
        aria-hidden="true"
        data-slot="questionnaire-choice-indicator"
        className="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"
          className="bg-primary-foreground size-2 hidden rounded-full group-data-[type=checkbox]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
        />
        <HugeiconsIcon icon={Tick02Icon} strokeWidth={2}
          data-slot="questionnaire-choice-indicator-check"
          className="size-3.5 hidden group-data-[type=radio]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
        />
      </span>
      <QuestionnairePrimitive.ChoiceLabel
        data-slot="questionnaire-choice-label"
        className="gap-0.5 flex min-w-0 flex-1 flex-col leading-snug"
      >
        {children}
      </QuestionnairePrimitive.ChoiceLabel>
      <QuestionnairePrimitive.ChoiceShortcut
        data-slot="questionnaire-choice-shortcut"
        className="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"
      />
    </QuestionnairePrimitive.Choice>
  )
}

function QuestionnaireChoiceDescription({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="questionnaire-choice-description"
      className={cn("text-muted-foreground", className)}
      {...props}
    />
  )
}

function QuestionnaireInput({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
  return (
    <div
      data-slot="questionnaire-input-wrapper"
      className="w-full group/questionnaire-input relative min-w-0"
    >
      <QuestionnairePrimitive.Input
        data-slot="questionnaire-input"
        className={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",
          className
        )}
        {...props}
      />
    </div>
  )
}

function QuestionnaireError({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
  return (
    <QuestionnairePrimitive.Error
      data-slot="questionnaire-error"
      className={cn("mt-2 text-sm text-destructive", className)}
      {...props}
    />
  )
}

function QuestionnaireActions({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="questionnaire-actions"
      className={cn(
        "gap-2 sm:min-h-8 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center",
        className
      )}
      {...props}
    />
  )
}

function QuestionnairePrevious({
  children,
  className,
  size = "default",
  variant = "outline",
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> &
  Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
  return (
    <QuestionnairePrimitive.Previous
      data-slot="questionnaire-previous"
      data-size={size}
      data-variant={variant}
      className={cn(
        buttonVariants({ size, variant }),
        "col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0",
        className
      )}
      {...props}
    >
      {children ?? "Previous"}
    </QuestionnairePrimitive.Previous>
  )
}

function QuestionnaireSkip({
  children,
  className,
  size = "default",
  variant = "outline",
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> &
  Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
  return (
    <QuestionnairePrimitive.Skip
      data-slot="questionnaire-skip"
      data-size={size}
      data-variant={variant}
      className={cn(
        buttonVariants({ size, variant }),
        "col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0",
        className
      )}
      {...props}
    >
      {children ?? "Skip"}
    </QuestionnairePrimitive.Skip>
  )
}

function QuestionnaireNext({
  children,
  className,
  size = "default",
  variant = "default",
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> &
  Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
  return (
    <QuestionnairePrimitive.Next
      data-slot="questionnaire-next"
      data-size={size}
      data-variant={variant}
      className={cn(
        buttonVariants({ size, variant }),
        "col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
        className
      )}
      {...props}
    >
      {children ?? "Next"}
    </QuestionnairePrimitive.Next>
  )
}

function QuestionnaireSubmit({
  children,
  className,
  size = "default",
  variant = "default",
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> &
  Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
  return (
    <QuestionnairePrimitive.Submit
      data-slot="questionnaire-submit"
      data-size={size}
      data-variant={variant}
      className={cn(
        buttonVariants({ size, variant }),
        "col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
        className
      )}
      {...props}
    >
      {children ?? "Submit"}
    </QuestionnairePrimitive.Submit>
  )
}

export {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoiceDescription,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
}

const items = [
  {
    choices: [
      { value: "tool-calls" },
      { value: "approvals" },
      { value: "handoffs" },
    ],
    name: "direction",
    required: true,
  },
  {
    choices: [
      { value: "progress" },
      { value: "decisions" },
      { value: "risks" },
      { value: "next-step" },
    ],
    name: "signals",
  },
  {
    choices: [{ value: "now" }, { value: "next-cycle" }, { value: "backlog" }],
    name: "timing",
    required: true,
  },
] as const

const toast = createToastManager()

export default function QuestionnaireDemo() {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()

    const formData = new FormData(event.currentTarget)
    const answers = {
      direction: formData.get("direction"),
      signals: formData.getAll("signals"),
      timing: formData.get("timing"),
    }

    toast.add({
      title: "Agent plan saved",
      description: `Direction: ${answers.direction ?? "None"} · Progress signals: ${answers.signals.join(", ") || "None"} · Timing: ${answers.timing ?? "None"}`,
    })
  }

  return (
    <Toaster toastManager={toast}>
      <Questionnaire
        className="mx-auto max-w-md"
        defaultItem="direction"
        items={items}
        shortcuts="letters"
        onSubmit={handleSubmit}
      >
        <QuestionnaireProgress />

        <QuestionnaireItem name="direction" required>
          <QuestionnaireTitle>
            What should the agent build next?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            Choose a direction or describe another task.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="tool-calls">
              <span className="font-medium">Tool call timeline</span>
              <span className="text-muted-foreground">
                Show what the agent ran and what came back.
              </span>
            </QuestionnaireChoice>
            <QuestionnaireChoice value="approvals">
              <span className="font-medium">Approval checkpoints</span>
              <span className="text-muted-foreground">
                Ask before sensitive or destructive actions.
              </span>
            </QuestionnaireChoice>
            <QuestionnaireChoice value="handoffs">
              <span className="font-medium">Sub-agent handoffs</span>
              <span className="text-muted-foreground">
                Make delegated work and results easier to follow.
              </span>
            </QuestionnaireChoice>
            <QuestionnaireInput
              aria-label="Another agent feature"
              placeholder="Describe another feature…"
            />
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>

        <QuestionnaireItem name="signals" multiple>
          <QuestionnaireTitle>
            What should every progress update include?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            Select all that apply, or skip this question.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="progress">Progress</QuestionnaireChoice>
            <QuestionnaireChoice value="decisions">Decisions</QuestionnaireChoice>
            <QuestionnaireChoice value="risks">Risks</QuestionnaireChoice>
            <QuestionnaireChoice value="next-step">Next step</QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>

        <QuestionnaireItem name="timing" required>
          <QuestionnaireTitle>When should work begin?</QuestionnaireTitle>
          <QuestionnaireDescription>
            Choose when the agent should begin the work.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="now">Start now</QuestionnaireChoice>
            <QuestionnaireChoice value="next-cycle">
              Next development cycle
            </QuestionnaireChoice>
            <QuestionnaireChoice value="backlog">
              Add it to the backlog
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>

        <QuestionnaireActions>
          <QuestionnairePrevious />
          <QuestionnaireSkip />
          <QuestionnaireNext>Next</QuestionnaireNext>
          <QuestionnaireSubmit>Save plan</QuestionnaireSubmit>
        </QuestionnaireActions>
      </Questionnaire>
    </Toaster>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add questionnaire
pnpm dlx shadcn@latest add questionnaire
yarn dlx shadcn@latest add questionnaire
bun x shadcn@latest add questionnaire

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate questionnaire
pnpm dlx @animate-ui/cli@latest animate questionnaire
yarn dlx @animate-ui/cli@latest animate questionnaire
bun x @animate-ui/cli@latest animate questionnaire

shadcn/ui's Questionnaire (with every base) isn't built on React Aria but on @shadcn/react, its headless questionnaire. Animate UI's questionnaire exports exactly the same API (Questionnaire with its 16 parts, and the 5 types) and adds the animation. animate-ui animate questionnaire swaps the @shadcn/react/questionnaire import for it. It is built on @shadcn/react, so keep that dependency (shadcn add questionnaire installs it).

Usage

Import from Animate UI instead of @shadcn/react. Everything else stays the same.

import { Questionnaire } from '@animate-ui/react/primitives/questionnaire';

shadcn/ui's component imports it as QuestionnairePrimitive. Then use it as usual:

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

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;

export function PrototypeQuestionnaire() {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const answers = new FormData(event.currentTarget);
    // answers.get("direction"), answers.getAll(...) for multiple items.
  }

  return (
    <Questionnaire items={items} onSubmit={handleSubmit}>
      <QuestionnaireProgress />
      {items.map((question) => (
        <QuestionnaireItem
          key={question.name}
          name={question.name}
          required={question.required}
        >
          <QuestionnaireTitle>{question.prompt}</QuestionnaireTitle>
          <QuestionnaireDescription>
            {question.description}
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            {question.choices.map((choice) => (
              <QuestionnaireChoice key={choice.value} value={choice.value}>
                <span className="font-medium">{choice.label}</span>
                {'description' in choice ? (
                  <span className="text-muted-foreground">
                    {choice.description}
                  </span>
                ) : null}
              </QuestionnaireChoice>
            ))}
            {'input' in question ? (
              <QuestionnaireInput
                aria-label={question.input.label}
                placeholder={question.input.placeholder}
              />
            ) : null}
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
      ))}
      <QuestionnaireActions>
        <QuestionnairePrevious />
        <QuestionnaireSkip />
        <QuestionnaireNext />
        <QuestionnaireSubmit />
      </QuestionnaireActions>
    </Questionnaire>
  );
}

shadcn/ui's Animated Items example animates the active question with tw-animate-css classes on QuestionnaireItem (data-active:animate-in, data-active:fade-in-0, data-active:slide-in-from-bottom-2, data-active:duration-300). Leave them out: they would run on top of the step's entrance.

The same drop-in animates the questionnaire of the Radix UI and Base UI bases: shadcn/ui's component is the same on all three, but for its Button import.

Animation

  • The questions change one after the other, sliding by their order, like the tab panels: going on (Next, Skip, Enter, Ctrl/⌘+Enter, the right arrow outside a text field or a radio), the leaving question slides 24px toward the start as it fades out into a 5px blur, over 150ms on an ease-out (cubic-bezier(0, 0, 0.58, 1)); once it's gone, the next one comes in from 24px toward the end, out of the blur, over 300ms (cubic-bezier(0.25, 0.1, 0.35, 1)). Going back (Previous, the left arrow, a reset), the reverse; right to left, mirrored. A jump over several questions (a reset, an invalid answer found on submit, a controlled item) goes straight there: the questions in between never show.
  • The height glides from the leaving question's to the next one's once the leaving one has gone, as the next one comes in, over 450ms on Motion's layout curve (cubic-bezier(0.4, 0, 0.1, 1): a gentle start, a long deceleration, never past its size), so the actions below move on that curve instead of jumping. Until then, it's held at the leaving one's height, so what's below doesn't slide over the question leaving (an error changing meanwhile takes the height along at once: below). If the question's content changes size on the way (content loaded late or added), the height follows it from where it is and at the speed it has, on a spring that never goes past (stiffness 660, damping 52). An error showing or folding on the way takes the question's height along on its own curve (below).
  • The leaving question stays where it was, over the next one: @shadcn/react hides it in the commit that changes the step, and Animate UI shows it again out of the flow until it has gone. It stays inert. The next one is never hidden on the way: the library focuses it in that commit, and the focus stays on it.
  • Changed again on the way (Next, then Previous at once): each question goes on from where it is. One coming in that leaves again fades out from its opacity, in that share of the 150ms; one picked again while it leaves comes back from where it is, without waiting; the height goes on from where it is. Two steps in a row (Next, then Skip at once): the next question also waits for the one still leaving from the step before.
  • Progress: the numbers of its default label ("Question 2 of 3") roll to the new ones: the old number slides 0.6em out (up when it grows, down when it shrinks) as the new one comes in from the other side, each fading through a 2px blur, on a spring that never goes past (stiffness 300, damping 35: about 750ms, 90% of the way in about 225ms). Changed again on the way, the number coming in leaves from where it is. A Progress inside each question (shadcn/ui's card and dialog examples) doesn't roll: the next question shows its own number, and the leaving one keeps its own until it has gone. Your own children or render stay as they are.
  • A choice pressed scales to 0.985 on Animate UI's press spring (stiffness 550, damping 30): like the buttons, a touch lighter (it's a whole row), and back up when let go: press only, nothing scales on hover, and nothing under reduced motion. A Choice with your own render stays as you render it.
  • A choice checked: shadcn/ui's indicator animates like Animate UI's radios and checkboxes. A single choice's dot pops in from 0 on a bouncy spring (stiffness 200, damping 16) and leaves the same way; a multiple choice's check draws itself in 0.3s after 0.2s (cubic-bezier(0, 0, 0.58, 1), once the box has answered) and erases itself in 0.3s. With checkAnimation="pop" on an AnimateUIProvider, the check pops in from half its size out of a 2px blur (a spring, stiffness 500, damping 24, after 0.2s) and shrinks back out in 0.15s. shadcn/ui hides the dot and the check as soon as the choice is unchecked: they're held shown while they leave. The indicator's background, border and text colors change over 200ms (ease) instead of at once. Letter or number shortcuts and Space check choices like a click, and so do the arrow keys on a single choice's radios; on a multiple choice's boxes, the arrow keys only move the focus.
  • The error unfolds: its height grows from 0, and the gap above it from none (its top margin from minus the question's gap), as it fades in out of a 2px blur, over 350ms on a soft ease-out (cubic-bezier(0.215, 0.61, 0.355, 1)), so what's below it moves instead of jumping. It folds back the same way once the question is answered (or skipped), held shown until it has. Changed again on the way (an answer picked as it unfolds), it goes on from its height, gap, opacity and blur. If its question's height is still gliding (a step just changed, Submit jumped back to an invalid question, or the error changed just before), the question goes on from its height to the one it'll have with the error open (or folded), from then on, on that same 350ms curve: the error is never clipped, then chased.
  • Previous, Skip, Next and Submit show and hide in place: from 0.9 of their own scale and a 2px blur as they fade in to their own resting look (a disabled button's opacity-50 included: it fades to 0.5, never past it), over 150ms (cubic-bezier(0.23, 1, 0.32, 1)), and back out the same way from it, held shown (still inert) until they've gone. The ones leaving stay where they were, out of the flow, so what stays doesn't move. shadcn/ui's Next and Submit share a cell of QuestionnaireActions: on the last question, they swap through each other. Changed again on the way, each goes on from where it is.
  • Nothing moves on mount, nor before the page has been painted: a resumed question (defaultItem) and default answers show at rest.
  • <AnimateUIProvider tempo> paces every transition, the height's follow spring included.
  • Under reduced motion, the questions still change one after the other, fading through their blur without sliding: out over 200ms, then in over 200ms (cubic-bezier(0.23, 1, 0.32, 1)), the height changing at once to the next one's when the leaving one has faded out. The error (what's below it jumps), the buttons (to their own resting opacity), the dot and the check fade only, and the progress numbers crossfade over 200ms.
  • The demo is shadcn/ui's questionnaire-demo for React Aria: three questions, a required one with three described choices and a freeform answer, an optional multiple choice (Skip shows), and a required last one (Submit reads "Save plan"), with letter shortcuts. Submitting shows the answers in Animate UI's Toast, where shadcn/ui's uses Sonner.

How it works

shadcn/ui's markup is untouched, and nothing is re-rendered: @shadcn/react keeps every question mounted (their answers are the form's) and hides the inactive ones (hidden, inert) in the commit that changes the step, and hides its buttons and its error the same way. Animate UI marks the parts it animates with data-animate-ui-questionnaire-part (item, choice, error, action), and the Root watches them with a MutationObserver (data-active on the questions, data-checked on the choices, data-invalid on the errors, data-hidden on the buttons). Its callback runs before the frame is painted: it animates the elements themselves with the Web Animations API, each Root only its own parts.

On a step change, the leaving question gets its hidden attribute removed and inline position: absolute, top, left, width, margin: 0, grid-area: auto and box-sizing: border-box (its own height kept), laid over the next one, on the next one's unrounded box (the questions' parent gets position: relative while something is pinned in it, unless it's positioned). Once it has gone, it's hidden again, and the question and its parent get their own inline styles back (yours included). The next one's entrance is delayed by the exit and fills backwards: it stays transparent while the leaving one goes, without ever being hidden. Its height glides as a min-height and max-height animation, with the same delay (held at the leaving one's height meanwhile), clipping its content vertically on the way; an error changing while it glides takes it over with its own min-height and max-height animation. A button leaving is pinned where it was the same way (its height too), read in the layout and with the state it was drawn in (a button turning disabled as it goes leaves from its look then), and an error or a button leaving has its hidden attribute removed until it has gone. The dot or check of a choice unchecked gets an inline display: block while it leaves. Changed again on the way, each animation starts from the element's computed style. The indicator's color transition and the progress numbers' pseudo-elements come from a small stylesheet; the color transition applies once the Root has data-animate-ui-questionnaire-ready.

Progress's numbers are data-animate-ui-questionnaire-count spans. While one rolls, it has data-animate-ui-from and data-animate-ui-to, its own text is painted transparent (-webkit-text-fill-color), and its ::before (the old number) and ::after (the new one) are animated over it; in a question leaving, data-animate-ui-hold keeps the old number painted by its ::before until the question is hidden.

API Reference

Every part takes exactly the props of @shadcn/react's Questionnaire, render included, with the same data attributes and render states; the types (QuestionnaireChoiceDefinition, QuestionnaireInputType, QuestionnaireItemDefinition, QuestionnaireItemStatus, QuestionnaireShortcutMode) are its own, re-exported. Animate UI adds no props (no transition or exitTransition): AnimateUIProvider's tempo and checkAnimation apply. It adds these attributes:

  • Root: data-animate-ui-questionnaire, always (each Root animates its own parts); data-animate-ui-questionnaire-ready once the page has been painted (changes animate from then on); and the --animate-ui-questionnaire-color-duration and --animate-ui-questionnaire-color-easing variables in its style (the indicator's color transition, paced by the tempo; your style wins).
  • Item, Choice, Error, Previous, Skip, Next and Submit: data-animate-ui-questionnaire-part (item, choice, error, action).
  • Choice: unless you pass your own render, it renders its label as a Motion element (for the press).
  • Progress: without children or render, it renders its default label through render, each number in a data-animate-ui-questionnaire-count span. Its text content stays the plain label ("Question 2 of 3"), mid-roll too: the rolling numbers are the spans' ::before and ::after pseudo-elements.

The dot and the check it animates are shadcn/ui's: the [data-slot="questionnaire-choice-indicator-dot"] of a single choice and the [data-slot="questionnaire-choice-indicator-check"] of a multiple one (data-type on the Choice), inside the Choice. A choice of your own animates too, as long as it carries those slots.

Accessibility

Everything is @shadcn/react's: each question is a fieldset with its legend, its description and its active error are tied to it with aria-describedby, choices are native radios and checkboxes, the progress is a named progressbar, the buttons are real buttons, and the library moves the focus to the next question, or to an answer when validation fails. The animation is visual only. A question or a button shown again while it leaves stays inert (no clicks, no focus, out of the accessibility tree), and a leaving button keeps its aria-hidden. The progress keeps its aria-valuenow and aria-valuetext ("Question 2 of 3"), and its text stays the label: the rolling numbers are pseudo-elements with empty alternative text (content: attr(…) / ""), so its aria-live announcement reads only "Question 2 of 3", mid-roll too. A browser without alternative text for content (Safari before 17.4, Firefox before 128) reads them as well while they roll. The error is in the page, with role="alert", from the frame it starts unfolding.

Credits

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page