# Questionnaire

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

Docs: https://hextaui.com/docs/questionnaire
Markdown: https://hextaui.com/docs/questionnaire.md

```tsx title="components/examples/questionnaire/demo.tsx"
"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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/questionnaire.json
```

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

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @shadcn/react @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/questionnaire.tsx"
"use client"

import * as React from "react"
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire"
import { IconCheck } from "@tabler/icons-react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { buttonVariants } from "@/components/ui/button"
import { inputVariants } from "@/components/ui/input"
import { Kbd } from "@/components/ui/kbd"
import { NumberFlow } from "@/components/ui/number-flow"
import { easeOut, prefersReducedMotion } from "@/lib/motion"

type QuestionnaireTransition = "slide" | "lift"

const itemSelector = "[data-slot=questionnaire-item]"

function ownItems(form: HTMLFormElement) {
  return Array.from(form.querySelectorAll<HTMLElement>(itemSelector)).filter(
    (item) => item.closest("form") === form
  )
}

function activeItem(form: HTMLFormElement) {
  return ownItems(form).find((item) => item.hasAttribute("data-active")) ?? null
}

const leavingProperties = [
  "display",
  "position",
  "top",
  "left",
  "width",
  "pointer-events",
]

function releaseLeaving(item: HTMLElement) {
  for (const property of leavingProperties) {
    item.style.removeProperty(property)
  }
  item.removeAttribute("aria-hidden")
  item.removeAttribute("data-leaving")
}

function releaseEntering(item: HTMLElement) {
  item.style.removeProperty("overflow")
  item.style.removeProperty("overflow-clip-margin")
}

function useQuestionTransition(
  formRef: React.RefObject<HTMLFormElement | null>,
  transition: QuestionnaireTransition,
  onActivate: (item: HTMLElement) => void
) {
  const onActivateRef = React.useRef(onActivate)

  React.useLayoutEffect(() => {
    onActivateRef.current = onActivate
  })

  React.useLayoutEffect(() => {
    const form = formRef.current
    if (!form || typeof MutationObserver === "undefined") {
      return
    }

    let active = activeItem(form)
    if (active) {
      onActivateRef.current(active)
    }
    const animations = new Set<Animation>()
    const leaving = new Set<HTMLElement>()
    const entering = new Set<HTMLElement>()

    const settle = () => {
      for (const animation of animations) {
        animation.cancel()
      }
      animations.clear()
      leaving.forEach(releaseLeaving)
      leaving.clear()
      entering.forEach(releaseEntering)
      entering.clear()
    }

    const track = (animation: Animation, done: () => void) => {
      animations.add(animation)
      animation.onfinish = () => {
        animations.delete(animation)
        done()
      }
    }

    const update = () => {
      const next = activeItem(form)
      if (next === active) {
        return
      }
      const previous = active
      active = next
      settle()
      if (next) {
        onActivateRef.current(next)
      }
      if (
        !previous ||
        !next ||
        !previous.isConnected ||
        prefersReducedMotion() ||
        typeof next.animate !== "function"
      ) {
        return
      }

      previous.style.setProperty("display", "flex", "important")
      previous.setAttribute("aria-hidden", "true")
      previous.setAttribute("data-leaving", "")
      const from = previous.getBoundingClientRect().height
      previous.style.setProperty("position", "absolute")
      previous.style.setProperty("pointer-events", "none")
      const to = next.getBoundingClientRect().height
      previous.style.setProperty("top", `${next.offsetTop}px`)
      previous.style.setProperty("left", `${next.offsetLeft}px`)
      previous.style.setProperty("width", `${next.offsetWidth}px`)
      leaving.add(previous)

      const forward = Boolean(
        previous.compareDocumentPosition(next) &
        Node.DOCUMENT_POSITION_FOLLOWING
      )
      const rtl = getComputedStyle(form).direction === "rtl"
      const lift = transition === "lift"
      const distance = (lift ? 14 : 28) * (forward ? 1 : -1)
      const offset = (amount: number) =>
        lift
          ? `translate3d(0, ${amount}px, 0)`
          : `translate3d(${rtl ? -amount : amount}px, 0, 0)`

      const exit = previous.animate(
        [
          { opacity: 1, transform: "none", filter: "blur(0px)" },
          {
            opacity: 0,
            transform: offset(-distance),
            filter: lift ? "blur(2px)" : "blur(0px)",
          },
        ],
        {
          duration: 180,
          easing: "cubic-bezier(0.4, 0, 1, 1)",
          fill: "forwards",
        }
      )
      track(exit, () => {
        leaving.delete(previous)
        exit.cancel()
        releaseLeaving(previous)
      })

      const enter = next.animate(
        [
          {
            opacity: 0,
            transform: offset(distance),
            filter: lift ? "blur(2px)" : "blur(0px)",
          },
          { opacity: 1, transform: "none", filter: "blur(0px)" },
        ],
        { duration: 340, delay: 50, easing: easeOut, fill: "backwards" }
      )
      track(enter, () => undefined)

      if (Math.abs(from - to) >= 1) {
        next.style.setProperty("overflow", "clip")
        next.style.setProperty("overflow-clip-margin", "8px")
        entering.add(next)
        const grow = next.animate(
          [{ height: `${from}px` }, { height: `${to}px` }],
          { duration: 340, easing: easeOut }
        )
        track(grow, () => {
          entering.delete(next)
          releaseEntering(next)
        })
      }
    }

    const observer = new MutationObserver(update)
    observer.observe(form, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: ["data-active"],
    })
    return () => {
      observer.disconnect()
      settle()
    }
  }, [formRef, transition])
}

function restartAttribute(element: Element, name: string) {
  element.removeAttribute(name)
  void (element as HTMLElement).offsetWidth
  element.setAttribute(name, "")
}

type QuestionnaireProps = React.ComponentProps<
  typeof QuestionnairePrimitive.Root
> & {
  transition?: QuestionnaireTransition
  autoAdvance?: boolean
}

