Questionnaire
A step-by-step form that moves between questions with a sense of direction, shows real progress and answers from the keyboard.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/questionnaire.jsonInstall manually
Install the dependencies:
npm install @shadcn/react @tabler/icons-react class-variance-authority cnIt builds on these, so copy them first:
Copy this into
components/ui/questionnaire.tsx: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, }
Examples
Auto-advance
Conditional questions
Lift transition and bar
Right to left
Feedback
Questions, bugs or ideas for Questionnaire? Post them here, or reply to someone else. Sign in with GitHub to comment.