# Project Brief Questionnaire Card

> 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.

A shadcn/ui block from the [@01orbital](https://01orbital.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/01orbital/questionnaire-card-01).

- Page, with a live preview: https://shadcn.exchange/r/01orbital/questionnaire-card-01
- Screenshot: https://shadcn.exchange/shot/01orbital/questionnaire-card-01/light.png
- Registry item: https://01orbital.dev/r/questionnaire-card-01.json
- Categories: ui-elements
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/01orbital/questionnaire-card-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `cn`
- Registry items: `@01orbital/button`, `card`, `@01orbital/progress`, `@01orbital/questionnaire`, `@01orbital/section`

## Files

### `blocks/questionnaire/questionnaire-card-01/index.tsx`

```tsx
"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>
  )
}
```