function Questionnaire({
  className,
  transition = "slide",
  autoAdvance = false,
  onKeyDown,
  onKeyDownCapture,
  onKeyUp,
  onClick,
  onChange,
  onMouseDown,
  ref,
  ...props
}: QuestionnaireProps) {
  const formRef = React.useRef<HTMLFormElement | null>(null)
  const answeredOnArrival = React.useRef(new WeakMap<HTMLElement, boolean>())
  const advanceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)

  const cancelAdvance = () => {
    if (advanceTimer.current) {
      clearTimeout(advanceTimer.current)
      advanceTimer.current = null
    }
  }

  useQuestionTransition(formRef, transition, (item) => {
    cancelAdvance()
    answeredOnArrival.current.set(
      item,
      item.getAttribute("data-status") !== "unanswered"
    )
  })

  React.useEffect(() => cancelAdvance, [])

  const setRef = React.useCallback(
    (node: HTMLFormElement | null) => {
      formRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  const shakeIfInvalid = () => {
    requestAnimationFrame(() => {
      const form = formRef.current
      const item = form ? activeItem(form) : null
      if (item?.hasAttribute("data-invalid") && !prefersReducedMotion()) {
        restartAttribute(item, "data-shake")
      }
    })
  }

  const shortcutFor = (key: string) => {
    const form = formRef.current
    const item = form ? activeItem(form) : null
    if (!item || key.length !== 1) {
      return null
    }
    return item.querySelector<HTMLElement>(
      `kbd[data-shortcut="${CSS.escape(key.toUpperCase())}"]`
    )
  }

  const handleKeys = (event: React.KeyboardEvent<HTMLFormElement>) => {
    const form = formRef.current
    const item = form ? activeItem(form) : null
    if (!form || !item || event.defaultPrevented || event.altKey) {
      return
    }
    const target = event.target as HTMLElement
    const typing = target.matches(
      "input:not([type=radio]):not([type=checkbox]), textarea, select, [contenteditable=true]"
    )
    if (event.metaKey || event.ctrlKey) {
      if (event.key === "Enter") {
        shakeIfInvalid()
      }
      return
    }
    const take = () => {
      event.preventDefault()
      event.stopPropagation()
    }
    const answers = Array.from(
      item.querySelectorAll<HTMLElement>(
        "input[type=radio]:not(:disabled), input[type=checkbox]:not(:disabled), [data-slot=questionnaire-input]:not(:disabled)"
      )
    )
    const index = answers.indexOf(target)
    const visible = (slot: string) =>
      form.querySelector<HTMLElement>(`[data-slot=${slot}][data-visible]`)
    const advance = () => {
      const before = document.activeElement as HTMLElement | null
      ;(
        visible("questionnaire-next") ?? visible("questionnaire-submit")
      )?.click()
      requestAnimationFrame(() => {
        if (
          before &&
          before !== document.activeElement &&
          item.contains(before) &&
          activeItem(form) === item &&
          item.hasAttribute("data-invalid")
        ) {
          before.focus()
        }
      })
    }

    switch (event.key) {
      case "ArrowDown":
      case "ArrowUp": {
        if (target.tagName === "TEXTAREA" || answers.length === 0) {
          return
        }
        take()
        const step = event.key === "ArrowDown" ? 1 : -1
        const next =
          index === -1
            ? step === 1
              ? 0
              : answers.length - 1
            : Math.min(answers.length - 1, Math.max(0, index + step))
        answers[next]?.focus()
        return
      }
      case "Home":
      case "End": {
        if (typing || answers.length === 0) {
          return
        }
        take()
        answers[event.key === "Home" ? 0 : answers.length - 1]?.focus()
        return
      }
      case "ArrowLeft":
      case "ArrowRight": {
        if (typing) {
          return
        }
        take()
        const rtl = getComputedStyle(form).direction === "rtl"
        const forward = (event.key === "ArrowRight") !== rtl
        if (event.repeat) {
          return
        }
        if (forward) {
          advance()
        } else {
          visible("questionnaire-previous")?.click()
        }
        return
      }
      case "Enter": {
        if (target.closest("button, a, textarea")) {
          return
        }
        take()
        if (event.repeat) {
          return
        }
        const input = target as HTMLInputElement
        if (input.type === "radio" && !input.checked) {
          input.click()
          cancelAdvance()
          setTimeout(advance, 0)
          return
        }
        advance()
        return
      }
      case "Escape": {
        if (typing) {
          take()
          item.focus()
        }
        return
      }
    }
  }

  return (
    <QuestionnairePrimitive.Root
      ref={setRef}
      data-slot="questionnaire"
      data-transition={transition}
      className={cn(
        "group/questionnaire relative flex w-full min-w-0 flex-col gap-5",
        className
      )}
      onKeyDownCapture={(event) => {
        onKeyDownCapture?.(event)
        handleKeys(event)
      }}
      onMouseDown={(event) => {
        onMouseDown?.(event)
        const form = formRef.current
        const item = form ? activeItem(form) : null
        const target = event.target as HTMLElement
        if (
          !event.defaultPrevented &&
          event.button === 0 &&
          item &&
          !item.contains(target) &&
          !target.closest(
            "input, textarea, select, button, a, label, [contenteditable=true]"
          )
        ) {
          event.preventDefault()
          item.focus({ preventScroll: true })
        }
      }}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        const target = event.target as HTMLElement
        const typing = target.matches(
          "input:not([type=radio]):not([type=checkbox]), textarea, select"
        )
        if (!typing && !event.metaKey && !event.ctrlKey && !event.altKey) {
          shortcutFor(event.key)?.setAttribute("data-pressed", "")
        }
      }}
      onKeyUp={(event) => {
        onKeyUp?.(event)
        shortcutFor(event.key)?.removeAttribute("data-pressed")
      }}
      onClick={(event) => {
        onClick?.(event)
        if (
          (event.target as Element).closest(
            "[data-slot=questionnaire-next], [data-slot=questionnaire-submit]"
          )
        ) {
          cancelAdvance()
          shakeIfInvalid()
        }
      }}
      onChange={(event) => {
        onChange?.(event)
        cancelAdvance()
        const input = event.target as unknown as HTMLInputElement
        const form = formRef.current
        const item = input.closest<HTMLElement>(itemSelector)
        if (
          !autoAdvance ||
          !form ||
          input.type !== "radio" ||
          !input.checked ||
          !item ||
          item !== activeItem(form) ||
          answeredOnArrival.current.get(item)
        ) {
          return
        }
        advanceTimer.current = setTimeout(() => {
          advanceTimer.current = null
          if (activeItem(form) !== item || !input.checked) {
            return
          }
          form
            .querySelector<HTMLElement>(
              "[data-slot=questionnaire-next][data-visible]"
            )
            ?.click()
        }, 350)
      }}
      {...props}
    />
  )
}

