EdTech

Quiz Runner

A complete quiz flow block with lockable options, correct/incorrect feedback, explanations, per-question progress, and a final score with retry.

Install

npx shadcn@latest add @paragon/quiz-runner

Also installs: button

quiz-runner.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, X, Trophy, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

export interface QuizItem {
  question: string;
  options: string[];
  /** Index of the correct option. */
  answer: number;
  /** Shown after answering. */
  explanation?: string;
}

export interface QuizRunnerProps extends React.ComponentProps<"div"> {
  title?: string;
  questions?: QuizItem[];
}

const DEFAULT_QUESTIONS: QuizItem[] = [
  {
    question: "Which HTTP status code means the request succeeded?",
    options: ["301 Moved", "200 OK", "404 Not Found", "500 Server Error"],
    answer: 1,
    explanation: "2xx codes signal success; 200 OK is the standard response.",
  },
  {
    question: "What does the `useMemo` hook help you avoid?",
    options: [
      "Stale closures",
      "Prop drilling",
      "Expensive recomputation",
      "Network requests",
    ],
    answer: 2,
    explanation:
      "useMemo caches a computed value between renders unless its deps change.",
  },
  {
    question: "In Big-O, which grows fastest as n increases?",
    options: ["O(log n)", "O(n)", "O(n log n)", "O(n²)"],
    answer: 3,
    explanation: "Quadratic time outpaces linear and log-linear growth.",
  },
];

/**
 * A complete quiz flow: question, selectable options that lock and reveal
 * correct/incorrect on submit (spring-animated feedback icon, reduced-motion
 * safe), an explanation, per-question progress, and a final score screen with
 * retry. Deterministic — no randomness at render.
 */
export function QuizRunner({
  title = "Web Fundamentals · Checkpoint",
  questions = DEFAULT_QUESTIONS,
  className,
  ...props
}: QuizRunnerProps) {
  const reduce = useReducedMotion();
  const [index, setIndex] = React.useState(0);
  const [selected, setSelected] = React.useState<number | null>(null);
  const [locked, setLocked] = React.useState(false);
  const [score, setScore] = React.useState(0);
  const [done, setDone] = React.useState(false);

  const q = questions[index];
  const isLast = index === questions.length - 1;

  function submit() {
    if (selected == null) return;
    setLocked(true);
    if (selected === q.answer) setScore((s) => s + 1);
  }

  function next() {
    if (isLast) {
      setDone(true);
      return;
    }
    setIndex((i) => i + 1);
    setSelected(null);
    setLocked(false);
  }

  function restart() {
    setIndex(0);
    setSelected(null);
    setLocked(false);
    setScore(0);
    setDone(false);
  }

  return (
    <div
      data-slot="quiz-runner"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between">
        <h3 className="text-sm font-medium">{title}</h3>
        {!done && (
          <span className="text-xs text-muted-foreground tabular-nums">
            {index + 1} / {questions.length}
          </span>
        )}
      </div>

      <div className="mt-2 h-1 overflow-hidden rounded-full bg-secondary">
        <div
          className="h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-out"
          style={{
            transform: `scaleX(${
              done ? 1 : (index + (locked ? 1 : 0)) / questions.length
            })`,
          }}
        />
      </div>

      <AnimatePresence mode="wait" initial={false}>
        {done ? (
          <motion.div
            key="result"
            initial={reduce ? false : { opacity: 0, y: 8, filter: "blur(4px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
            className="mt-6 flex flex-col items-center py-4 text-center"
          >
            <span className="flex size-12 items-center justify-center rounded-full bg-success/10 text-success">
              <Trophy className="size-6" />
            </span>
            <div className="mt-3 text-2xl font-semibold tabular-nums">
              {score} / {questions.length}
            </div>
            <p className="mt-1 text-sm text-muted-foreground">
              {score === questions.length
                ? "Perfect score — nicely done."
                : `You got ${Math.round(
                    (score / questions.length) * 100,
                  )}% correct.`}
            </p>
            <Button variant="outline" className="mt-4" onClick={restart}>
              <RotateCcw /> Try again
            </Button>
          </motion.div>
        ) : (
          <motion.div
            key={index}
            initial={reduce ? false : { opacity: 0, y: 8, filter: "blur(4px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={reduce ? undefined : { opacity: 0, y: -8, filter: "blur(4px)" }}
            transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
            className="mt-4"
          >
            <p className="text-sm font-medium text-balance">{q.question}</p>

            <div className="mt-3 space-y-2">
              {q.options.map((opt, i) => {
                const isCorrect = i === q.answer;
                const isPicked = i === selected;
                const showState = locked && (isCorrect || isPicked);
                return (
                  <button
                    key={opt}
                    type="button"
                    disabled={locked}
                    onClick={() => setSelected(i)}
                    aria-pressed={isPicked}
                    className={cn(
                      "flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-[background-color,border-color] duration-150 ease-out",
                      "disabled:cursor-default",
                      !locked &&
                        isPicked &&
                        "border-primary bg-secondary/60",
                      !locked &&
                        !isPicked &&
                        "border-border hover:bg-secondary/40",
                      showState &&
                        isCorrect &&
                        "border-success bg-success/10 text-success-foreground",
                      showState &&
                        !isCorrect &&
                        isPicked &&
                        "border-destructive bg-destructive/10",
                      locked && !showState && "border-border opacity-60",
                    )}
                  >
                    <span
                      className={cn(
                        "flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium",
                        showState && isCorrect && "border-success bg-success text-success-foreground",
                        showState && !isCorrect && isPicked && "border-destructive bg-destructive text-destructive-foreground",
                        (!showState) && "border-border text-muted-foreground",
                      )}
                    >
                      {showState && isCorrect ? (
                        <Check className="size-3" />
                      ) : showState && isPicked ? (
                        <X className="size-3" />
                      ) : (
                        String.fromCharCode(65 + i)
                      )}
                    </span>
                    <span className="min-w-0 flex-1">{opt}</span>
                  </button>
                );
              })}
            </div>

            {locked && q.explanation && (
              <motion.p
                initial={reduce ? false : { opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                className="mt-3 rounded-lg bg-secondary/50 p-3 text-xs text-muted-foreground"
              >
                {q.explanation}
              </motion.p>
            )}

            <div className="mt-4 flex justify-end">
              {locked ? (
                <Button onClick={next}>
                  {isLast ? "See results" : "Next question"}
                </Button>
              ) : (
                <Button disabled={selected == null} onClick={submit}>
                  Check answer
                </Button>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}