Feedback

Quiz Question

A multiple-choice question where the correct option settles green and a wrong one shakes, then reveals an explanation.

Install

npx shadcn@latest add @paragon/quiz-question

quiz-question.tsx

"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>
  );
}