Quiz

Interactive multiple-choice quiz with hints, explanations, and scoring.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/quiz.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import { type ReactNode, useState } from "react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; export type QuizOption = { correct?: boolean; explanation?: string; label: string; }; type QuizOptionButtonProps = { index: number; onSelect: (index: number) => void; option: QuizOption; selectedIndex: null | number; submitted: boolean; }; // eslint-disable-next-line max-lines-per-function -- Option button showing selected, correct, incorrect states function QuizOptionButton({ index, onSelect, option, selectedIndex, submitted, }: QuizOptionButtonProps): React.ReactNode { const isSelected = selectedIndex === index; const showResult = submitted && isSelected; return ( <button className={cn( "w-full text-left p-3 rounded-md border transition-colors hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary", isSelected && !submitted && "border-primary bg-primary/10", showResult && option.correct && "border-green-500 bg-green-500/10", showResult && !option.correct && "border-red-500 bg-red-500/10", submitted && !isSelected && option.correct && "border-green-500/50 bg-green-500/5", )} disabled={submitted} onClick={() => { onSelect(index); }} type="button" > <div className="flex items-center justify-between"> <span className="text-sm">{option.label}</span> {showResult ? ( option.correct ? ( <svg className="size-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) : ( <svg className="size-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) ) : null} {submitted && !isSelected && option.correct ? ( <svg className="size-4 text-green-500/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) : null} </div> {submitted && option.explanation ? ( <p className="text-xs text-muted-foreground mt-2"> {option.explanation} </p> ) : null} </button> ); } type QuizHintProps = { hint: string; onShow: () => void; showHint: boolean; }; function QuizHint({ hint, onShow, showHint }: QuizHintProps): React.ReactNode { return ( <div className="mb-4"> {showHint ? ( <p className="text-sm text-muted-foreground italic bg-muted/50 p-2 rounded"> {hint} </p> ) : ( <button className="text-sm text-primary hover:underline" onClick={onShow} type="button" > Show hint </button> )} </div> ); } type QuizResultProps = { explanation: ReactNode; isCorrect: boolean; }; function QuizResult({ explanation, isCorrect, }: QuizResultProps): React.ReactNode { return ( <div className={cn( "p-3 rounded-md text-sm mb-4", isCorrect ? "bg-green-500/10 text-green-700 dark:text-green-300" : "bg-muted", )} > <p className="font-medium mb-1"> {isCorrect ? "Correct!" : "Not quite right."} </p> <div className="[&>p]:mb-0">{explanation}</div> </div> ); } export type QuizProps = { /** Heading tag for the question. Defaults to `h4`. */ as?: HeadingTag; className?: string; explanation?: ReactNode; hint?: string; onAnswer?: (correct: boolean) => void; options: QuizOption[]; question: string; }; // eslint-disable-next-line max-lines-per-function -- Interactive quiz with state management export function Quiz({ as: Heading = "h4", className, explanation, hint, onAnswer, options, question, }: QuizProps): React.ReactNode { const [selectedIndex, setSelectedIndex] = useState<null | number>(null); const [showHint, setShowHint] = useState(false); const [submitted, setSubmitted] = useState(false); const handleReset = (): void => { setSelectedIndex(null); setSubmitted(false); setShowHint(false); }; const handleSubmit = (): void => { setSubmitted(true); const isCorrect = selectedIndex !== null && options[selectedIndex]?.correct; onAnswer?.(Boolean(isCorrect)); }; const isCorrect = selectedIndex !== null && options[selectedIndex]?.correct; return ( <div className={cn("my-6 rounded-lg border bg-card p-6", className)}> <div className="flex items-start gap-3 mb-4"> <svg className="size-5 text-primary flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> <Heading className="font-semibold text-foreground">{question}</Heading> </div> <div className="space-y-2 mb-4"> {options.map((opt, index) => ( <QuizOptionButton index={index} key={`quiz-option-${index}-${opt.label}`} onSelect={(index_) => { if (!submitted) setSelectedIndex(index_); }} option={opt} selectedIndex={selectedIndex} submitted={submitted} /> ))} </div> {hint && !submitted ? ( <QuizHint hint={hint} onShow={() => { setShowHint(true); }} showHint={showHint} /> ) : null} {submitted && explanation ? ( <QuizResult explanation={explanation} isCorrect={Boolean(isCorrect)} /> ) : null} <div className="flex gap-2"> {submitted ? ( <button className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors" onClick={handleReset} type="button" > Try Again </button> ) : ( <button className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-colors" disabled={selectedIndex === null} onClick={handleSubmit} type="button" > Check Answer </button> )} </div> </div> ); }

Dependencies

  • @vllnt/ui@^0.2.1