Knowledge Check
Inline knowledge check with multiple-choice / true-false / fill-blank questions, per-answer feedback, and a final score summary.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/knowledge-check.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook3 stories available:
Code
"use client";
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { CheckCircle2, RotateCcw, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../button/button";
import { Input } from "../input/input";
/**
* Question kind for {@link KnowledgeCheckQuestion}.
*
* @public
*/
export type KnowledgeCheckQuestionType =
| "fill-blank"
| "multiple-choice"
| "true-false";
/**
* Localizable strings.
*
* @public
*/
export type KnowledgeCheckLabels = {
/** Caption for the previous-question button. Defaults to `"Back"`. */
back?: string;
/** Caption for the check-answer button. Defaults to `"Check"`. */
check?: string;
/** Caption for the correct-answer feedback. Defaults to `"Correct"`. */
correct?: string;
/** Caption for the false option. Defaults to `"False"`. */
falseOption?: string;
/** Caption for the incorrect-answer feedback. Defaults to `"Try again"`. */
incorrect?: string;
/** Caption for the next-question button. Defaults to `"Next"`. */
next?: string;
/** Caption for the summary "out of" connector. Defaults to `"of"`. */
outOf?: string;
/** Caption for the retry button. Defaults to `"Retry"`. */
retry?: string;
/** Heading above the score summary. Defaults to `"You scored"`. */
scored?: string;
/** Caption for the true option. Defaults to `"True"`. */
trueOption?: string;
};
const DEFAULT_LABELS = {
back: "Back",
check: "Check",
correct: "Correct",
falseOption: "False",
incorrect: "Try again",
next: "Next",
outOf: "of",
retry: "Retry",
scored: "You scored",
trueOption: "True",
} as const satisfies Required<KnowledgeCheckLabels>;
/**
* Choice option entry passed to a choice question.
*
* @public
*/
export type KnowledgeCheckOption = {
/** When true, this option is the correct answer. */
correct?: boolean;
/** Display label. */
label: ReactNode;
/** Stable identifier within the question. */
value: string;
};
type FillBlankQuestion = {
/** Expected answer string. */
answer: string;
/** When true, comparison is case-sensitive. Defaults to `false`. */
caseSensitive?: boolean;
/** Optional explanation shown after the user submits. */
explanation?: ReactNode;
/** Stable identifier. */
id: string;
/** Question prompt. */
question: ReactNode;
type: "fill-blank";
};
type MultipleChoiceQuestion = {
/** Optional explanation shown after the user submits. */
explanation?: ReactNode;
/** Stable identifier. */
id: string;
/** Option list. Mark the correct option(s) with `correct: true`. */
options: KnowledgeCheckOption[];
/** Question prompt. */
question: ReactNode;
type: "multiple-choice";
};
type TrueFalseQuestion = {
/** Expected boolean answer. */
answer: boolean;
/** Optional explanation shown after the user submits. */
explanation?: ReactNode;
/** Stable identifier. */
id: string;
/** Question prompt. */
question: ReactNode;
type: "true-false";
};
/**
* Question entry passed to {@link KnowledgeCheckProps.questions}.
*
* @public
*/
export type KnowledgeCheckQuestion =
| FillBlankQuestion
| MultipleChoiceQuestion
| TrueFalseQuestion;
/**
* Per-question result reported to the consumer.
*
* @public
*/
export type KnowledgeCheckAnswer = {
/** True when the user's response matched the expected answer. */
correct: boolean;
/** Question id. */
questionId: string;
/** The user's raw response (string for `fill-blank`, option value for choice, boolean for `true-false`). */
response: boolean | string;
};
/**
* Score payload reported on completion.
*
* @public
*/
export type KnowledgeCheckScore = {
/** Map of question id → answer. */
answers: Record<string, KnowledgeCheckAnswer>;
/** Right-answer count. */
correct: number;
/** Total number of questions. */
total: number;
};
function compareFillBlank(
question: FillBlankQuestion,
response: string,
): boolean {
const normalize = (value: string): string =>
question.caseSensitive ? value.trim() : value.trim().toLowerCase();
return normalize(response) === normalize(question.answer);
}
function isMultipleChoiceCorrect(
question: MultipleChoiceQuestion,
value: string,
): boolean {
return question.options.some(
(option) => option.value === value && option.correct === true,
);
}
function evaluateAnswer(
question: KnowledgeCheckQuestion,
response: boolean | string,
): boolean {
switch (question.type) {
case "fill-blank":
return (
typeof response === "string" && compareFillBlank(question, response)
);
case "multiple-choice":
return (
typeof response === "string" &&
isMultipleChoiceCorrect(question, response)
);
case "true-false":
return typeof response === "boolean" && response === question.answer;
}
}
type ResponseMap = Record<string, boolean | string>;
type AnswerMap = Record<string, KnowledgeCheckAnswer>;
type ControllerState = {
answers: AnswerMap;
current: KnowledgeCheckQuestion;
handleNext: () => void;
handlePrevious: () => void;
handleReset: () => void;
handleSubmit: () => void;
index: number;
isComplete: boolean;
isFirst: boolean;
isLast: boolean;
responses: ResponseMap;
score?: KnowledgeCheckScore;
setResponse: (questionId: string, value: boolean | string) => void;
};
type ControllerOptions = {
onAnswer?: (answer: KnowledgeCheckAnswer) => void;
onComplete?: (score: KnowledgeCheckScore) => void;
questions: KnowledgeCheckQuestion[];
};
function useKnowledgeCheckController(
options: ControllerOptions,
): ControllerState {
const { onAnswer, onComplete, questions } = options;
const [index, setIndex] = useState(0);
const [responses, setResponses] = useState<ResponseMap>({});
const [answers, setAnswers] = useState<AnswerMap>({});
const [completed, setCompleted] = useState(false);
const setResponse = useCallback(
(questionId: string, value: boolean | string) => {
setResponses((current) => ({ ...current, [questionId]: value }));
},
[],
);
const handleSubmit = useCallback(() => {
const question = questions[index];
if (!question) return;
const response = responses[question.id];
if (response === undefined) return;
const answer: KnowledgeCheckAnswer = {
correct: evaluateAnswer(question, response),
questionId: question.id,
response,
};
setAnswers((current) => ({ ...current, [question.id]: answer }));
onAnswer?.(answer);
}, [index, onAnswer, questions, responses]);
const handleNext = useCallback(() => {
if (index >= questions.length - 1) {
const finalScore = computeScore(questions, answers);
setCompleted(true);
onComplete?.(finalScore);
return;
}
setIndex((value) => value + 1);
}, [answers, index, onComplete, questions]);
const handlePrevious = useCallback(() => {
setIndex((value) => Math.max(0, value - 1));
}, []);
const handleReset = useCallback(() => {
setIndex(0);
setResponses({});
setAnswers({});
setCompleted(false);
}, []);
const score: KnowledgeCheckScore | undefined = useMemo(
() => (completed ? computeScore(questions, answers) : undefined),
[answers, completed, questions],
);
const current = questions[index] ?? questions[0];
if (!current) {
throw new Error("KnowledgeCheck requires at least one question");
}
return {
answers,
current,
handleNext,
handlePrevious,
handleReset,
handleSubmit,
index,
isComplete: completed,
isFirst: index === 0,
isLast: index === questions.length - 1,
responses,
score,
setResponse,
};
}
function computeScore(
questions: KnowledgeCheckQuestion[],
answers: AnswerMap,
): KnowledgeCheckScore {
const correct = Object.values(answers).filter(
(entry) => entry.correct,
).length;
return { answers, correct, total: questions.length };
}
/**
* Props for {@link KnowledgeCheck}.
*
* @public
*/
export type KnowledgeCheckProps = {
/** Localizable strings. */
labels?: KnowledgeCheckLabels;
/** Fires after the user submits an answer. */
onAnswer?: (answer: KnowledgeCheckAnswer) => void;
/** Fires when the user finishes the last question. */
onComplete?: (score: KnowledgeCheckScore) => void;
/** Question list. Must contain at least one entry. */
questions: KnowledgeCheckQuestion[];
/** Optional title shown above the questions. */
title?: ReactNode;
} & ComponentPropsWithoutRef<"section">;
type FeedbackProps = {
answer?: KnowledgeCheckAnswer;
correctLabel: string;
explanation?: ReactNode;
incorrectLabel: string;
};
function Feedback({
answer,
correctLabel,
explanation,
incorrectLabel,
}: FeedbackProps): ReactNode {
if (!answer) return null;
return (
<div
aria-live="polite"
className={cn(
"flex items-start gap-2 rounded-md border p-3 text-sm",
answer.correct
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
role="status"
>
{answer.correct ? (
<CheckCircle2 aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
) : (
<XCircle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
)}
<div className="flex flex-col gap-1">
<span className="font-medium">
{answer.correct ? correctLabel : incorrectLabel}
</span>
{explanation ? (
<span className="text-xs opacity-80">{explanation}</span>
) : null}
</div>
</div>
);
}
type MultipleChoiceFieldProps = {
groupName: string;
onChange: (value: string) => void;
options: KnowledgeCheckOption[];
value?: string;
};
type MultipleChoiceOptionProps = {
groupName: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
option: KnowledgeCheckOption;
value?: string;
};
function MultipleChoiceOption({
groupName,
onChange,
option,
value,
}: MultipleChoiceOptionProps): ReactNode {
const id = `${groupName}-${option.value}`;
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm hover:bg-accent">
<input
checked={value === option.value}
className="size-4"
id={id}
name={groupName}
onChange={onChange}
type="radio"
value={option.value}
/>
<label className="flex-1 cursor-pointer" htmlFor={id}>
{option.label}
</label>
</div>
);
}
function MultipleChoiceField({
groupName,
onChange,
options,
value,
}: MultipleChoiceFieldProps): ReactNode {
const handleSelect = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
},
[onChange],
);
return (
<div className="flex flex-col gap-1.5" role="radiogroup">
{options.map((option) => (
<MultipleChoiceOption
groupName={groupName}
key={option.value}
onChange={handleSelect}
option={option}
value={value}
/>
))}
</div>
);
}
type TrueFalseFieldProps = {
falseLabel: string;
groupName: string;
onChange: (value: boolean) => void;
trueLabel: string;
value?: boolean;
};
function TrueFalseField({
falseLabel,
groupName,
onChange,
trueLabel,
value,
}: TrueFalseFieldProps): ReactNode {
const handleSelectTrue = useCallback(() => {
onChange(true);
}, [onChange]);
const handleSelectFalse = useCallback(() => {
onChange(false);
}, [onChange]);
return (
<div
aria-label={groupName}
className="flex flex-wrap gap-2"
role="radiogroup"
>
<Button
aria-pressed={value === true}
onClick={handleSelectTrue}
size="sm"
type="button"
variant={value === true ? "default" : "outline"}
>
{trueLabel}
</Button>
<Button
aria-pressed={value === false}
onClick={handleSelectFalse}
size="sm"
type="button"
variant={value === false ? "default" : "outline"}
>
{falseLabel}
</Button>
</div>
);
}
type FillBlankFieldProps = {
inputId: string;
onChange: (value: string) => void;
value: string;
};
function FillBlankField({
inputId,
onChange,
value,
}: FillBlankFieldProps): ReactNode {
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
},
[onChange],
);
return <Input id={inputId} onChange={handleChange} value={value} />;
}
type QuestionFieldProps = {
groupName: string;
inputId: string;
labels: Required<KnowledgeCheckLabels>;
onResponse: (value: boolean | string) => void;
question: KnowledgeCheckQuestion;
response?: boolean | string;
};
function QuestionField({
groupName,
inputId,
labels,
onResponse,
question,
response,
}: QuestionFieldProps): ReactNode {
switch (question.type) {
case "fill-blank":
return (
<FillBlankField
inputId={inputId}
onChange={onResponse}
value={typeof response === "string" ? response : ""}
/>
);
case "multiple-choice":
return (
<MultipleChoiceField
groupName={groupName}
onChange={onResponse}
options={question.options}
value={typeof response === "string" ? response : undefined}
/>
);
case "true-false":
return (
<TrueFalseField
falseLabel={labels.falseOption}
groupName={groupName}
onChange={onResponse}
trueLabel={labels.trueOption}
value={typeof response === "boolean" ? response : undefined}
/>
);
}
}
type ScoreSummaryProps = {
labels: Required<KnowledgeCheckLabels>;
onRetry: () => void;
score: KnowledgeCheckScore;
};
function ScoreSummary({
labels,
onRetry,
score,
}: ScoreSummaryProps): ReactNode {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/20 p-6 text-center">
<p className="text-sm text-muted-foreground">{labels.scored}</p>
<p className="text-3xl font-bold tracking-tight text-foreground">
{score.correct} {labels.outOf} {score.total}
</p>
<Button onClick={onRetry} size="sm" type="button" variant="outline">
<RotateCcw aria-hidden="true" className="mr-2 size-4" />
{labels.retry}
</Button>
</div>
);
}
type QuestionPaneProps = {
controller: ControllerState;
groupName: string;
inputId: string;
labels: Required<KnowledgeCheckLabels>;
questions: KnowledgeCheckQuestion[];
};
function QuestionPane({
controller,
groupName,
inputId,
labels,
questions,
}: QuestionPaneProps): ReactNode {
const question = controller.current;
const response = controller.responses[question.id];
const answer = controller.answers[question.id];
const handleResponse = useCallback(
(value: boolean | string) => {
controller.setResponse(question.id, value);
},
[controller, question.id],
);
return (
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{`${(controller.index + 1).toString()} ${labels.outOf} ${questions.length.toString()}`}
</p>
<p className="text-sm font-medium text-foreground">{question.question}</p>
<QuestionField
groupName={groupName}
inputId={inputId}
labels={labels}
onResponse={handleResponse}
question={question}
response={response}
/>
<Feedback
answer={answer}
correctLabel={labels.correct}
explanation={question.explanation}
incorrectLabel={labels.incorrect}
/>
<PaneActions
answered={answer !== undefined}
controller={controller}
labels={labels}
responseSet={response !== undefined}
/>
</div>
);
}
type PaneActionsProps = {
answered: boolean;
controller: ControllerState;
labels: Required<KnowledgeCheckLabels>;
responseSet: boolean;
};
function PaneActions({
answered,
controller,
labels,
responseSet,
}: PaneActionsProps): ReactNode {
return (
<div className="flex items-center justify-between gap-2 pt-1">
<Button
disabled={controller.isFirst}
onClick={controller.handlePrevious}
size="sm"
type="button"
variant="ghost"
>
{labels.back}
</Button>
<div className="flex items-center gap-2">
{answered ? (
<Button onClick={controller.handleNext} size="sm" type="button">
{controller.isLast ? labels.scored : labels.next}
</Button>
) : (
<Button
disabled={!responseSet}
onClick={controller.handleSubmit}
size="sm"
type="button"
>
{labels.check}
</Button>
)}
</div>
</div>
);
}
/**
* Inline knowledge check for embedding within content. Supports three
* question types (choice, true-false, fill-blank) with per-question
* feedback (correct / incorrect + optional `explanation`) and a final
* score summary with retry. Composes {@link Input} and {@link Button}.
*
* Question state lives inside the component. Drive analytics or
* persistence from the `onAnswer` and `onComplete` callbacks.
*
* @example
* ```tsx
* <KnowledgeCheck
* title="Check your understanding"
* questions={[
* {
* id: "react-framework",
* type: "true-false",
* question: "React is a framework.",
* answer: false,
* },
* {
* id: "state-hook",
* type: "fill-blank",
* question: "The hook for state is ___",
* answer: "useState",
* },
* ]}
* onComplete={(score) => track("kc:complete", score)}
* />
* ```
*
* @public
*/
export const KnowledgeCheck = forwardRef<HTMLElement, KnowledgeCheckProps>(
(props, ref) => {
const {
className,
labels,
onAnswer,
onComplete,
questions,
title,
...rest
} = props;
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const groupName = useId();
const inputId = useId();
const controller = useKnowledgeCheckController({
onAnswer,
onComplete,
questions,
});
return (
<section
className={cn(
"flex flex-col gap-4 rounded-2xl border bg-background p-4",
className,
)}
ref={ref}
{...rest}
>
{title ? (
<h3 className="text-base font-semibold tracking-tight text-foreground">
{title}
</h3>
) : null}
{controller.isComplete && controller.score ? (
<ScoreSummary
labels={resolvedLabels}
onRetry={controller.handleReset}
score={controller.score}
/>
) : (
<QuestionPane
controller={controller}
groupName={groupName}
inputId={inputId}
labels={resolvedLabels}
questions={questions}
/>
)}
</section>
);
},
);
KnowledgeCheck.displayName = "KnowledgeCheck";
typescript