type Segment = { status: string; active: boolean; invalid: boolean }

function readSegments(form: HTMLFormElement): Segment[] {
  return ownItems(form)
    .filter((item) => !item.hasAttribute("disabled"))
    .map((item) => ({
      status: item.getAttribute("data-status") ?? "unanswered",
      active: item.hasAttribute("data-active"),
      invalid: item.hasAttribute("data-invalid"),
    }))
}

function useSegments(ref: React.RefObject<HTMLElement | null>) {
  const [segments, setSegments] = React.useState<Segment[] | null>(null)

  React.useEffect(() => {
    const form = ref.current?.closest("form")
    if (!form || typeof MutationObserver === "undefined") {
      return
    }
    let last = ""
    const read = () => {
      const next = readSegments(form)
      const key = JSON.stringify(next)
      if (key !== last) {
        last = key
        setSegments(next)
      }
    }
    read()
    const observer = new MutationObserver(read)
    observer.observe(form, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [
        "data-status",
        "data-active",
        "data-invalid",
        "disabled",
      ],
    })
    return () => observer.disconnect()
  }, [ref])

  return segments
}

type QuestionnaireProgressProps = React.ComponentProps<
  typeof QuestionnairePrimitive.Progress
> & {
  variant?: "segments" | "bar"
}

function QuestionnaireProgress({
  className,
  variant = "segments",
  ref,
  ...props
}: QuestionnaireProgressProps) {
  const ownRef = React.useRef<HTMLDivElement | null>(null)
  const segments = useSegments(ownRef)

  const setRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      ownRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  return (
    <QuestionnairePrimitive.Progress
      ref={setRef}
      data-slot="questionnaire-progress"
      data-variant={variant}
      className={cn("flex w-full min-w-0 items-center gap-3", className)}
      render={(renderProps, state) => {
        const list =
          segments && segments.length === state.total
            ? segments
            : Array.from({ length: state.total }, (_, index) => ({
                status: index < state.current - 1 ? "answered" : "unanswered",
                active: index === state.current - 1,
                invalid: false,
              }))
        const done = list.filter((item) => item.status !== "unanswered").length
        return (
          <div {...renderProps}>
            {variant === "segments" ? (
              <span
                aria-hidden="true"
                data-slot="questionnaire-progress-track"
                className="flex h-1 min-w-0 flex-1 gap-1"
              >
                {list.map((segment, index) => (
                  <span
                    key={index}
                    data-slot="questionnaire-progress-segment"
                    data-status={segment.status}
                    data-active={segment.active ? "" : undefined}
                    data-invalid={segment.invalid ? "" : undefined}
                    className="relative min-w-0 flex-1 overflow-hidden rounded-full bg-muted transition-colors duration-300 data-invalid:bg-destructive/30 motion-reduce:transition-none data-active:bg-primary/20"
                  >
                    <span className="absolute inset-0 origin-left scale-x-0 rounded-full bg-primary transition-[scale,background-color] duration-500 ease-out-quint in-data-[status=answered]:scale-x-100 in-data-[status=skipped]:scale-x-100 in-data-[status=skipped]:bg-muted-foreground/30 motion-reduce:transition-none rtl:origin-right" />
                  </span>
                ))}
              </span>
            ) : (
              <span
                aria-hidden="true"
                data-slot="questionnaire-progress-track"
                className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted"
              >
                <span
                  style={
                    {
                      "--questionnaire-progress":
                        state.total > 0 ? done / state.total : 0,
                    } as React.CSSProperties
                  }
                  className="absolute inset-0 origin-left scale-x-(--questionnaire-progress) rounded-full bg-primary transition-[scale] duration-500 ease-out-quint motion-reduce:transition-none rtl:origin-right"
                />
              </span>
            )}
            <span
              aria-hidden="true"
              data-slot="questionnaire-progress-label"
              className="shrink-0 text-xs font-medium text-muted-foreground tabular-nums"
            >
              <span dir="ltr" className="inline-flex gap-1">
                <NumberFlow value={state.current} />
                <span className="text-muted-foreground/60">/</span>
                {state.total}
              </span>
            </span>
            <span className="sr-only">
              {renderProps.children as React.ReactNode}
            </span>
          </div>
        )
      }}
      {...props}
    />
  )
}

