# Quiz

> Tap an answer and see at once whether it was right, and which option was. One attempt per question, then a score and Try again.

```tsx
"use client"

import { Quiz, type QuizQuestion } from "@/components/ui/quiz"

// Fictional questions. Nothing here is saved anywhere.
const QUESTIONS: QuizQuestion[] = [
  {
    prompt: "水",
    promptLang: "ja",
    hint: "Which reading is right?",
    options: [
      { label: "みず", lang: "ja" },
      { label: "ひ", lang: "ja" },
      { label: "き", lang: "ja" },
      { label: "やま", lang: "ja" },
    ],
    answer: 0,
  },
  {
    prompt: "What does a smoke detector's steady green light mean?",
    options: [
      "It is powered and working",
      "The battery needs replacing",
      "It has detected smoke",
    ],
    answer: 0,
  },
  {
    prompt: "How often should a fire extinguisher be inspected?",
    hint: "Choose one.",
    options: ["Every week", "Every month", "Every year", "Only after use"],
    answer: 1,
    explanation:
      "A quick visual check each month, with a full service once a year.",
  },
]

export function QuizDemo() {
  return (
    <div className="w-full max-w-xl">
      <Quiz questions={QUESTIONS} />
    </div>
  )
}
```

Use it where a person checks what they just learned: a vocabulary check after a lesson, a refresher after a policy change. Each question is a set of buttons. A tap shows right or wrong straight away, marks the right option, and locks that question. When every question is answered the footer shows the score and a Try again button.

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/quiz.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/quiz
```

This also adds the shadcn `button` and the [`quiz-lib`](https://realgood.site/r/quiz-lib.json) helpers, and installs `lucide-react`.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/ui/quiz.tsx"
"use client"

import * as React from "react"
import { Check, RotateCcw, X } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  emptyPicks,
  isCorrect,
  optionLetter,
  tally,
  type QuizTally,
} from "@/lib/quiz"
import { Button } from "@/components/ui/button"

export interface QuizOption {
  label: React.ReactNode
  /** Language of `label` when it differs from the page, e.g. "ja". */
  lang?: string
}

export interface QuizQuestion {
  /** The thing being asked about. Shown large. */
  prompt: React.ReactNode
  /** Language of `prompt` when it differs from the page. */
  promptLang?: string
  /** A quieter line (or two) under the prompt, e.g. the question in words. */
  hint?: React.ReactNode
  /** Two or more. A plain string is the same as `{ label: string }`. */
  options: Array<string | QuizOption>
  /** Index into `options` of the one right answer. */
  answer: number
  /** Shown under the result once the question is answered. */
  explanation?: React.ReactNode
}

export type QuizResult = QuizTally

type OptionState = "idle" | "correct" | "wrong" | "answer" | "other"

// Tints are `color-mix` on the token, not `bg-primary/10`: an alpha modifier
// compiles to nothing where a theme stores colours as bare `var(--x)` values.
// `--destructive-ink` is used when a theme defines one (a darker red for text
// and borders) and `--destructive` otherwise. The classes are written out in
// full because Tailwind cannot see a class assembled from a string.
const OPTION_STYLES: Record<OptionState, string> = {
  idle: "border-border bg-background hover:bg-muted",
  // A ring, not a tint: a tint of a dark primary over a warm page reads as grey,
  // which looks disabled rather than right.
  correct: "border-primary bg-background ring-1 ring-primary",
  wrong:
    "border-[color:var(--destructive-ink,var(--destructive))] bg-[color-mix(in_srgb,var(--destructive)_8%,var(--background))]",
  answer: "border-primary bg-background",
  other: "border-border bg-background opacity-60",
}

function normalise(option: string | QuizOption): QuizOption {
  return typeof option === "string" ? { label: option } : option
}

export interface QuizCardProps {
  question: QuizQuestion
  /** The index the person picked, or `null` before they answer. */
  picked: number | null
  /** Called with the index of an option. Not called again once answered. */
  onPick: (index: number) => void
  /** 1-based position, shown as "1 / 3" when `total` is given too. */
  number?: number
  total?: number
}

/**
 * One question. Controlled: it shows `picked` and tells you about a tap, and
 * keeps no state of its own. Use `Quiz` for a whole set.
 */
function QuizCard({ question, picked, onPick, number, total }: QuizCardProps) {
  const promptId = React.useId()
  const answered = picked !== null
  const right = isCorrect(question, picked)
  const options = question.options.map(normalise)
  const answerLetter = optionLetter(question.answer)

  const stateOf = (index: number): OptionState => {
    if (!answered) return "idle"
    if (index === picked) return right ? "correct" : "wrong"
    if (index === question.answer) return "answer"
    return "other"
  }

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-1">
        {number !== undefined && total !== undefined && (
          <p className="text-xs font-medium text-muted-foreground tabular-nums">
            {number} / {total}
          </p>
        )}
        <p
          id={promptId}
          lang={question.promptLang}
          className="text-xl font-semibold tracking-tight [word-break:keep-all] sm:text-2xl"
        >
          {question.prompt}
        </p>
        {question.hint && (
          <div className="text-sm text-muted-foreground">{question.hint}</div>
        )}
      </div>

      <div
        role="group"
        aria-labelledby={promptId}
        className="grid gap-2 sm:grid-cols-[repeat(auto-fit,minmax(9rem,1fr))]"
      >
        {options.map((option, index) => {
          const state = stateOf(index)
          const mark =
            state === "correct" || state === "answer" ? (
              <Check className="size-3.5" />
            ) : state === "wrong" ? (
              <X className="size-3.5" />
            ) : (
              optionLetter(index)
            )
          return (
            <button
              key={index}
              type="button"
              data-quiz-option=""
              data-state={state}
              aria-pressed={picked === index}
              aria-disabled={answered || undefined}
              onClick={() => {
                if (!answered) onPick(index)
              }}
              className={cn(
                "flex w-full items-center gap-3 rounded-lg border px-3.5 py-3 text-left text-base outline-none",
                "transition-colors motion-reduce:transition-none",
                "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                answered ? "cursor-default" : "cursor-pointer",
                OPTION_STYLES[state]
              )}
            >
              <span
                aria-hidden="true"
                className={cn(
                  "grid size-6 shrink-0 place-items-center rounded-md border text-xs font-semibold",
                  state === "correct" || state === "answer"
                    ? "border-primary bg-primary text-primary-foreground"
                    : state === "wrong"
                      ? "border-destructive bg-destructive text-destructive-foreground"
                      : "border-border text-muted-foreground"
                )}
              >
                {mark}
              </span>
              <span lang={option.lang} className="[word-break:keep-all]">
                {option.label}
              </span>
            </button>
          )
        })}
      </div>

      {/* Always mounted, so the result is announced when it fills in. The
          fixed height keeps the page from jumping when it does. */}
      <div role="status" className="min-h-6 text-sm">
        {answered && (
          <div className="flex flex-col gap-1">
            <p
              className={cn(
                "font-medium",
                right
                  ? "text-foreground"
                  : "text-[color:var(--destructive-ink,var(--destructive))]"
              )}
            >
              {right ? "Correct" : `Not quite. The answer is ${answerLetter}.`}
            </p>
            {question.explanation && (
              <div className="text-muted-foreground">
                {question.explanation}
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  )
}

export interface QuizProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  questions: QuizQuestion[]
  /** Called once, when the last question is answered. */
  onComplete?: (result: QuizResult) => void
}

/**
 * A set of questions answered by tapping. Each tap shows at once whether it was
 * right and which option was, then locks that question. When every question is
 * answered the footer shows the score and a Try again button.
 */
function Quiz({ questions, onComplete, className, ...props }: QuizProps) {
  const [picks, setPicks] = React.useState<(number | null)[]>(() =>
    emptyPicks(questions)
  )
  const rootRef = React.useRef<HTMLDivElement>(null)
  // Set by Try again: the button that had focus unmounts, so focus moves to
  // the first option once the picks have reset.
  const refocus = React.useRef(false)

  // `questions` can change under the quiz (a new episode, a reshuffle); a pick
  // for a question that is no longer there would grade the wrong one. Done
  // while rendering, not in an effect, so the stale picks are never painted.
  if (picks.length !== questions.length) setPicks(emptyPicks(questions))

  const result = tally(questions, picks)

  React.useEffect(() => {
    if (!refocus.current || picks.some((pick) => pick !== null)) return
    refocus.current = false
    rootRef.current?.querySelector<HTMLElement>("[data-quiz-option]")?.focus()
  }, [picks])

  const pick = (question: number, option: number) => {
    const next = picks.map((p, i) => (i === question ? option : p))
    setPicks(next)
    const after = tally(questions, next)
    if (after.complete) onComplete?.(after)
  }

  return (
    <div ref={rootRef} className={cn("flex flex-col", className)} {...props}>
      <ol className="overflow-hidden rounded-xl border bg-card">
        {questions.map((question, i) => (
          <li key={i} className="border-b p-5 sm:p-6">
            <QuizCard
              question={question}
              number={i + 1}
              total={questions.length}
              picked={picks[i] ?? null}
              onPick={(option) => pick(i, option)}
            />
          </li>
        ))}
        <li className="flex min-h-14 items-center justify-between gap-3 bg-muted px-5 py-2 text-sm sm:px-6">
          <span className="whitespace-nowrap tabular-nums">
            {result.complete ? (
              <span className="font-medium">
                {result.correct} of {result.total} correct
              </span>
            ) : (
              <span className="text-muted-foreground">
                {result.answered} of {result.total} answered
              </span>
            )}
          </span>
          {result.complete && (
            <Button
              variant="outline"
              size="sm"
              onClick={() => {
                refocus.current = true
                setPicks(emptyPicks(questions))
              }}
            >
              <RotateCcw />
              Try again
            </Button>
          )}
        </li>
      </ol>
      <span role="status" className="sr-only">
        {result.complete
          ? `You got ${result.correct} of ${result.total} correct`
          : ""}
      </span>
    </div>
  )
}

export { Quiz, QuizCard }
```

