Block
Questionnaire Card
A one-question-at-a-time card for AI chat apps — designed to overlay the chat composer when an agent needs answers and clarifications from the user. Single-choice questions advance on click, multi-select and free-text questions get explicit pills, and skipped questions fall back to sensible defaults.
Dashboard setup
Installation
npx shadcn@latest add dreambaseai/registry/questionnaire-cardInstalls questionnaire-card.tsx into your UI directory alongside the upstream questionnaire and button components for your selected preset. Pin a release with #v0.1.0.
Usage
Questions are plain JSON — single choice, multiple choice (multiple), free text (omit choices), and an optional "other" input (allowOther).
import { QuestionnaireCard, type QuestionnaireCardData,} from "@/components/ui/questionnaire-card";
const questionnaire: QuestionnaireCardData = { id: "dashboard_setup", title: "Dashboard setup", questions: [ { id: "timeframe", prompt: "Which timeframe should the dashboard cover?", choices: [ { value: "last_7_days", label: "Last 7 days" }, { value: "decide_for_me", label: "Decide for me" }, ], defaultValue: "decide_for_me", }, { id: "focus", prompt: "Which areas should it focus on?", multiple: true, allowOther: true, choices: [ { value: "revenue", label: "Revenue" }, { value: "retention", label: "Retention" }, ], }, { id: "notes", prompt: "Anything else the agent should know?" }, ],};
export function AgentQuestions() { return ( <QuestionnaireCard questionnaire={questionnaire} onSubmit={({ questionnaireId, answers, reason }) => { // reason is "submit" or "timeout"; skipped questions carry // their defaultValue (or null). sendToAgent(questionnaireId, answers, reason); }} /> );}Collapse and reopen
Wire collapsed / onCollapsedChange to let users tuck the card away without losing answers — the form stays mounted while hidden.
Dashboard setup
const [collapsed, setCollapsed] = useState(false);
<QuestionnaireCard questionnaire={questionnaire} onSubmit={handleSubmit} collapsed={collapsed} onCollapsedChange={setCollapsed}/>Opt-in timeout
With timeoutMs set, the card auto-submits whatever the user already answered (defaults fill the rest) and reports reason: "timeout". The demo below uses 65 seconds so you can watch the final-minute countdown appear.
Dashboard setup
<QuestionnaireCard questionnaire={questionnaire} onSubmit={handleSubmit} // Opt-in: auto-submit collected answers after 10 minutes idle. // Any interaction resets the countdown; 0 (default) disables it. timeoutMs={600_000}/>Disabled state
While disabled, the card is inert, keyboard submits are blocked, and the timeout is paused — it resumes (and fires if overdue) once re-enabled.
Dashboard setup
Props
| Prop | Type | Description |
|---|---|---|
| questionnaire | QuestionnaireCardData | The questionnaire to render. Changing its id fully resets the card. |
| onSubmit | (submission: QuestionnaireCardSubmission) => void | Called once per questionnaire with { questionnaireId, answers, reason }. |
| timeoutMs | number = 0 | Opt-in idle timeout that auto-submits current answers. 0 disables. |
| disabled | boolean = false | Renders the card inert and pauses the timeout (e.g. while a turn is in flight). |
| collapsed | boolean = false | Shows a reopen pill instead of the card; answers and timers survive. |
| onCollapsedChange | (collapsed: boolean) => void | Enables the × collapse button and the reopen pill. Omit for read-only cards. |
| messages | QuestionnaireCardMessages | Overrides for user-facing status text and placeholders. |
Source
"use client";
// ============================================// QUESTIONNAIRE CARD// ============================================//// Renders a questionnaire as a compact one-question-at-a-time card, designed// to overlay a chat composer while an AI agent waits for clarifications.// Single-choice questions advance on click (the last one submits);// multi-select and free-text questions get explicit Next/Submit pills.// Answers are collected from the form's FormData on submit and overlaid on// each question's defaultValue so skipped questions still carry a sensible// answer.//// The × collapses the card to a reopen pill without submitting; answers and// the auto-submit timer persist across the toggle. The opt-in timeout submits// the current answers (defaults fill the rest) whether open or collapsed, and// resets whenever the user interacts with the form.
import { useCallback, useEffect, useRef, useState } from "react";import { ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, CircleCheckIcon, ClockIcon, MessagesSquareIcon, PencilIcon, XIcon,} from "lucide-react";import { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge";
import { Button } from "@/components/ui/button";import { Questionnaire, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@/components/ui/questionnaire";
// Package-level class merging keeps this file free of consumer aliases like// `@/lib/utils`.function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
// ============================================// TYPES// ============================================
export interface QuestionnaireCardChoice { /** Stable machine value. Used as the answer value. */ value: string; /** Human-readable label shown to the user. */ label: string; /** Optional muted one-liner shown next to the label. */ description?: string;}
export interface QuestionnaireCardQuestion { /** Stable id — becomes the key in the answers record. */ id: string; /** Short, clear question shown as the item title. */ prompt: string; /** Optional help text (visually hidden, exposed to screen readers). */ description?: string; /** If true, the user may pick any subset of choices. */ multiple?: boolean; /** If true, a free-text "other" input is shown alongside the choices. */ allowOther?: boolean; /** Omit for a free-text question. */ choices?: QuestionnaireCardChoice[]; /** Fallback answer used when the question is skipped or times out. */ defaultValue?: string | string[] | null;}
export interface QuestionnaireCardData { /** Unique id for this questionnaire. Changing it resets the card. */ id: string; /** Accessible title (visually hidden). */ title: string; description?: string; questions: QuestionnaireCardQuestion[];}
export type QuestionnaireCardAnswerValue = string | string[] | null;
export type QuestionnaireCardAnswers = Record< string, QuestionnaireCardAnswerValue>;
export type QuestionnaireCardSubmitReason = "submit" | "timeout";
export interface QuestionnaireCardSubmission { questionnaireId: string; answers: QuestionnaireCardAnswers; /** "submit" for user-driven submits, "timeout" for the auto-submit path. */ reason: QuestionnaireCardSubmitReason;}
export interface QuestionnaireCardMessages { /** Reopen pill label while collapsed. Default: "Continue questions". */ continueLabel?: string; /** Countdown notice; `{countdown}` is replaced, e.g. "45s" or "2m". */ autoSubmitNotice?: string; /** Notice shown while `disabled`. Default: "Waiting…". */ disabledNotice?: string; /** Hint appended to multi-select titles. Default: "Select all that apply". */ multipleHint?: string; /** Free-text placeholder when the question also has choices. */ otherPlaceholder?: string; /** Free-text placeholder for questions without choices. */ freeTextPlaceholder?: string; /** aria-label for the free-text input. Default: "Another answer". */ otherInputLabel?: string;}
export interface QuestionnaireCardProps { questionnaire: QuestionnaireCardData; onSubmit: (submission: QuestionnaireCardSubmission) => void; /** Auto-submit timeout in ms. 0 (default) disables the timeout. */ timeoutMs?: number; /** While true, the card is non-interactive and the timeout is paused. */ disabled?: boolean; /** Collapsed shows a reopen pill in place of the card; the form stays * mounted (hidden) so answers and the auto-submit timer survive. */ collapsed?: boolean; /** Enables the × collapse button and the reopen pill. When omitted * (e.g. a read-only transcript card), the × is not rendered. */ onCollapsedChange?: (collapsed: boolean) => void; /** Override user-facing status and placeholder text. */ messages?: QuestionnaireCardMessages; className?: string;}
// Only show the countdown hint in the final minute.const COUNTDOWN_VISIBLE_MS = 60_000;
// Brief pause after a single-choice click so the selection registers visually// before the card advances.const AUTO_ADVANCE_DELAY_MS = 150;
const DEFAULT_MESSAGES = { continueLabel: "Continue questions", autoSubmitNotice: "Auto-submits in {countdown}", disabledNotice: "Waiting…", multipleHint: "Select all that apply", otherPlaceholder: "Or type another answer…", freeTextPlaceholder: "Type your answer…", otherInputLabel: "Another answer",} as const satisfies Required<QuestionnaireCardMessages>;
// Pill-shaped choice row: the letter-shortcut badge leads the row and doubles// as the selection indicator; label + description share one truncating line// and an arrow slides in on hover/selection.const CHOICE_CLASSNAME = cn( "min-h-0 items-center rounded-full border-0 px-2 py-2 dark:bg-transparent", "hover:bg-muted/60 data-checked:bg-muted dark:data-checked:bg-muted", "[&_[data-slot=questionnaire-choice-indicator]]:hidden", "[&_[data-slot=questionnaire-choice-shortcut]]:order-first [&_[data-slot=questionnaire-choice-shortcut]]:ms-0 [&_[data-slot=questionnaire-choice-shortcut]]:size-7 [&_[data-slot=questionnaire-choice-shortcut]]:translate-y-0 [&_[data-slot=questionnaire-choice-shortcut]]:rounded-full [&_[data-slot=questionnaire-choice-shortcut]]:text-[11px]", "data-checked:[&_[data-slot=questionnaire-choice-shortcut]]:border-transparent data-checked:[&_[data-slot=questionnaire-choice-shortcut]]:bg-transparent data-checked:[&_[data-slot=questionnaire-choice-shortcut]]:text-primary", "[&_[data-slot=questionnaire-choice-label]]:flex-row [&_[data-slot=questionnaire-choice-label]]:items-center [&_[data-slot=questionnaire-choice-label]]:gap-2",);
const HEADER_BUTTON_CLASSNAME = "text-muted-foreground min-h-0 size-6 rounded-full sm:min-h-0";
const PILL_BUTTON_CLASSNAME = "min-h-0 rounded-full sm:min-h-0";
function defaultAnswerFor( question: QuestionnaireCardQuestion,): QuestionnaireCardAnswerValue { if (question.defaultValue !== undefined) { return question.defaultValue; } return question.multiple ? [] : null;}
/** Build the answers payload from defaults alone (timeout with no input). */export function buildDefaultAnswers( questionnaire: QuestionnaireCardData,): QuestionnaireCardAnswers { const answers: QuestionnaireCardAnswers = {}; for (const question of questionnaire.questions) { answers[question.id] = defaultAnswerFor(question); } return answers;}
/** * Overlay the submitted FormData on per-question defaults. Choices and the * free-text input share the question id as their field name; entries not * matching a known choice value are free text. Free text is appended for * `multiple` questions and overrides the selection for single-choice ones. */export function collectAnswers( questionnaire: QuestionnaireCardData, formData: FormData,): QuestionnaireCardAnswers { const answers: QuestionnaireCardAnswers = {}; for (const question of questionnaire.questions) { const entries = formData .getAll(question.id) .filter((v): v is string => typeof v === "string" && v.trim().length > 0);
if (entries.length === 0) { answers[question.id] = defaultAnswerFor(question); continue; }
if (!question.choices?.length) { answers[question.id] = entries[0]; continue; }
const choiceValues = new Set(question.choices.map((c) => c.value)); const selected = entries.filter((v) => choiceValues.has(v)); const other = entries.filter((v) => !choiceValues.has(v));
if (question.multiple) { answers[question.id] = [...selected, ...other]; } else { answers[question.id] = other[0] ?? selected[0] ?? null; } } return answers;}
/** True when the question needs explicit Next/Submit (no click-to-advance). */function needsExplicitAdvance(question: QuestionnaireCardQuestion): boolean { return Boolean(question.multiple) || !question.choices?.length;}
export function QuestionnaireCard({ questionnaire, onSubmit, timeoutMs = 0, disabled = false, collapsed = false, onCollapsedChange, messages, className,}: QuestionnaireCardProps) { const text = { ...DEFAULT_MESSAGES, ...messages }; const submittedRef = useRef(false); const formRef = useRef<HTMLFormElement>(null); const autoNextRef = useRef<HTMLButtonElement>(null); const autoAdvanceTimerRef = useRef<number | null>(null); const activeNameRef = useRef<string>(questionnaire.questions[0]?.id ?? ""); const [activeName, setActiveName] = useState<string>( questionnaire.questions[0]?.id ?? "", ); const [otherValues, setOtherValues] = useState<Record<string, string>>({});
const clearAutoAdvance = useCallback(() => { if (autoAdvanceTimerRef.current === null) { return; } window.clearTimeout(autoAdvanceTimerRef.current); autoAdvanceTimerRef.current = null; }, []);
useEffect(() => clearAutoAdvance, [clearAutoAdvance]);
const submitAnswers = useCallback( ( answers: QuestionnaireCardAnswers, reason: QuestionnaireCardSubmitReason, ) => { // The disabled guard backs up the inert wrapper — keyboard-driven // submits (Enter, shortcuts) must not slip through while paused. if (submittedRef.current || disabled) { return; } submittedRef.current = true; onSubmit({ questionnaireId: questionnaire.id, answers, reason }); }, [questionnaire.id, onSubmit, disabled], );
const handleFormSubmit = useCallback( (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); submitAnswers( collectAnswers(questionnaire, new FormData(event.currentTarget)), "submit", ); }, [questionnaire, submitAnswers], );
// Timeout auto-submit: keep whatever the user already selected/typed, // defaults fill only the unanswered questions. Runs open or collapsed — // the hidden form still yields its FormData. const submitWithCurrentAnswers = useCallback(() => { const form = formRef.current; submitAnswers( form ? collectAnswers(questionnaire, new FormData(form)) : buildDefaultAnswers(questionnaire), "timeout", ); }, [questionnaire, submitAnswers]);
// Click-to-advance for single-choice questions: selecting a choice moves to // the next question; on the last question it submits the whole form. const handleChoiceSelected = useCallback( (question: QuestionnaireCardQuestion) => { clearAutoAdvance(); if ( needsExplicitAdvance(question) || otherValues[question.id]?.trim() || disabled ) { return; } const originatingQuestionId = question.id; const timeoutId = window.setTimeout(() => { if (autoAdvanceTimerRef.current !== timeoutId) { return; } autoAdvanceTimerRef.current = null; if ( submittedRef.current || activeNameRef.current !== originatingQuestionId ) { return; } const lastId = questionnaire.questions[questionnaire.questions.length - 1]?.id; if (activeNameRef.current === lastId) { formRef.current?.requestSubmit(); } else { autoNextRef.current?.click(); } }, AUTO_ADVANCE_DELAY_MS); autoAdvanceTimerRef.current = timeoutId; }, [questionnaire, disabled, otherValues, clearAutoAdvance], );
const handleOtherValueChange = useCallback( (questionId: string, value: string) => { setOtherValues((current) => ({ ...current, [questionId]: value })); if (value.trim()) { clearAutoAdvance(); } }, [clearAutoAdvance], );
// ============================================ // AUTO-SUBMIT TIMEOUT (opt-in via timeoutMs > 0) // ============================================ const [remainingMs, setRemainingMs] = useState<number>(timeoutMs); // Seeded in the tick effect (kept out of render — `Date.now()` is impure). const deadlineRef = useRef<number>(0);
// Any form interaction (choice change, typing, question navigation) resets // the deadline. const resetDeadline = useCallback(() => { if (timeoutMs <= 0) { return; } deadlineRef.current = Date.now() + timeoutMs; setRemainingMs(timeoutMs); }, [timeoutMs]);
const handleItemChange = useCallback( (name: string) => { clearAutoAdvance(); activeNameRef.current = name; setActiveName(name); resetDeadline(); }, [resetDeadline, clearAutoAdvance], );
// Replacing the questionnaire (new id) resets every piece of interaction // state: submission guard, active question, free-text values, timers. const previousIdRef = useRef(questionnaire.id); useEffect(() => { if (previousIdRef.current === questionnaire.id) { return; } previousIdRef.current = questionnaire.id; submittedRef.current = false; clearAutoAdvance(); const firstId = questionnaire.questions[0]?.id ?? ""; activeNameRef.current = firstId; setActiveName(firstId); setOtherValues({}); deadlineRef.current = 0; setRemainingMs(timeoutMs); }, [questionnaire, timeoutMs, clearAutoAdvance]);
// Tick the countdown every second. useEffect(() => { if (timeoutMs <= 0) { return; } // Seed the deadline on first run (0 = unset); preserved across `disabled` // toggles so pausing doesn't restart the countdown. if (deadlineRef.current === 0) { deadlineRef.current = Date.now() + timeoutMs; } const id = setInterval(() => { // Pause while disabled; the deadline resumes (and fires if already // past) once the card is interactive again. if (submittedRef.current || disabled) { return; } const remaining = deadlineRef.current - Date.now(); if (remaining <= 0) { submitWithCurrentAnswers(); setRemainingMs(0); return; } setRemainingMs(remaining); }, 1000); return () => clearInterval(id); }, [timeoutMs, submitWithCurrentAnswers, disabled, questionnaire.id]);
const showCountdown = timeoutMs > 0 && remainingMs > 0 && remainingMs <= COUNTDOWN_VISIBLE_MS; const countdownSeconds = Math.max(0, Math.ceil(remainingMs / 1000)); const countdownLabel = countdownSeconds >= 60 ? `${Math.ceil(countdownSeconds / 60)}m` : `${countdownSeconds}s`; const autoSubmitNotice = text.autoSubmitNotice.replace( "{countdown}", countdownLabel, );
const activeIndex = Math.max( 0, questionnaire.questions.findIndex((q) => q.id === activeName), ) + 1;
return ( <div className={cn(disabled && "pointer-events-none opacity-60", className)} aria-disabled={disabled} inert={disabled || undefined} > {collapsed && onCollapsedChange ? ( <div className="pointer-events-auto flex justify-end"> <Button type="button" variant="secondary" size="sm" disabled={disabled} className="rounded-full shadow-lg" onClick={() => onCollapsedChange(false)} > <MessagesSquareIcon data-icon="inline-start" /> {text.continueLabel} <span> {activeIndex} of {questionnaire.questions.length} </span> {showCountdown ? <span>· {autoSubmitNotice}</span> : null} <ChevronUpIcon data-icon="inline-end" /> </Button> </div> ) : null}
{/* `hidden` (not unmount) while collapsed — the form's answers and the running auto-submit timer must survive the toggle. */} <div hidden={collapsed} className="border-border bg-popover relative w-full rounded-2xl border p-4 shadow-xl" > <h4 className="sr-only">{questionnaire.title}</h4>
<Questionnaire items={questionnaire.questions.map((q) => ({ name: q.id, required: false, choices: q.choices?.map((c) => ({ value: c.value })), }))} ref={formRef} shortcuts="letters" onSubmit={handleFormSubmit} onItemChange={handleItemChange} onChange={resetDeadline} className="gap-3" > {/* Progress + navigation cluster, pinned to the card's top-right. */} <div className="absolute top-4 right-4 flex items-center gap-0.5"> <QuestionnairePrevious variant="ghost" size="icon" aria-label="Previous question" disabled={disabled} className={HEADER_BUTTON_CLASSNAME} > <ChevronLeftIcon className="size-3.5" /> </QuestionnairePrevious> <QuestionnaireProgress className="text-muted-foreground min-w-0 px-1 text-xs tabular-nums" render={(props, state) => ( <div {...(props as React.HTMLAttributes<HTMLDivElement>)}> {state.current} of {state.total} </div> )} /> <QuestionnaireNext variant="ghost" size="icon" aria-label="Next question" disabled={disabled} className={HEADER_BUTTON_CLASSNAME} > <ChevronRightIcon className="size-3.5" /> </QuestionnaireNext> {onCollapsedChange ? ( <Button type="button" variant="ghost" size="icon" disabled={disabled} className={HEADER_BUTTON_CLASSNAME} onClick={() => onCollapsedChange(true)} aria-label="Hide questionnaire" > <XIcon className="size-3.5" /> </Button> ) : null} </div>
{/* Invisible Next used by click-to-advance; on the last question the form is submitted directly instead. */} <QuestionnaireNext ref={autoNextRef} tabIndex={-1} aria-hidden disabled={disabled} className="hidden" />
{questionnaire.questions.map((question) => ( <QuestionnaireItem key={question.id} name={question.id} multiple={question.multiple} required={false} className="gap-3" > <QuestionnaireTitle className="flex flex-col gap-0.5 pr-28 text-sm font-semibold lg:flex-row lg:items-end lg:gap-2"> {question.prompt} {question.multiple ? ( <span className="text-muted-foreground text-xs font-normal"> {text.multipleHint} </span> ) : null} </QuestionnaireTitle> {question.description ? ( <QuestionnaireDescription className="sr-only"> {question.description} </QuestionnaireDescription> ) : null} {question.choices?.length ? ( <QuestionnaireChoices className="gap-0.5"> {question.choices.map((choice) => ( <QuestionnaireChoice key={choice.value} value={choice.value} title={ choice.label + (choice.description ? " - " + choice.description : "") } onChange={() => handleChoiceSelected(question)} className={cn( "truncate", CHOICE_CLASSNAME, // Square badges signal checkbox semantics; circles stay // for single choice. question.multiple && "**:data-[slot=questionnaire-choice-shortcut]:rounded-md", )} > <span className="font-medium whitespace-nowrap"> {choice.label} </span> {choice.description ? ( <QuestionnaireChoiceDescription className="truncate"> {choice.description} </QuestionnaireChoiceDescription> ) : null} {question.multiple ? ( <CircleCheckIcon className="text-muted-foreground group-data-checked/questionnaire-choice:text-primary ms-auto me-2 size-4 shrink-0 opacity-0 transition-opacity group-hover/questionnaire-choice:opacity-100 group-data-checked/questionnaire-choice:opacity-100" /> ) : ( <ArrowRightIcon className="text-muted-foreground ms-auto me-2 size-4 shrink-0 opacity-0 transition-opacity group-hover/questionnaire-choice:opacity-100 group-data-checked/questionnaire-choice:opacity-100" /> )} </QuestionnaireChoice> ))} </QuestionnaireChoices> ) : null}
{/* Footer row: free-text input (pencil) + Skip, plus explicit Next/Submit for multi-select and free-text questions. */} <div className="flex items-center gap-2.5 px-2"> {question.allowOther || !question.choices?.length ? ( <> <span aria-hidden="true" className="border-input text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full border" > <PencilIcon className="size-3.5" /> </span> <QuestionnaireInput aria-label={text.otherInputLabel} value={otherValues[question.id] ?? ""} onChange={(event) => handleOtherValueChange(question.id, event.target.value) } className="h-8 min-h-8 border-0 bg-transparent px-0 shadow-none focus-visible:border-0 focus-visible:ring-0 sm:min-h-0 dark:bg-transparent" placeholder={ question.choices?.length ? text.otherPlaceholder : text.freeTextPlaceholder } /> </> ) : ( <div className="min-w-0 flex-1" /> )} <QuestionnaireSkip variant="outline" size="sm" disabled={disabled} className={PILL_BUTTON_CLASSNAME} /> {needsExplicitAdvance(question) || otherValues[question.id]?.trim() ? ( <> <QuestionnaireNext variant="secondary" size="sm" disabled={disabled} className={PILL_BUTTON_CLASSNAME} /> <QuestionnaireSubmit variant="secondary" size="sm" disabled={disabled} className={PILL_BUTTON_CLASSNAME} /> </> ) : null} </div> <QuestionnaireError className="mt-0 px-2 text-xs" /> </QuestionnaireItem> ))} </Questionnaire>
{disabled ? ( <div className="text-muted-foreground mt-2 flex items-center justify-end gap-1.5 text-xs"> <ClockIcon className="size-3" /> <span>{text.disabledNotice}</span> </div> ) : showCountdown ? ( <div className="text-muted-foreground mt-2 flex items-center justify-end gap-1.5 text-xs"> <ClockIcon className="size-3" /> <span>{autoSubmitNotice}</span> </div> ) : null} </div> </div> );}