function QuestionnaireItem({
  className,
  onAnimationEnd,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
  return (
    <QuestionnairePrimitive.Item
      data-slot="questionnaire-item"
      className={cn(
        "flex min-w-0 flex-col gap-4 border-0 p-0 outline-none focus-visible:outline-hidden data-shake:motion-safe:animate-button-shake",
        className
      )}
      onAnimationEnd={(event) => {
        onAnimationEnd?.(event)
        if (event.target === event.currentTarget) {
          event.currentTarget.removeAttribute("data-shake")
        }
      }}
      {...props}
    />
  )
}

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

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

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

function QuestionnaireChoice({
  className,
  children,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
  return (
    <QuestionnairePrimitive.Choice
      data-slot="questionnaire-choice"
      className={cn(
        "group/questionnaire-choice relative flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg bg-background px-3 py-2.5 text-start text-sm inset-ring-(length:--hairline) inset-ring-border transition-[background-color,box-shadow,scale] duration-150 ease-out-cubic select-none has-data-[slot=questionnaire-choice-description]:items-start has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-focus-ring has-[>input:focus-visible]:inset-ring-ring data-invalid:inset-ring-destructive/60 motion-safe:active:scale-[0.99] motion-reduce:transition-none dark:bg-input/20 forced-colors:border data-checked:bg-muted/70 data-checked:inset-ring-foreground/20 dark:data-checked:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50 [@media(hover:hover)]:hover:bg-muted/50",
        className
      )}
      {...props}
    >
      <QuestionnairePrimitive.ChoiceInput
        data-slot="questionnaire-choice-input"
        className="absolute inset-0 z-1 size-full cursor-pointer appearance-none rounded-[inherit] opacity-0 outline-none focus-visible:outline-hidden"
      />
      <span
        aria-hidden="true"
        data-slot="questionnaire-choice-indicator"
        className="relative flex size-4 shrink-0 items-center justify-center rounded-full bg-background inset-ring-(length:--hairline) inset-ring-input transition-[background-color,box-shadow] duration-150 group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:mt-0.5 group-data-invalid/questionnaire-choice:inset-ring-destructive group-data-[type=checkbox]/questionnaire-choice:rounded-[4px] group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:inset-ring-primary dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary forced-colors:border"
      >
        <span className="size-1.5 scale-0 rounded-full bg-current transition-[scale] duration-300 ease-spring group-data-[type=checkbox]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:scale-100 motion-reduce:transition-none" />
        <IconCheck
          stroke={3}
          className="hidden size-3 opacity-0 group-data-[type=checkbox]/questionnaire-choice:block group-data-checked/questionnaire-choice:opacity-100 motion-safe:group-data-checked/questionnaire-choice:animate-checkbox-draw motion-safe:group-data-checked/questionnaire-choice:[stroke-dasharray:22] motion-safe:group-data-checked/questionnaire-choice:[stroke-dashoffset:22]"
        />
      </span>
      <QuestionnairePrimitive.ChoiceLabel
        data-slot="questionnaire-choice-label"
        className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug wrap-break-word"
      >
        {children}
      </QuestionnairePrimitive.ChoiceLabel>
      <QuestionnairePrimitive.ChoiceShortcut
        data-slot="questionnaire-choice-shortcut"
        render={<Kbd />}
        className="opacity-50 transition-opacity duration-200 group-focus-within/questionnaire:opacity-100 group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:mt-px pointer-coarse:hidden"
      />
    </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 (
    <QuestionnairePrimitive.Input
      data-slot="questionnaire-input"
      className={cn(
        inputVariants({ size: "default" }),
        "h-11 sm:h-10",
        className
      )}
      {...props}
    />
  )
}

function QuestionnaireError({
  className,
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
  return (
    <QuestionnairePrimitive.Error
      data-slot="questionnaire-error"
      className={cn(
        "-mt-1 text-sm text-destructive motion-safe:animate-in motion-safe:animation-duration-200 motion-safe:fade-in-0 motion-safe:slide-in-from-top-1",
        className
      )}
      {...props}
    />
  )
}

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

type NavigationProps = Pick<
  VariantProps<typeof buttonVariants>,
  "size" | "variant"
>

const navigationClassName = "pointer-coarse:h-11"

function QuestionnairePrevious({
  children,
  className,
  size = "default",
  variant = "ghost",
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> &
  NavigationProps) {
  return (
    <QuestionnairePrimitive.Previous
      data-slot="questionnaire-previous"
      className={cn(
        buttonVariants({ size, variant }),
        navigationClassName,
        "col-start-1 row-start-1 justify-self-start",
        className
      )}
      {...props}
    >
      {children ?? "Previous"}
    </QuestionnairePrimitive.Previous>
  )
}

function QuestionnaireSkip({
  children,
  className,
  size = "default",
  variant = "ghost",
  ...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> & NavigationProps) {
  return (
    <QuestionnairePrimitive.Skip
      data-slot="questionnaire-skip"
      className={cn(
        buttonVariants({ size, variant }),
        navigationClassName,
        "col-start-2 row-start-1 text-muted-foreground",
        className
      )}
      {...props}
    >
      {children ?? "Skip"}
    </QuestionnairePrimitive.Skip>
  )
}

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

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

export {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoiceDescription,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
}
export type {
  QuestionnaireProgressProps,
  QuestionnaireProps,
  QuestionnaireTransition,
}
```

```tsx title="components/ui/button.tsx"
"use client"

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

```tsx title="components/ui/input.tsx"
"use client"

import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { useAutosize } from "@/hooks/use-autosize"
import { useComposedRef } from "@/hooks/use-composed-ref"
import { useInvalidShake } from "@/hooks/use-invalid-shake"
import { useMergedRef } from "@/hooks/use-merged-ref"
import { prefersReducedMotion } from "@/lib/motion"

import { NumberFlow } from "@/components/ui/number-flow"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const inputVariants = cva(
  "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70",
  {
    variants: {
      size: {
        sm: "h-8 px-2.5 file:pe-2.5",
        default: "h-9 px-3",
        lg: "h-10 px-3.5",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

type InputControl = HTMLInputElement | HTMLTextAreaElement

type InputProps = Omit<InputPrimitive.Props, "size"> &
  VariantProps<typeof inputVariants> & {
    htmlSize?: number
    shake?: boolean
  }

function Input({
  className,
  size = "default",
  htmlSize,
  shake = true,
  ref,
  ...props
}: InputProps) {
  const resolvedSize = size ?? "default"
  const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref)
  useInvalidShake(inputRef, shake)

  return (
    <InputPrimitive
      ref={setRef}
      data-slot="input"
      data-size={resolvedSize}
      size={htmlSize}
      className={mergeClassName(
        inputVariants({ size: resolvedSize }),
        className
      )}
      {...props}
    />
  )
}

function defaultAnnouncement(remaining: number) {
  return remaining === 0
    ? "Character limit reached"
    : `${remaining} ${remaining === 1 ? "character" : "characters"} left`
}

type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & {
  length: number
  maxLength?: number | null
  controlRef?: React.RefObject<InputControl | null>
  threshold?: number
  announcement?: (remaining: number) => string
}

function InputCount({
  className,
  length,
  maxLength = null,
  controlRef,
  threshold,
  announcement = defaultAnnouncement,
  ref,
  ...props
}: InputCountProps) {
  const max = maxLength !== null && maxLength > 0 ? maxLength : null
  const remaining = max === null ? null : Math.max(0, max - length)
  const near =
    max === null
      ? Infinity
      : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20)))
  const state =
    remaining === null
      ? undefined
      : remaining === 0
        ? "limit"
        : remaining <= near
          ? "near"
          : undefined

  const [message, setMessage] = React.useState("")
  const [lastState, setLastState] = React.useState(state)
  if (state !== lastState) {
    setLastState(state)
    setMessage(state && remaining !== null ? announcement(remaining) : "")
  }

  const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref)

  React.useEffect(() => {
    const element = controlRef?.current
    const count = countRef.current
    if (!element || !count || max === null) {
      return
    }
    let timer: ReturnType<typeof setTimeout> | undefined
    const onBeforeInput = (event: Event) => {
      const input = event as InputEvent
      if (
        !input.inputType?.startsWith("insert") ||
        element.value.length < max ||
        element.selectionStart !== element.selectionEnd ||
        prefersReducedMotion()
      ) {
        return
      }
      count.removeAttribute("data-bump")
      void count.offsetWidth
      count.setAttribute("data-bump", "")
      clearTimeout(timer)
      timer = setTimeout(() => count.removeAttribute("data-bump"), 400)
    }
    element.addEventListener("beforeinput", onBeforeInput)
    return () => {
      element.removeEventListener("beforeinput", onBeforeInput)
      clearTimeout(timer)
    }
  }, [controlRef, max])

  return (
    <span
      ref={setCountRef}
      data-slot="input-count"
      data-state={state}
      className={cn(
        "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground",
        className
      )}
      {...props}
    >
      <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline">
        <NumberFlow value={length} />
        {max !== null && <span>/{max}</span>}
      </span>
      <span role="status" className="sr-only">
        {message}
      </span>
    </span>
  )
}

export {
  Input,
  InputCount,
  useAutosize,
  inputVariants,
  useComposedRef,
  useInvalidShake,
  useMergedRef,
}
export type { InputControl, InputCountProps, InputProps }
```

```tsx title="components/ui/kbd.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { useHeldKeys } from "@/hooks/use-held-keys"
import { keyLabel, parseHotkey, spokenKey, useIsApple } from "@/lib/hotkey"

function resolveKey(key: string, apple: boolean) {
  if (key === "mod") {
    return apple ? "meta" : "ctrl"
  }
  return key
}

const kbdVariants = cva(
  "inline-flex w-fit shrink-0 items-center justify-center gap-0.5 font-sans font-medium whitespace-nowrap tabular-nums transition-[translate,box-shadow,background-color,color] duration-100 ease-out-cubic select-none [unicode-bidi:isolate] motion-reduce:transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        keycap:
          "bg-background text-muted-foreground shadow-[0_1px_0_0_var(--color-border)] inset-ring-(length:--hairline) inset-ring-border in-data-[slot=button]:bg-current/10 in-data-[slot=button]:text-current in-data-[slot=button]:shadow-none in-data-[slot=button]:inset-ring-current/20 data-pressed:bg-muted data-pressed:text-foreground data-pressed:shadow-none motion-safe:data-pressed:translate-y-px dark:not-in-data-[slot=button]:bg-muted/60 dark:data-pressed:bg-muted forced-colors:border",
        flat: "bg-muted text-muted-foreground in-data-highlighted:bg-background in-data-[slot=button]:bg-current/10 in-data-[slot=button]:text-current data-pressed:bg-foreground/15 data-pressed:text-foreground dark:in-data-highlighted:bg-foreground/10",
      },
      size: {
        sm: "h-4.5 min-w-4.5 rounded-[calc(var(--radius-sm)*0.6)] px-1 text-xs [&_svg:not([class*='size-'])]:size-2.5",
        default:
          "h-5 min-w-5 rounded-[calc(var(--radius-sm)*0.75)] px-1 text-xs [&_svg:not([class*='size-'])]:size-3",
        lg: "h-6 min-w-6 rounded-sm px-1.5 text-sm [&_svg:not([class*='size-'])]:size-3.5",
      },
    },
    defaultVariants: {
      variant: "keycap",
      size: "default",
    },
  }
)

type KbdVariant = NonNullable<VariantProps<typeof kbdVariants>["variant"]>
type KbdSize = NonNullable<VariantProps<typeof kbdVariants>["size"]>

type KbdGroupContextValue = {
  variant?: KbdVariant
  size?: KbdSize
  listen?: boolean
}

const KbdGroupContext = React.createContext<KbdGroupContextValue | null>(null)

function KeyName({ keys, apple }: { keys: string[]; apple: boolean }) {
  const visible = keys.map((key) => keyLabel(key, apple))
  const spoken = keys.map((key) => spokenKey(key, apple)).join(" ")
  const label = apple ? visible.join("") : visible.join("+")

  if (label === spoken) {
    return label
  }

  return (
    <>
      <span aria-hidden="true">{label}</span>
      <span className="sr-only">{spoken}</span>
    </>
  )
}

type KbdProps = useRender.ComponentProps<"kbd"> & {
  variant?: KbdVariant
  size?: KbdSize
  keys?: string
  listen?: boolean
}

function Kbd({
  className,
  variant,
  size,
  keys,
  listen,
  render,
  children,
  ...props
}: KbdProps) {
  const group = React.useContext(KbdGroupContext)
  const resolvedVariant = variant ?? group?.variant ?? "keycap"
  const resolvedSize = size ?? group?.size ?? "default"
  const listening = listen ?? group?.listen ?? false
  const apple = useIsApple()
  const pressedKeys = useHeldKeys(listening)

  const chord = keys ? (parseHotkey(keys)[0] ?? []) : null
  const own =
    chord ??
    (typeof children === "string" && children.trim()
      ? parseHotkey(children.trim().replace(/\s+/g, "+"))[0]
      : null)
  const pressed =
    listening &&
    own !== null &&
    own.length > 0 &&
    own.every((key) => pressedKeys.has(resolveKey(key, apple)))

  return useRender({
    defaultTagName: "kbd",
    render,
    props: mergeProps<"kbd">(
      {
        className: cn(
          kbdVariants({ variant: resolvedVariant, size: resolvedSize }),
          className
        ),
        children: chord ? <KeyName keys={chord} apple={apple} /> : children,
      },
      props,
      {
        "data-slot": "kbd",
        "data-variant": resolvedVariant,
        "data-size": resolvedSize,
        ...(pressed ? { "data-pressed": "" } : {}),
      } as React.ComponentProps<"kbd">
    ),
  })
}

type KbdGroupProps = useRender.ComponentProps<"kbd"> & {
  variant?: KbdVariant
  size?: KbdSize
  keys?: string
  listen?: boolean
  separator?: React.ReactNode
}

function KbdGroup({
  className,
  variant,
  size,
  keys,
  listen,
  separator = "then",
  render,
  children,
  ...props
}: KbdGroupProps) {
  const context = React.useMemo(
    () => ({ variant, size, listen }),
    [variant, size, listen]
  )
  const chords = keys ? parseHotkey(keys) : null

  const content = chords
    ? chords.map((chord, index) => (
        <React.Fragment key={index}>
          {index > 0 && (
            <span
              data-slot="kbd-separator"
              className="px-0.5 font-sans text-xs text-muted-foreground"
            >
              {separator}
            </span>
          )}
          {chord.map((key, keyIndex) => (
            <Kbd key={keyIndex} keys={key} />
          ))}
        </React.Fragment>
      ))
    : children

  const element = useRender({
    defaultTagName: "kbd",
    render,
    props: mergeProps<"kbd">(
      {
        className: cn(
          "inline-flex w-fit shrink-0 items-center gap-1 align-middle [unicode-bidi:isolate] rtl:flex-row-reverse",
          className
        ),
        children: content,
      },
      props,
      { "data-slot": "kbd-group" } as React.ComponentProps<"kbd">
    ),
  })

  return (
    <KbdGroupContext.Provider value={context}>
      {element}
    </KbdGroupContext.Provider>
  )
}

export { Kbd, KbdGroup, kbdVariants, useHeldKeys }
export type { KbdGroupProps, KbdProps }
```

```tsx title="components/ui/number-flow.tsx"
"use client"

import * as React from "react"
import { cn } from "cn"

import { easeSpring } from "@/lib/motion"

type NumberFlowTrend = "auto" | "up" | "down" | "shortest"

type NumberFlowChar = {
  key: string
  section: number
  order: number
  value: string
  digit: number | null
}

type NumberFlowItem = NumberFlowChar & {
  exiting: boolean
  exitId: number
}

type NumberFlowProps = Omit<
  React.ComponentProps<"span">,
  "children" | "prefix"
> & {
  value: number
  locales?: Intl.LocalesArgument
  format?: Intl.NumberFormatOptions
  prefix?: string
  suffix?: string
  trend?: NumberFlowTrend
  duration?: number
  easing?: string
  animated?: boolean
  onAnimationsStart?: () => void
  onAnimationsFinish?: () => void
}

const rows = 30

const restingClasses =
  "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]"

function getSpinDelta(
  from: number,
  to: number,
  trend: Exclude<NumberFlowTrend, "auto">
) {
  let delta = to - from

  if (trend === "up") {
    if (delta < 0) {
      delta += 10
    }
  } else if (trend === "down") {
    if (delta > 0) {
      delta -= 10
    }
  } else if (delta > 5) {
    delta -= 10
  } else if (delta < -4) {
    delta += 10
  }

  return delta
}

function digitGlyphs(
  formatter: Intl.NumberFormat,
  locales: Intl.LocalesArgument
) {
  const { numberingSystem } = formatter.resolvedOptions()
  const plain = new Intl.NumberFormat(locales, {
    numberingSystem,
    useGrouping: false,
  })

  return Array.from({ length: 10 }, (_, digit) => plain.format(digit))
}

function formatChars(
  value: number,
  locales: Intl.LocalesArgument,
  format: Intl.NumberFormatOptions | undefined,
  prefix: string | undefined,
  suffix: string | undefined
) {
  const formatter = new Intl.NumberFormat(locales, format)
  const glyphs = digitGlyphs(formatter, locales)
  const parts = formatter.formatToParts(value)
  const integerCount = parts
    .filter((part) => part.type === "integer")
    .reduce((count, part) => count + Array.from(part.value).length, 0)
  const chars: NumberFlowChar[] = []
  const occurrences = new Map<string, number>()
  let integerIndex = 0
  let fractionIndex = 0
  let seenNumber = false

  function symbol(section: number, type: string, text: string) {
    const name = `${section}-${type}`
    const occurrence = occurrences.get(name) ?? 0
    occurrences.set(name, occurrence + 1)
    chars.push({
      key: `s${name}-${occurrence}`,
      section,
      order: occurrence,
      value: text,
      digit: null,
    })
  }

  if (prefix) {
    chars.push({
      key: "prefix",
      section: 0,
      order: 0,
      value: prefix,
      digit: null,
    })
  }

  for (const part of parts) {
    if (part.type === "integer") {
      seenNumber = true
      for (const glyph of Array.from(part.value)) {
        const place = integerCount - 1 - integerIndex
        const digit = glyphs.indexOf(glyph)
        chars.push({
          key: `i${place}`,
          section: 2,
          order: -place,
          value: glyph,
          digit: digit === -1 ? null : digit,
        })
        integerIndex += 1
      }
    } else if (part.type === "group") {
      const place = integerCount - 1 - integerIndex
      chars.push({
        key: `g${place}`,
        section: 2,
        order: -place - 0.5,
        value: part.value,
        digit: null,
      })
    } else if (part.type === "decimal") {
      seenNumber = true
      chars.push({
        key: "decimal",
        section: 3,
        order: 0,
        value: part.value,
        digit: null,
      })
    } else if (part.type === "fraction") {
      for (const glyph of Array.from(part.value)) {
        const digit = glyphs.indexOf(glyph)
        chars.push({
          key: `f${fractionIndex}`,
          section: 4,
          order: fractionIndex,
          value: glyph,
          digit: digit === -1 ? null : digit,
        })
        fractionIndex += 1
      }
    } else {
      symbol(seenNumber ? 5 : 1, part.type, part.value)
    }
  }

  if (suffix) {
    chars.push({
      key: "suffix",
      section: 6,
      order: 0,
      value: suffix,
      digit: null,
    })
  }

  return chars
}

function signature(chars: NumberFlowChar[]) {
  return chars.map((char) => `${char.key}:${char.value}`).join("|")
}

function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) {
  const keys = new Set(next.map((char) => char.key))
  const kept = previous
    .filter((item) => !keys.has(item.key))
    .map((item) =>
      item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 }
    )
  const fresh = next.map((char) => {
    const before = previous.find((item) => item.key === char.key)
    return { ...char, exiting: false, exitId: before?.exitId ?? 0 }
  })

  return [...kept, ...fresh].sort(
    (a, b) => a.section - b.section || a.order - b.order
  )
}

function subscribeReducedMotion(callback: () => void) {
  if (typeof window.matchMedia !== "function") {
    return () => {}
  }
  const query = window.matchMedia("(prefers-reduced-motion: reduce)")
  query.addEventListener("change", callback)
  return () => query.removeEventListener("change", callback)
}

function getReducedMotion() {
  return (
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

function canAnimate() {
  return (
    typeof Element !== "undefined" &&
    typeof Element.prototype.animate === "function"
  )
}

function liveIndex(column: HTMLElement) {
  const height = column.offsetHeight

  if (!height) {
    return null
  }

  const translate = getComputedStyle(column).translate
  const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0")
  const amount = parseFloat(y) || 0

  return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows
}

function NumberFlow({
  value,
  locales = "en-US",
  format,
  prefix,
  suffix,
  trend = "auto",
  duration = 600,
  easing = easeSpring,
  animated = true,
  onAnimationsStart,
  onAnimationsFinish,
  className,
  ...props
}: NumberFlowProps) {
  const chars = formatChars(value, locales, format, prefix, suffix)
  const key = signature(chars)
  const reducedMotion = React.useSyncExternalStore(
    subscribeReducedMotion,
    getReducedMotion,
    () => false
  )
  const animate = animated && !reducedMotion && canAnimate()
  const [state, setState] = React.useState(() => ({
    key,
    items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),
  }))

  if (state.key !== key) {
    setState({
      key,
      items: animate
        ? mergeItems(state.items, chars)
        : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),
    })
  }

  const rootRef = React.useRef<HTMLSpanElement>(null)
  const previousRef = React.useRef<Map<
    string,
    { digit: number | null; exiting: boolean; exitId: number }
  > | null>(null)
  const valueRef = React.useRef(value)
  const batchRef = React.useRef(0)
  const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish })
  const finishExitRef = React.useRef((itemKey: string, exitId: number) => {
    setState((current) => ({
      ...current,
      items: current.items.filter(
        (item) =>
          !(item.key === itemKey && item.exiting && item.exitId === exitId)
      ),
    }))
  })

  React.useLayoutEffect(() => {
    callbacksRef.current = { onAnimationsStart, onAnimationsFinish }
  })

  React.useLayoutEffect(() => {
    const root = rootRef.current
    const previous = previousRef.current
    const current = new Map(
      state.items.map((item) => [
        item.key,
        { digit: item.digit, exiting: item.exiting, exitId: item.exitId },
      ])
    )
    const previousValue = valueRef.current

    previousRef.current = current
    valueRef.current = value

    if (!root || !previous || !animate) {
      return
    }

    const direction =
      trend !== "auto"
        ? trend
        : value > previousValue
          ? "up"
          : value < previousValue
            ? "down"
            : "shortest"
    const timing = { duration, easing }
    const started: Animation[] = []
    const cells = root.querySelectorAll<HTMLElement>(
      ":scope > [data-number-flow-key]"
    )

    for (const cell of cells) {
      const itemKey = cell.dataset.numberFlowKey!
      const item = current.get(itemKey)
      const before = previous.get(itemKey)

      if (!item || typeof cell.animate !== "function") {
        continue
      }

      const column = cell.querySelector<HTMLElement>(
        "[data-slot=number-flow-column]"
      )
      const entering = !item.exiting && (!before || before.exiting)
      const leaving = item.exiting && (!before || !before.exiting)

      if (entering || leaving) {
        const running = cell.getAnimations()
        const fromWidth = running.length
          ? cell.getBoundingClientRect().width
          : entering
            ? 0
            : cell.getBoundingClientRect().width
        const fromOpacity = running.length
          ? Number(getComputedStyle(cell).opacity)
          : entering
            ? 0
            : 1
        running.forEach((animation) => animation.cancel())
        const toWidth = entering ? cell.getBoundingClientRect().width : 0
        const animation = cell.animate(
          [
            { width: `${fromWidth}px`, opacity: fromOpacity },
            { width: `${toWidth}px`, opacity: entering ? 1 : 0 },
          ],
          { ...timing, fill: leaving ? "forwards" : "none" }
        )
        started.push(animation)
        if (leaving) {
          const exitId = item.exitId
          animation.finished.then(
            () => finishExitRef.current(itemKey, exitId),
            () => {}
          )
        }
      }

      if (!column || item.digit === null || item.exiting) {
        continue
      }

      const target = item.digit
      const running = column.getAnimations()
      const live = running.length ? liveIndex(column) : null
      running.forEach((animation) => animation.cancel())
      const from =
        live !== null
          ? live - 10
          : entering
            ? 0
            : before && before.digit !== null
              ? before.digit
              : target

      if (live === null && from === target && !entering) {
        continue
      }

      const delta = getSpinDelta(
        from,
        target,
        entering && live === null ? "up" : direction
      )

      if (delta === 0) {
        continue
      }

      started.push(
        column.animate(
          [
            { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` },
            { translate: `0 ${(-(target + 10) / rows) * 100}%` },
          ],
          timing
        )
      )
    }

    if (!started.length) {
      return
    }

    batchRef.current += 1
    const batch = batchRef.current
    callbacksRef.current.onAnimationsStart?.()
    Promise.all(started.map((animation) => animation.finished)).then(
      () => {
        if (batch === batchRef.current) {
          callbacksRef.current.onAnimationsFinish?.()
        }
      },
      () => {}
    )
  }, [state.items, animate, duration, easing, trend, value])

  return (
    <span
      ref={rootRef}
      dir="ltr"
      className={cn("inline-block max-w-full tabular-nums", className)}
      {...props}
      data-slot="number-flow"
    >
      <span data-slot="number-flow-text" className="sr-only">
        {state.items
          .filter((item) => !item.exiting)
          .map((item) => item.value)
          .join("")}
      </span>
      {state.items.map((item) =>
        item.digit === null ? (
          <span
            key={item.key}
            data-number-flow-key={item.key}
            data-slot="number-flow-symbol"
            aria-hidden
            className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap"
          >
            {item.value}
          </span>
        ) : (
          <span
            key={item.key}
            data-number-flow-key={item.key}
            data-slot="number-flow-digit"
            aria-hidden
            className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]"
          >
            <span className="opacity-0">{item.value}</span>
            <span
              aria-hidden
              data-slot="number-flow-column"
              data-digit={item.digit}
              className={cn(
                "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none",
                restingClasses
              )}
            >
              {Array.from({ length: rows }, (_, row) => (
                <span key={row} className="block py-[0.2em]">
                  {glyphFor(item, row)}
                </span>
              ))}
            </span>
          </span>
        )
      )}
    </span>
  )
}

