A complete quiz flow block with lockable options, correct/incorrect feedback, explanations, per-question progress, and a final score with retry.
npx shadcn@latest add @paragon/quiz-runnerAlso installs: button
"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>
);
}