**Step 2.** Add the helpers. They have no React and no dependencies, so you can import
them in a test or on your server too.

```ts title="lib/quiz.ts"
/**
 * Pure helpers for the quiz pattern: grade one pick, tally a set of picks, and
 * check that a question set is well formed. No React and no network, so they
 * are safe to unit test and to run on a server.
 *
 * A pick is the index of the chosen option, or `null` while unanswered. Every
 * helper returns a new value and never changes what it is given.
 */

/** The part of a question the helpers need. The component's `QuizQuestion` fits it. */
export interface GradableQuestion {
  options: readonly unknown[]
  /** Index into `options` of the one right answer. */
  answer: number
}

export interface QuizTally {
  total: number
  /** Questions with a pick. */
  answered: number
  /** Picks that matched the answer. */
  correct: number
  /** Every question has a pick. An empty quiz is never complete. */
  complete: boolean
}

/** "A" for 0, "B" for 1, and so on. Wraps to "AA" after "Z". */
export function optionLetter(index: number): string {
  if (!Number.isInteger(index) || index < 0) return ""
  let n = index
  let letter = ""
  do {
    letter = String.fromCharCode(65 + (n % 26)) + letter
    n = Math.floor(n / 26) - 1
  } while (n >= 0)
  return letter
}

/** True when the pick is the answer. An unanswered pick is never correct. */
export function isCorrect(
  question: Pick<GradableQuestion, "answer">,
  pick: number | null
): boolean {
  return pick !== null && pick === question.answer
}

/**
 * Count answered and correct picks. `picks` is read by position; a missing
 * entry counts as unanswered, and extra entries are ignored.
 */
export function tally(
  questions: readonly GradableQuestion[],
  picks: readonly (number | null)[]
): QuizTally {
  let answered = 0
  let correct = 0
  questions.forEach((question, i) => {
    const pick = picks[i] ?? null
    if (pick === null) return
    answered += 1
    if (isCorrect(question, pick)) correct += 1
  })
  return {
    total: questions.length,
    answered,
    correct,
    complete: questions.length > 0 && answered === questions.length,
  }
}

/** A fresh, all-unanswered pick list for `questions`. */
export function emptyPicks(questions: readonly unknown[]): null[] {
  // Annotated so a project without `strictNullChecks` still gets `null[]`, not `any[]`.
  return questions.map((): null => null)
}

/**
 * Problems that would make a question unanswerable or ambiguous, one message
 * per problem, each naming the question (1-based). An empty list means the set
 * is fine. Run it in a test or at build time against your own data.
 */
export function checkQuestions(
  questions: readonly GradableQuestion[]
): string[] {
  const problems: string[] = []
  questions.forEach((question, i) => {
    const name = `Question ${i + 1}`
    if (question.options.length < 2) {
      problems.push(`${name}: needs at least two options`)
    }
    if (
      !Number.isInteger(question.answer) ||
      question.answer < 0 ||
      question.answer >= question.options.length
    ) {
      problems.push(`${name}: answer ${question.answer} is not an option`)
    }
    const labels = question.options.filter(
      (option): option is string => typeof option === "string"
    )
    const seen = new Set<string>()
    for (const label of labels) {
      const key = label.trim().toLowerCase()
      if (seen.has(key)) {
        problems.push(
          `${name}: "${label}" appears twice, so two options are right`
        )
      }
      seen.add(key)
    }
  })
  return problems
}
```

