Quiz Question
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 { 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>
  );
}