function glyphFor(item: NumberFlowItem, row: number) {
  return String.fromCodePoint(
    item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10)
  )
}

export {
  NumberFlow,
  getSpinDelta,
  formatChars,
  type NumberFlowProps,
  type NumberFlowTrend,
}
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

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

```tsx
<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.

## Composition

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

## Examples

### Lift transition and bar

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

```tsx title="components/examples/questionnaire/lift.tsx"
"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.

```tsx title="components/examples/questionnaire/auto-advance.tsx"
"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.

```tsx title="components/examples/questionnaire/conditional.tsx"
"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="you@example.com"
            />
          </QuestionnaireChoices>
        </QuestionnaireItem>
        <QuestionnaireActions>
          <QuestionnairePrevious />
          <QuestionnaireNext />
          <QuestionnaireSubmit />
        </QuestionnaireActions>
      </Questionnaire>
    </div>
  )
}
```

### Right to left

The slide follows the reading direction.

```tsx title="components/examples/questionnaire/rtl.tsx"
"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>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `↑` `↓` | Moves between the answers, including the text field. Moving doesn't pick. |
| `Space` | Picks the focused answer, or toggles it in multiple choice. |
| `Enter` | Continues. 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. |
| `Home` `End` | First or last answer. |
| `A` `1` | Picks an answer by its key when shortcuts is set. Key caps brighten once the questionnaire has focus; click anywhere in it to start. |
| `Esc` | Leaves the text field so letter shortcuts work again. |
| `⌘` `Enter` | Submits from anywhere. |