**Step 3.** Add the shadcn `button` and install `lucide-react`. Update the import paths to
match your project setup.

## Usage

```tsx
import { Quiz } from "@/components/ui/quiz"
```

```tsx
<Quiz
  questions={[
    {
      prompt: "水",
      promptLang: "ja",
      hint: "Which reading is right?",
      options: [
        { label: "みず", lang: "ja" },
        { label: "ひ", lang: "ja" },
        { label: "き", lang: "ja" },
      ],
      answer: 0,
    },
    {
      prompt: "How often is the extinguisher inspected?",
      options: ["Every week", "Every month", "Every year"],
      answer: 1,
      explanation: "A quick check each month, a full service each year.",
    },
  ]}
  onComplete={(result) => save(result.correct, result.total)}
/>
```

## Notes

- **One attempt per question.** The first tap decides it. The question then locks, the picked option shows a check (right) or a cross (wrong), the right option is marked, and the rest fade. The result is written as text too: "Correct" or "Not quite. The answer is B." Colour is never the only signal.
- **Colours use `color-mix`, not alpha modifiers.** A wrong answer's tint is written as `bg-[color-mix(in_srgb,var(--destructive)_8%,var(--background))]`, not `bg-destructive/8`, and the right answer uses a ring instead of a tint. Some themes store colours as bare `var(--x)` values, and Tailwind compiles an alpha modifier on those to nothing, so the tint would silently disappear.
- **`--destructive-ink` is optional.** Wrong-answer text and border use `--destructive-ink` when your theme defines it (a darker red that reads on a light background) and fall back to `--destructive` when it does not.
- **`lang` and `promptLang`.** Set `promptLang` on a question and `lang` on an option written in another language, for example `"ja"`. They set the `lang` attribute so screen readers pronounce the text correctly and the browser picks the right font. Text in such a language is also kept from breaking mid-word.
- **Focus.** After Try again the button unmounts, so focus moves to the first option of the first question. Tab, Enter and Space work as on any button.
- **`onComplete`.** Called once, with `{ total, answered, correct, complete }`, when the last question is answered. It is not called again until the person tries again and finishes again.
- **No persistence, no timer, no shuffling.** Nothing is stored. There is no time limit. Options appear in the order you give them, so shuffle on your side (once, not on every render) and set `answer` to the shuffled position.
- **Check your own questions.** `checkQuestions` in `quiz-lib` lists problems that make a question unanswerable or ambiguous: fewer than two options, an `answer` that is not an option, and two options with the same text. Run it in a test against your own data.
- **Fixed wording.** "Correct", "Not quite. The answer is B.", "N of M correct", "N of M answered" and "Try again" are not configurable. Copy the file if you need other words.
- **Client component.** The questions render in the server HTML as buttons, but they need JavaScript to be answered.

