A multiple-choice question where the correct option settles green and a wrong one shakes, then reveals an explanation.
npx shadcn@latest add @paragon/quiz-question"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface QuizOption {
id: string;
label: React.ReactNode;
}
export interface QuizQuestionProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
question: React.ReactNode;
options: QuizOption[];
/** Id of the correct option. */
correctId: string;
/** Shown after an answer is chosen. */
explanation?: React.ReactNode;
/** Fires once with whether the chosen option was correct. */
onAnswer?: (correct: boolean, chosenId: string) => void;
/** Suppresses the shake on a wrong answer. */
static?: boolean;
}
/**
* A single quiz question. Choosing an option locks the set: the correct option
* settles green (with a check), a wrong choice shakes once then reveals the
* correct answer, and an explanation slides in. The shake is a keyframe gated
* to a single wrong pick and removed under reduced motion; everything else is
* color/transition.
*/
export function QuizQuestion({
question,
options,
correctId,
explanation,
onAnswer,
static: isStatic = false,
className,
...props
}: QuizQuestionProps) {
const reducedMotion = useReducedMotion();
const shakeOn = !isStatic && !reducedMotion;
const [chosen, setChosen] = React.useState<string | null>(null);
const answered = chosen !== null;
const wasCorrect = chosen === correctId;
const choose = (id: string) => {
if (answered) return;
setChosen(id);
onAnswer?.(id === correctId, id);
};
return (
<div
data-slot="quiz-question"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<style href="paragon-quiz-question" precedence="paragon">{`
@keyframes quiz-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@media (prefers-reduced-motion: reduce) {
[data-quiz-shake] { animation: none !important; }
}
`}</style>
<p className="text-sm font-medium text-balance">{question}</p>
<div role="radiogroup" className="mt-4 flex flex-col gap-2">
{options.map((option) => {
const isCorrect = option.id === correctId;
const isChosen = option.id === chosen;
const revealCorrect = answered && isCorrect;
const revealWrong = answered && isChosen && !isCorrect;
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={isChosen}
disabled={answered}
data-quiz-shake={revealWrong && shakeOn ? "" : undefined}
onClick={() => choose(option.id)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-3.5 py-3 text-left text-sm outline-none transition-[background-color,border-color,color] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
!answered &&
"border-border bg-background hover:bg-accent active:not-disabled:scale-[0.99]",
revealCorrect &&
"border-success/40 bg-success/10 text-foreground",
revealWrong &&
"border-destructive/40 bg-destructive/10 text-foreground",
answered &&
!revealCorrect &&
!revealWrong &&
"border-border bg-background opacity-55",
)}
style={
revealWrong && shakeOn
? { animation: "quiz-shake 400ms var(--ease-in-out)" }
: undefined
}
>
<span className="min-w-0">{option.label}</span>
{revealCorrect && (
<Check aria-hidden className="size-4 shrink-0 text-success" />
)}
{revealWrong && (
<X aria-hidden className="size-4 shrink-0 text-destructive" />
)}
</button>
);
})}
</div>
<AnimatePresence initial={false}>
{answered && explanation && (
<motion.div
key="explanation"
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, height: 0, y: -4, filter: "blur(4px)" }
}
animate={
reducedMotion
? { opacity: 1 }
: { opacity: 1, height: "auto", y: 0, filter: "blur(0px)" }
}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<div
role="status"
className={cn(
"mt-3 rounded-lg p-3 text-sm",
wasCorrect
? "bg-success/10 text-foreground"
: "bg-secondary/60 text-foreground",
)}
>
<p className="mb-0.5 text-xs font-medium">
{wasCorrect ? "Correct" : "Not quite"}
</p>
<p className="text-muted-foreground">{explanation}</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}