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 { 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 — a 4-leg keyframe
* with per-segment easing, orthogonal to the error styling so the tint never
* flickers — then the correct answer reveals, and an explanation opens via a
* grid-rows expand. The shake is 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 questionId = React.useId();
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% { transform: translateX(0); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
25% { transform: translateX(-6px); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
50% { transform: translateX(5px); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
75% { transform: translateX(-3px); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
100% { transform: translateX(0); }
}
@keyframes quiz-explanation-enter {
from { grid-template-rows: 0fr; opacity: 0; filter: blur(4px); }
to { grid-template-rows: 1fr; opacity: 1; filter: blur(0); }
}
[data-quiz-shake] { animation: quiz-shake 350ms 1 both; }
[data-quiz-explanation] {
animation: quiz-explanation-enter 300ms var(--ease-out) both;
}
@media (prefers-reduced-motion: reduce) {
[data-quiz-shake],
[data-quiz-explanation] { animation: none !important; }
}
`}</style>
<p id={questionId} className="text-sm font-medium text-balance">
{question}
</p>
<div
role="radiogroup"
aria-labelledby={questionId}
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",
)}
>
<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>
{answered && explanation && (
// Grid-rows expand (the sanctioned height exception); it mounts once
// and never exits, so a keyframe is safe here.
<div data-quiz-explanation="" className="grid grid-rows-[1fr]">
<div 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={cn(
"mb-0.5 text-xs font-medium",
wasCorrect && "text-success",
)}
>
{wasCorrect ? "Correct" : "Not quite"}
</p>
<p className="text-muted-foreground">{explanation}</p>
</div>
</div>
</div>
)}
</div>
);
}