## What it does not do

- **It does not store results.** It keeps picks in memory only, so a reload starts again. Save the score yourself from `onComplete`.
- **It does not shuffle, time or limit attempts.** One try per question per run; Try again clears the whole set.
- **It does not check your data at runtime.** A wrong `answer` index is not caught while the page runs. Use `checkQuestions` in a test.
- **It is not an assessment.** It is a self-check with instant feedback. It does not prove that anyone learned or understood anything, and it makes no compliance claim.
- **Its wording is fixed.** Copy the file to change it.

## Props

### Quiz

Also accepts the props of a `div`, including `className` and `ref`. The prop types are exported as `QuizProps`.

| Prop         | Type                           | Description                                                                |
| ------------ | ------------------------------ | -------------------------------------------------------------------------- |
| `questions`  | `QuizQuestion[]`               | The questions, in the order shown. Required.                               |
| `onComplete` | `(result: QuizResult) => void` | Called once when the last question is answered, with the totals. Optional. |

### QuizQuestion

| Field         | Type                       | Description                                                         |
| ------------- | -------------------------- | ------------------------------------------------------------------- |
| `prompt`      | `ReactNode`                | The thing asked about, shown large. Required.                       |
| `promptLang`  | `string`                   | Language of the prompt when it differs from the page, e.g. `"ja"`.  |
| `hint`        | `ReactNode`                | A quieter line under the prompt.                                    |
| `options`     | `(string \| QuizOption)[]` | Two or more. A string is the same as `{ label: string }`. Required. |
| `answer`      | `number`                   | Index into `options` of the one right answer. Required.             |
| `explanation` | `ReactNode`                | Shown under the result once the question is answered.               |

