rapidUI

Questionnaire

Step-by-step question flow with single and multiple choice answers.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/questionnaire.json

Installs to @/components/ui/questionnaire.

Manual installation

Install the dependencies:

pnpm add @shadcn/react@^0.3.1 lucide-react@^1.48.0

Add the components it builds on: button.

Copy the source into your project:

components/ui/questionnaire.tsx
'use client'

import { Questionnaire as QuestionnairePrimitive } from '@shadcn/react/questionnaire'
import { CheckIcon } from 'lucide-react'
import type * as React from 'react'

import { type Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'

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

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

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

function QuestionnaireTitle({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
	return (
		<QuestionnairePrimitive.Title
			data-slot="questionnaire-title"
			className={cn(
				'font-heading text-base leading-snug font-medium text-pretty [&: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 text-muted-foreground', className)}
			{...props}
		/>
	)
}

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

function QuestionnaireChoice({
	children,
	className,
	...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
	return (
		<QuestionnairePrimitive.Choice
			data-slot="questionnaire-choice"
			className={cn(
				'group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border border-input bg-transparent px-3 py-2.5 text-start text-sm transition-colors outline-none select-none hover:bg-muted/50 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive dark:bg-input/20 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted',
				'data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
				className,
			)}
			{...props}
		>
			<QuestionnairePrimitive.ChoiceInput
				data-slot="questionnaire-choice-input"
				className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
			/>
			<span
				aria-hidden="true"
				data-slot="questionnaire-choice-indicator"
				className="pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border border-input group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary"
			>
				<span
					data-slot="questionnaire-choice-indicator-dot"
					className="hidden size-2 rounded-full bg-primary-foreground group-data-[type=checkbox]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
				/>
				<CheckIcon
					data-slot="questionnaire-choice-indicator-check"
					className="hidden size-3.5 group-data-[type=radio]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
				/>
			</span>
			<QuestionnairePrimitive.ChoiceLabel
				data-slot="questionnaire-choice-label"
				className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
			>
				{children}
			</QuestionnairePrimitive.ChoiceLabel>
			<QuestionnairePrimitive.ChoiceShortcut
				data-slot="questionnaire-choice-shortcut"
				className="pointer-events-none ms-auto hidden size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-md border border-input bg-background font-mono text-[0.625rem] leading-none font-medium text-muted-foreground group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[shortcut]/questionnaire-choice:inline-flex"
			/>
		</QuestionnairePrimitive.Choice>
	)
}

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

function QuestionnaireInput({ className, ...props }: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
	return (
		<div data-slot="questionnaire-input-wrapper" className="group/questionnaire-input relative w-full min-w-0">
			<QuestionnairePrimitive.Input
				data-slot="questionnaire-input"
				className={cn(
					'h-10 min-h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 sm:min-h-0 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
					'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
					className,
				)}
				{...props}
			/>
		</div>
	)
}

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

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

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

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

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

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

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

A step-by-step form that shows one question at a time, built on @shadcn/react. It manages the active question, answer state, validation of required questions, progress, keyboard shortcuts and previous, skip, next and submit navigation. Answers are submitted as regular form data, which makes it suitable for citizen surveys and guided applications.

Features

  • One question at a time with progress
  • Single and multiple choice answers
  • Free-text answers via QuestionnaireInput
  • Required questions with built-in validation messages
  • Optional letter or number shortcuts
  • Previous, skip, next and submit buttons that show only when relevant
  • Answers submitted as FormData

Usage

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

const items = [{ name: 'satisfaction', required: true }] as const

export function Survey() {
  return (
    <Questionnaire
      items={items}
      onSubmit={(event) => {
        event.preventDefault()
        const answers = new FormData(event.currentTarget)
      }}
    >
      <QuestionnaireProgress />
      <QuestionnaireItem name="satisfaction" required>
        <QuestionnaireTitle>How satisfied are you with how your case was handled?</QuestionnaireTitle>
        <QuestionnaireChoices>
          <QuestionnaireChoice value="satisfied">Satisfied</QuestionnaireChoice>
          <QuestionnaireChoice value="dissatisfied">Dissatisfied</QuestionnaireChoice>
        </QuestionnaireChoices>
        <QuestionnaireError />
      </QuestionnaireItem>
      <QuestionnaireActions>
        <QuestionnairePrevious />
        <QuestionnaireSkip />
        <QuestionnaireNext />
        <QuestionnaireSubmit />
      </QuestionnaireActions>
    </Questionnaire>
  )
}

Examples

Default

A three-step citizen satisfaction survey with a free-text option.

Multiple Answers

Accessibility needs for a council meeting: a question that accepts several answers, then an optional one with a free-text field.

With Shortcuts

A library visit survey where number keys select answers.

Free Text

Signing up for case updates with text and email inputs.

With Result

Handles submit and shows a confirmation with the collected answers.

Accessibility

  • Each question is a fieldset with its title as legend
  • Choices are native radio or checkbox inputs
  • Progress is exposed as a progressbar with a text value
  • Validation errors are announced with role="alert"
  • Inactive questions are hidden and inert; focus moves to the active question