A step-by-step form that moves between questions with a sense of direction, shows real progress and answers from the keyboard.
"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's what the form sent:
</p>
</div>
<ul className="flex w-full flex-col gap-1 font-mono text-xs">
{answers.map(([name, value], index) => (
<li key={index} className="flex justify-between gap-4">
<span className="text-muted-foreground">{name}</span>
<span className="truncate">{value}</span>
</li>
))}
</ul>
<Button variant="outline" size="sm" onClick={() => setAnswers(null)}>
Start over
</Button>
</div>
)
}
return (
<div className="w-full max-w-md rounded-xl border p-5">
<Questionnaire
items={items}
shortcuts="letters"
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
setAnswers(
Array.from(data.entries(), ([name, value]) => [name, String(value)])
)
}}
>
<QuestionnaireProgress />
{items.map((item) => (
<QuestionnaireItem
key={item.name}
name={item.name}
required={"required" in item && item.required}
multiple={"multiple" in item && item.multiple}
>
<QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
<QuestionnaireDescription>
{item.description}
</QuestionnaireDescription>
<QuestionnaireChoices>
{item.choices.map((choice) => (
<QuestionnaireChoice key={choice.value} value={choice.value}>
<span className="font-medium">{choice.label}</span>
{"description" in choice ? (
<QuestionnaireChoiceDescription>
{choice.description}
</QuestionnaireChoiceDescription>
) : null}
</QuestionnaireChoice>
))}
{"input" in item ? (
<QuestionnaireInput
aria-label={item.input.label}
placeholder={item.input.placeholder}
/>
) : null}
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireSkip />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/questionnaire.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @shadcn/react @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
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, }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 }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 }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 }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, }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 } - 4
Update the import paths to match your project setup.
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"<Questionnaire onSubmit={handleSubmit}>
<QuestionnaireProgress />
<QuestionnaireItem name="role" required>
<QuestionnaireTitle>What best describes your work?</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="design">Design</QuestionnaireChoice>
<QuestionnaireChoice value="engineering">Engineering</QuestionnaireChoice>
</QuestionnaireChoices>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>Answers submit as a regular form, keyed by each item's name. Questions move toward the direction you go, the card eases to each question's height, and the progress fills only for questions that were answered or skipped.
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│ ├── QuestionnaireTitle
│ ├── QuestionnaireDescription
│ ├── QuestionnaireChoices
│ │ ├── QuestionnaireChoice
│ │ │ └── QuestionnaireChoiceDescription
│ │ └── QuestionnaireInput
│ └── QuestionnaireError
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmitLift transition and bar#
transition="lift" moves questions up and down like a stack. variant="bar" on the progress shows one continuous bar.
"use client"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
const items = [
{
name: "team",
prompt: "How big is your team?",
choices: ["Just me", "2–10", "11–50", "More than 50"],
},
{
name: "stage",
prompt: "Where is your product today?",
choices: ["Idea", "In development", "Launched"],
},
{
name: "timeline",
prompt: "When do you want to ship?",
choices: ["This month", "This quarter", "No rush"],
},
]
export function QuestionnaireLift() {
return (
<div className="w-full max-w-md rounded-xl border p-5">
<Questionnaire
transition="lift"
onSubmit={(event) => event.preventDefault()}
>
<QuestionnaireProgress variant="bar" />
{items.map((item) => (
<QuestionnaireItem key={item.name} name={item.name} required>
<QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
<QuestionnaireChoices>
{item.choices.map((choice) => (
<QuestionnaireChoice key={choice} value={choice}>
{choice}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
</div>
)
}Auto-advance#
With autoAdvance, picking a single answer moves on after a short pause, so you can answer with 1–5 alone. Changing an earlier answer never jumps ahead, and the last question never submits by itself.
"use client"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
const scale = ["Not at all", "Slightly", "Moderately", "Very", "Extremely"]
const items = [
{ name: "useful", prompt: "How useful was today's session?" },
{ name: "pace", prompt: "How comfortable was the pace?" },
{ name: "recommend", prompt: "How likely are you to come back?" },
]
export function QuestionnaireAutoAdvance() {
return (
<div className="w-full max-w-md rounded-xl border p-5">
<Questionnaire
autoAdvance
shortcuts="numbers"
onSubmit={(event) => event.preventDefault()}
>
<QuestionnaireProgress />
{items.map((item) => (
<QuestionnaireItem key={item.name} name={item.name} required>
<QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
<QuestionnaireChoices>
{scale.map((label) => (
<QuestionnaireChoice key={label} value={label}>
{label}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
</div>
)
}Conditional questions#
Disable an item to leave it out. It drops from the progress and the flow until it applies.
"use client"
import * as React from "react"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
export function QuestionnaireConditional() {
const [usesFramework, setUsesFramework] = React.useState<string | null>(null)
return (
<div className="w-full max-w-md rounded-xl border p-5">
<Questionnaire onSubmit={(event) => event.preventDefault()}>
<QuestionnaireProgress />
<QuestionnaireItem name="framework" required>
<QuestionnaireTitle>Do you use a React framework?</QuestionnaireTitle>
<QuestionnaireChoices>
{["Yes", "No"].map((value) => (
<QuestionnaireChoice
key={value}
value={value}
onChange={(event) => setUsesFramework(event.target.value)}
>
{value}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
</QuestionnaireItem>
<QuestionnaireItem
name="which"
required
disabled={usesFramework !== "Yes"}
>
<QuestionnaireTitle>Which one?</QuestionnaireTitle>
<QuestionnaireDescription>
Only asked when you use one. The progress counts it only then.
</QuestionnaireDescription>
<QuestionnaireChoices>
{["Next.js", "React Router", "TanStack Start"].map((value) => (
<QuestionnaireChoice key={value} value={value}>
{value}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
</QuestionnaireItem>
<QuestionnaireItem name="email">
<QuestionnaireTitle>
Where can we send the results?
</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireInput
type="email"
aria-label="Email"
placeholder="[email protected]"
/>
</QuestionnaireChoices>
</QuestionnaireItem>
<QuestionnaireActions>
<QuestionnairePrevious />
<QuestionnaireNext />
<QuestionnaireSubmit />
</QuestionnaireActions>
</Questionnaire>
</div>
)
}Right to left#
The slide follows the reading direction.
"use client"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
const items = [
{
name: "language",
prompt: "ما اللغة التي تفضلها؟",
choices: ["العربية", "الإنجليزية", "كلاهما"],
},
{
name: "theme",
prompt: "أي مظهر تستخدم؟",
choices: ["فاتح", "داكن", "حسب النظام"],
},
]
export function QuestionnaireRtl() {
return (
<div dir="rtl" className="w-full max-w-md rounded-xl border p-5">
<Questionnaire onSubmit={(event) => event.preventDefault()}>
<QuestionnaireProgress />
{items.map((item) => (
<QuestionnaireItem key={item.name} name={item.name} required>
<QuestionnaireTitle>{item.prompt}</QuestionnaireTitle>
<QuestionnaireChoices>
{item.choices.map((choice) => (
<QuestionnaireChoice key={choice} value={choice}>
{choice}
</QuestionnaireChoice>
))}
</QuestionnaireChoices>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious>السابق</QuestionnairePrevious>
<QuestionnaireNext>التالي</QuestionnaireNext>
<QuestionnaireSubmit>إرسال</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
</div>
)
}| 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. |
| HomeEnd | First or last answer. |
| A1 | 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. |
- Each
QuestionnaireItemis a<fieldset>whose title is its<legend>. Inactive questions are hidden and inert, including the one animating out. - The progress is a
progressbarthat announces “Question 2 of 5”; the segments and spinning number are decorative. - Errors use
role="alert"and the invalid answers getaria-invalid. - With reduced motion, questions switch instantly and nothing shakes.
| Prop | Type | Default |
|---|---|---|
transition | "slide" | "lift" | "slide" |
autoAdvanceMoves on 350ms after a single answer is picked for the first time. | boolean | false |
shortcutsAdds a key shortcut to every answer. | "letters" | "numbers" | – |
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. |
| Prop | Type | Default |
|---|---|---|
variant | "segments" | "bar" | "segments" |
| Attribute | Description |
|---|---|
data-slot="questionnaire-progress-segment" | One per question, with data-status, data-active and data-invalid. |
| Prop | Type | Default |
|---|---|---|
nameThe form field name. | string | – |
required | boolean | false |
multipleCheckboxes instead of radios. | boolean | false |
disabledLeaves the question out of the flow. | boolean | false |
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. |
| Prop | Type | Default |
|---|---|---|
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. |
A free-text answer that sits with the choices. Typing clears the picked choice. Give it a label or aria-label.
| Prop | Type | Default |
|---|---|---|
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.