`QuizOption` is `{ label: ReactNode; lang?: string }`.

### QuizCard

One controlled question, for when you lay the set out yourself. It keeps no state. Prop types are exported as `QuizCardProps`.

| Prop       | Type                      | Description                                                      |
| ---------- | ------------------------- | ---------------------------------------------------------------- |
| `question` | `QuizQuestion`            | The question. Required.                                          |
| `picked`   | `number \| null`          | The index picked, or `null` before it is answered. Required.     |
| `onPick`   | `(index: number) => void` | Called with an option index. Not called once answered. Required. |
| `number`   | `number`                  | 1-based position, shown as "1 / 3" when `total` is set too.      |
| `total`    | `number`                  | The number of questions, for the "1 / 3" label.                  |

## Agent prompt

Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.

```text
Goal: add a tap-to-answer quiz with instant right or wrong feedback to my [lesson, refresher or onboarding page].

Install: npx shadcn@latest add https://realgood.site/r/quiz.json
Read the installed files (components/ui/quiz.tsx, lib/quiz.ts) before writing any code. Do not guess props.

Contract:
<Quiz questions={QuizQuestion[]} onComplete?={(result) => void} />  // also takes div props
QuizQuestion = { prompt, promptLang?, hint?, options: (string | { label, lang? })[], answer: number, explanation? }
<QuizCard question picked onPick number? total? />  // one controlled question
The wording is fixed: "Correct", "Not quite. The answer is B.", "N of M correct", "Try again".

Wiring rule: my questions are plain data. `answer` is an index into `options`. If I shuffle options, I shuffle once (not on every render) and set `answer` to the new position. Set promptLang / option lang for text in another language, such as "ja". Save the score myself in onComplete, because the quiz stores nothing. Write one test that runs checkQuestions from lib/quiz.ts against my real questions and expects [].

States to handle: unanswered, answered right, answered wrong (the right option is marked), all answered (score and Try again), Try again (focus moves to the first option).

Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. checkQuestions(myQuestions) returns [].
3. Tapping a wrong option shows the cross, marks the right one and says which letter was right. The question then ignores further taps.
4. After the last question the footer shows "N of M correct" and onComplete ran exactly once.
5. Keyboard: Tab reaches each option, Enter or Space answers it, and Try again puts focus on the first option.

UI only: it does not persist results, time, shuffle or prove anyone learned anything, and it makes no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.
```

## Examples by sector

Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.

- **Education.** A vocabulary check after a lesson: three words, each with four readings or meanings, and a score at the end.
- **Manufacturing.** A safety sign-off check before a shift: which sign means hearing protection is required, what to do when a guard is missing.
- **Engineering.** A refresher on the naming rule for drawing revisions before someone files a change order.
- **Health.** A policy refresher on how long to keep a record before review. Staff policy only; no patient data in the example.

## Next

Comes after: [`confirm-send`](https://realgood.site/docs/components/confirm-send.md), [`notify-envelope`](https://realgood.site/docs/components/notify-envelope.md). Leads to: [`audit-timeline`](https://realgood.site/docs/components/audit-timeline.md).