## Accessibility

- 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.

## API reference

### Questionnaire

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `transition` | `"slide" \| "lift"` | `"slide"` |  |
| `autoAdvance` | `boolean` | `false` | Moves on 350ms after a single answer is picked for the first time. |
| `shortcuts` | `"letters" \| "numbers"` | – | Adds a key shortcut to every answer. |
| `item` | `string` | – |  |
| `defaultItem` | `string` | – |  |
| `onItemChange` | `(item: string) => void` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="questionnaire"` | The form, with data-transition. |
| `data-current / data-total` | The current position and number of questions. |
| `data-first / data-last` | Present on the first or last question. |

### QuestionnaireProgress

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"segments" \| "bar"` | `"segments"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="questionnaire-progress-segment"` | One per question, with data-status, data-active and data-invalid. |

### QuestionnaireItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | – | The form field name. |
| `required` | `boolean` | `false` |  |
| `multiple` | `boolean` | `false` | Checkboxes instead of radios. |
| `disabled` | `boolean` | `false` | Leaves the question out of the flow. |
| `onStatusChange` | `(status) => void` | – |  |

| Attribute | Description |
| --- | --- |
| `data-status` | "unanswered", "answered" or "skipped". |
| `data-active` | The current question. |
| `data-invalid` | Needs an answer. |
| `data-leaving` | The question animating out. |

### QuestionnaireChoice

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | – |  |
| `defaultChecked` | `boolean` | – |  |
| `disabled` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<label>` |  |

| Attribute | Description |
| --- | --- |
| `data-checked / data-unchecked` | Whether the answer is picked. |
| `data-type` | "radio" or "checkbox". |
| `data-shortcut` | The answer's key. |

### QuestionnaireInput

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

### Navigation buttons

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
