For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Questionnaire 01

A compact, multi-step questionnaire with single and multiple selection.

Single selection

Select an option to move to the next step. An optional free-text answer can be submitted with Enter.

Installation

Set up the @cobalt registry as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/questionnaire-01

Pass questions with unique IDs, step labels, and options. Set select on the questionnaire or an individual question, and use onComplete to receive answers.

Source

"use client";

import { RiCloseLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
  CardAction,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Toggle } from "@/components/ui/toggle";

export interface QuestionnaireQuestion {
  id: string;
  question: string;
  stepLabel: string;
  options: { value: string; label: string; description?: string }[];
  select?: "single" | "multiple";
  other?: boolean | { placeholder?: string };
}
export type QuestionnaireAnswers = Record<
  string,
  { values: string[]; other: string }
>;

export function Questionnaire({
  questions,
  select = "multiple",
  onComplete,
  onDismiss,
  className,
}: {
  questions: QuestionnaireQuestion[];
  select?: "single" | "multiple";
  onComplete?: (answers: QuestionnaireAnswers) => void;
  onDismiss?: () => void;
  className?: string;
}) {
  const [step, setStep] = React.useState(0);
  const [answers, setAnswers] = React.useState<QuestionnaireAnswers>({});
  const [dismissed, setDismissed] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  React.useEffect(
    () => () => {
      if (timer.current) {
        clearTimeout(timer.current);
      }
    },
    []
  );
  const question = questions[step];
  if (!question || dismissed) {
    return null;
  }
  const mode = question.select ?? select;
  const answer = answers[question.id] ?? { values: [], other: "" };
  const update = (next: { values: string[]; other: string }) => {
    setAnswers((current) => ({ ...current, [question.id]: next }));
  };
  const next = (result = answers) => {
    if (step < questions.length - 1) {
      setStep(step + 1);
    } else {
      onComplete?.(result);
    }
  };
  const choose = (value: string) => {
    const values =
      mode === "single"
        ? [value]
        : answer.values.includes(value)
          ? answer.values.filter((item) => item !== value)
          : [...answer.values, value];
    const updated = {
      ...answers,
      [question.id]: {
        ...answer,
        values,
        ...(mode === "single" ? { other: "" } : {}),
      },
    };
    setAnswers(updated);
    if (mode === "single") {
      if (timer.current) {
        clearTimeout(timer.current);
      }
      timer.current = setTimeout(() => next(updated), 180);
    }
  };
  return (
    <Card
      size="sm"
      radius={16}
      className={cn("w-full max-w-[560px]", className)}
    >
      <CardHeader className="mx-1 -mb-1">
        <CardTitle id={`question-${question.id}`}>
          {question.question}
        </CardTitle>
        <CardAction>
          <Button
            variant="close"
            size="icon-2xs"
            aria-label="Dismiss questionnaire"
            tooltip="Dismiss"
            onClick={() => {
              if (timer.current) {
                clearTimeout(timer.current);
              }
              setDismissed(true);
              onDismiss?.();
            }}
          >
            <RiCloseLine />
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent className="gap-0">
        <fieldset
          key={question.id}
          role={mode === "single" ? "radiogroup" : undefined}
          aria-labelledby={`question-${question.id}`}
          className="flex min-w-0 flex-col gap-2"
        >
          {question.options.map((option, index) => {
            const checked = answer.values.includes(option.value);
            return (
              <div
                key={option.value}
                className="questionnaire-01-enter"
                style={{ "--delay": `${index * 40}ms` } as React.CSSProperties}
              >
                {mode === "single" ? (
                  <Toggle
                    variant="choice"
                    role="radio"
                    aria-checked={checked}
                    pressed={checked}
                    onPressedChange={() => choose(option.value)}
                    className="h-auto w-full justify-between gap-3 text-left whitespace-normal"
                  >
                    <span className="block min-h-12 min-w-0 flex-1 py-2">
                      <span className="block text-sm leading-5 font-medium">
                        {option.label}
                      </span>
                      {option.description && (
                        <span className="block text-sm leading-5 font-normal text-muted-foreground">
                          {option.description}
                        </span>
                      )}
                    </span>
                    <Badge
                      aria-hidden="true"
                      shape="circle"
                      variant="counter"
                      tone={checked ? "primary" : "neutral"}
                    >
                      {index + 1}
                    </Badge>
                  </Toggle>
                ) : (
                  <div
                    className={cn(
                      "relative flex min-h-[50px] items-center justify-between gap-3 rounded-[12px] border border-border px-3 py-2 transition-colors duration-150 hover:bg-muted/50 motion-reduce:transition-none",
                      checked && "border-primary bg-primary/5"
                    )}
                  >
                    <Label
                      htmlFor={`${question.id}-${option.value}`}
                      className="min-w-0 cursor-pointer flex-col items-start after:absolute after:inset-0"
                    >
                      <span>{option.label}</span>
                      {option.description && (
                        <span className="font-normal text-muted-foreground">
                          {option.description}
                        </span>
                      )}
                    </Label>
                    <Checkbox
                      id={`${question.id}-${option.value}`}
                      className="relative z-10"
                      checked={checked}
                      onCheckedChange={() => choose(option.value)}
                    />
                  </div>
                )}
              </div>
            );
          })}
          {question.other && (
            // cobalt-allow-focus-within -- text input (the Other field)
            <div className="flex min-h-[50px] items-center gap-3 rounded-[12px] border border-border px-3 py-2 focus-within:border-ring">
              <div className="min-w-0 flex-1">
                <Label htmlFor={`${question.id}-other`}>Other</Label>
                <Input
                  id={`${question.id}-other`}
                  variant="ghost"
                  size="xs"
                  placeholder={
                    typeof question.other === "object"
                      ? question.other.placeholder
                      : "Write your answer"
                  }
                  value={answer.other}
                  onChange={(event) =>
                    update({
                      values: mode === "single" ? [] : answer.values,
                      other: event.target.value,
                    })
                  }
                  onKeyDown={(event) => {
                    if (event.key === "Enter") {
                      event.preventDefault();
                      next();
                    }
                  }}
                />
              </div>
              {mode === "single" ? (
                <Badge
                  aria-hidden="true"
                  shape="circle"
                  variant="counter"
                  tone="neutral"
                >
                  {question.options.length + 1}
                </Badge>
              ) : (
                <span
                  aria-hidden="true"
                  className={cn(
                    "size-4 rounded-[4px] border border-input",
                    answer.other && "border-primary bg-primary"
                  )}
                />
              )}
            </div>
          )}
        </fieldset>
      </CardContent>
      <CardFooter className="flex-wrap justify-between gap-2">
        <nav
          aria-label="Questionnaire steps"
          className="flex flex-wrap items-center gap-1"
        >
          {questions.map((item, index) => (
            <Button
              key={item.id}
              variant={index === step ? "soft" : "ghost-muted"}
              size="xs"
              type="button"
              aria-current={index === step ? "step" : undefined}
              onClick={() => {
                if (timer.current) {
                  clearTimeout(timer.current);
                }
                setStep(index);
              }}
            >
              {item.stepLabel}
            </Button>
          ))}
        </nav>
        <div className="flex items-center gap-1">
          <Button
            size="xs"
            variant="outline"
            disabled={step === 0}
            onClick={() => setStep(step - 1)}
          >
            Previous
          </Button>
          <Button size="xs" onClick={() => next()}>
            {step === questions.length - 1 ? "Done" : "Next"}
          </Button>
        </div>
      </CardFooter>
    </Card>
  );
}