- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
What would help you focus at work?
Single selection
Select an option to move to the next step. An optional free-text answer can be submitted with Enter.
When should your day begin?
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>
);
} On this page