Project brief questionnaire in a card: one question at a time with a progress bar, letter shortcuts, a conditional team-size step and name validation, ending in an answer summary with Edit answers.
"use client"
import { Fragment, useId, useState, type FormEvent } from "react"
import { cn } from "cn"
import {
Section,
SectionContent,
} from "@/components/editorial/section"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
// The copy: change text here, not in the markup.
const COPY = {
title: "Build your project brief",
question: "Question",
previous: "Back",
skip: "Skip",
next: "Next",
submit: "Finish brief",
nameError: "Use at least three characters for your project name.",
complete: "Your brief is ready",
completeDescription: "Review your answers below, or go back to make changes.",
edit: "Edit answers",
skipped: "Skipped",
}
type QuestionName =
"project" | "people" | "team-size" | "features" | "name" | "timing"
type Answers = Partial<Record<QuestionName, string[]>>
const QUESTIONS: readonly {
name: QuestionName
label: string
title: string
description: string
required: boolean
multiple?: boolean
choices: readonly { value: string; label: string; description?: string }[]
input?: { label: string; placeholder: string }
}[] = [
{
name: "project",
label: "Project",
title: "What are you making?",
description: "Choose a starting point, or describe something else.",
required: true,
choices: [
{
value: "website",
label: "A website",
description: "A home for your work, business or ideas.",
},
{
value: "store",
label: "An online store",
description: "A place to browse products and buy them.",
},
{
value: "app",
label: "An application",
description: "A tool people use to get things done.",
},
],
input: { label: "Another kind of project", placeholder: "Something else…" },
},
{
name: "people",
label: "People",
title: "Who is working on it?",
description: "Team projects get one more question about collaborators.",
required: true,
choices: [
{ value: "solo", label: "Just me" },
{ value: "team", label: "A team" },
],
},
{
name: "team-size",
label: "Team size",
title: "How many people are on your team?",
description: "Include everyone who will work on the project.",
required: true,
choices: [
{ value: "2-5", label: "2–5 people" },
{ value: "6-20", label: "6–20 people" },
{ value: "21+", label: "More than 20 people" },
],
},
{
name: "features",
label: "Priorities",
title: "What does your project need?",
description: "Choose as many as you like, or skip this for now.",
required: false,
multiple: true,
choices: [
{ value: "content", label: "Publishing and content" },
{ value: "payments", label: "Payments and checkout" },
{ value: "accounts", label: "Member accounts" },
{ value: "analytics", label: "Analytics and reporting" },
],
},
{
name: "name",
label: "Project name",
title: "What is your project called?",
description: "A working title is fine. Use at least three characters.",
required: true,
choices: [],
input: { label: "Project name", placeholder: "Give your project a name…" },
},
{
name: "timing",
label: "Timing",
title: "When would you like to get started?",
description: "Choose the timing that fits your plans.",
required: true,
choices: [
{ value: "now", label: "This week" },
{ value: "month", label: "This month" },
{ value: "exploring", label: "I’m still exploring" },
],
},
]
// Each question rises in as it becomes the active one.
const ENTER =
"data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:data-active:animate-none"
export default function QuestionnaireCard01() {
const id = useId()
const [item, setItem] = useState("project")
const [people, setPeople] = useState("")
const [nameError, setNameError] = useState("")
const [answers, setAnswers] = useState<Answers | null>(null)
// The team-size question only applies to a team.
const questions = QUESTIONS.map((question) => ({
...question,
disabled: question.name === "team-size" && people !== "team",
}))
const asked = questions.filter((question) => !question.disabled)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
if (String(data.get("name") ?? "").trim().length < 3) {
setNameError(COPY.nameError)
setItem("name")
return
}
setNameError("")
setAnswers(
Object.fromEntries(
asked.map((question) => [
question.name,
data.getAll(question.name).map(String),
])
)
)
}
return (
<Section>
<SectionContent width="sm">
<Questionnaire
aria-label={COPY.title}
className={cn(answers && "hidden")}
item={item}
items={questions}
shortcuts="letters"
onItemChange={setItem}
onSubmit={handleSubmit}
>
<Card className="mx-auto w-full max-w-lg">
<QuestionnaireProgress
render={(props, state) => (
<CardContent {...props} className="grid gap-2">
<span className="text-xs text-muted-foreground tabular-nums">
{`${COPY.question} ${state.current} / ${state.total}`}
</span>
<Progress
aria-hidden="true"
value={
state.total ? (state.current / state.total) * 100 : 0
}
/>
</CardContent>
)}
/>
{questions.map((question) => (
<QuestionnaireItem
key={question.name}
aria-labelledby={`${id}-${question.name}`}
className={ENTER}
disabled={question.disabled}
invalid={question.name === "name" && Boolean(nameError)}
multiple={question.multiple}
name={question.name}
required={question.required}
>
<CardHeader>
<QuestionnaireTitle
id={`${id}-${question.name}`}
render={<CardTitle />}
>
<h2>{question.title}</h2>
</QuestionnaireTitle>
<QuestionnaireDescription render={<CardDescription />}>
{question.description}
</QuestionnaireDescription>
</CardHeader>
<CardContent>
<QuestionnaireChoices>
{question.choices.map((choice) => (
<QuestionnaireChoice
key={choice.value}
value={choice.value}
onChange={
question.name === "people"
? () => setPeople(choice.value)
: undefined
}
>
<span>{choice.label}</span>
{choice.description && (
<QuestionnaireChoiceDescription>
{choice.description}
</QuestionnaireChoiceDescription>
)}
</QuestionnaireChoice>
))}
{question.input && (
<QuestionnaireInput
aria-label={question.input.label}
placeholder={question.input.placeholder}
onChange={
question.name === "name"
? () => setNameError("")
: undefined
}
/>
)}
</QuestionnaireChoices>
<QuestionnaireError>
{question.name === "name"
? nameError || undefined
: undefined}
</QuestionnaireError>
</CardContent>
</QuestionnaireItem>
))}
<CardFooter>
<QuestionnaireActions>
<QuestionnairePrevious>{COPY.previous}</QuestionnairePrevious>
<QuestionnaireSkip>{COPY.skip}</QuestionnaireSkip>
<QuestionnaireNext>{COPY.next}</QuestionnaireNext>
<QuestionnaireSubmit>{COPY.submit}</QuestionnaireSubmit>
</QuestionnaireActions>
</CardFooter>
</Card>
</Questionnaire>
{answers && (
<Card className="mx-auto w-full max-w-lg">
<CardHeader>
<CardTitle aria-live="polite">
<h2>{COPY.complete}</h2>
</CardTitle>
<CardDescription>{COPY.completeDescription}</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 text-sm @md/section:grid-cols-[auto_1fr]">
{asked.map((question) => (
<Fragment key={question.name}>
<dt className="text-muted-foreground">{question.label}</dt>
<dd>
{answers[question.name]
?.map(
(value) =>
question.choices.find(
(choice) => choice.value === value
)?.label ?? value
)
.join(", ") || COPY.skipped}
</dd>
</Fragment>
))}
</dl>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={() => setAnswers(null)}>
{COPY.edit}
</Button>
</CardFooter>
</Card>
)}
</SectionContent>
</Section>
)
}
pnpm dlx shadcn add @01orbital/questionnaire-card-01npx shadcn add @01orbital/questionnaire-card-01yarn shadcn add @01orbital/questionnaire-card-01bunx --bun shadcn add @01orbital/questionnaire-card-01Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/blocks/questionnaire/questionnaire-card-01/index.tsx, as source you own and edit.
import QuestionnaireCard01 from "@/components/blocks/questionnaire/questionnaire-card-01"
export default function Example() {
return <QuestionnaireCard01 />
}npm: cn. The CLI installs them for you.
Registry pieces it installs with it:
buttoncardprogressquestionnaireSubscription Lifecycle
Subscription card tracing trial, charges, today and next renewal on one rail, with Manage plan and Cancel buttons. Cancelling rewrites the upcoming renewal as an end date. Client component.
Project Brief Questionnaire Dialog
The project brief questionnaire inside a Dialog opened by a Start your brief button: progress, letter shortcuts and a conditional step, with the answer summary and a Done button in the same dialog.