Geography Quiz Map
Interactive identify-mode map quiz — click the correct region per prompt with visual feedback, score, and results panel.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/geography-quiz-map.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook3 stories available:
Code
"use client";
import {
type ComponentPropsWithoutRef,
createContext,
forwardRef,
type ReactNode,
useCallback,
useContext,
useId,
useMemo,
useState,
} from "react";
import { cn } from "../../lib/utils";
const VIEWBOX_WIDTH = 1000;
const VIEWBOX_HEIGHT = 500;
const FEEDBACK_DURATION_MS = 800;
/**
* Geographic coordinate `[longitude, latitude]`.
*
* @public
*/
export type GeoPosition = [number, number];
/**
* A region polygon — outer ring of `[lng, lat]` positions; close the
* ring by repeating the first point.
*
* @public
*/
export type QuizRegion = {
/** Outer ring positions. */
coordinates: GeoPosition[];
/** Stable identifier — referenced by `QuizQuestion.answerRegionId`. */
id: string;
/** Human-readable name (used in the prompt and results panel). */
name: string;
};
/**
* Identify-mode question. The user clicks the region matching `answerRegionId`.
*
* @public
*/
export type QuizQuestion = {
/** Region id of the correct answer. */
answerRegionId: string;
/** Stable identifier. */
id: string;
/** Human-readable prompt rendered above the map. */
prompt: ReactNode;
};
/**
* Outcome for a single answered question.
*
* @public
*/
export type QuizAnswer = {
/** `true` when the selected region matches the answer. */
correct: boolean;
/** The original question. */
question: QuizQuestion;
/** Region id the user clicked. */
selectedRegionId: string;
};
/**
* Localizable strings.
*
* @public
*/
export type GeographyQuizMapLabels = {
/** Aria-label for the quiz region. Defaults to `"Geography quiz map"`. */
region?: string;
};
const DEFAULT_LABELS = {
region: "Geography quiz map",
} as const satisfies Required<GeographyQuizMapLabels>;
type Phase = "complete" | "playing";
type Feedback = "correct" | "incorrect";
type QuizCtx = {
answers: QuizAnswer[];
current?: QuizQuestion;
feedback?: Feedback;
phase: Phase;
questionIndex: number;
totalQuestions: number;
};
const QuizContext = createContext<null | QuizCtx>(null);
function useQuizContext(): QuizCtx {
const ctx = useContext(QuizContext);
if (!ctx) {
throw new Error("GeographyQuizMap subcomponent used outside its root.");
}
return ctx;
}
function projectEquirectangular(position: GeoPosition): {
x: number;
y: number;
} {
const [lng, lat] = position;
const x = ((lng + 180) / 360) * VIEWBOX_WIDTH;
const y = ((90 - lat) / 180) * VIEWBOX_HEIGHT;
return { x, y };
}
function regionPath(region: QuizRegion): string {
const points = region.coordinates
.map((coord, index) => {
const projected = projectEquirectangular(coord);
return `${index === 0 ? "M" : "L"}${projected.x.toString()},${projected.y.toString()}`;
})
.join(" ");
return `${points} Z`;
}
/**
* Props for {@link GeographyQuizMap}.
*
* @public
*/
export type GeographyQuizMapProps = {
/** Optional backdrop image URL. */
backdrop?: string;
/** Aria-label for the backdrop image. */
backdropAlt?: string;
/** Localizable strings. */
labels?: GeographyQuizMapLabels;
/** Fires once the user finishes every question. */
onComplete?: (answers: QuizAnswer[]) => void;
/** Quiz questions in order. */
questions: QuizQuestion[];
/** Region polygons. */
regions: QuizRegion[];
} & ComponentPropsWithoutRef<"section">;
type RegionPathProps = {
disabled: boolean;
feedback?: Feedback;
isAnswerForCurrent: boolean;
isSelected: boolean;
onSelect: (id: string) => void;
region: QuizRegion;
showAnswerFlash: boolean;
};
function RegionShape({
disabled,
feedback,
isAnswerForCurrent,
isSelected,
onSelect,
region,
showAnswerFlash,
}: RegionPathProps): ReactNode {
let fill = "rgb(226, 232, 240)";
if (isSelected && feedback === "correct") fill = "rgb(34, 197, 94)";
else if (isSelected && feedback === "incorrect") fill = "rgb(239, 68, 68)";
else if (showAnswerFlash && isAnswerForCurrent && !isSelected)
fill = "rgba(34, 197, 94, 0.4)";
return (
<path
aria-label={region.name}
className={cn(
"stroke-background outline-none transition-colors",
disabled
? "cursor-not-allowed"
: "cursor-pointer hover:opacity-90 focus-visible:opacity-90",
)}
d={regionPath(region)}
data-region-id={region.id}
data-state={
isSelected
? feedback === "correct"
? "correct"
: "incorrect"
: showAnswerFlash && isAnswerForCurrent
? "answer"
: undefined
}
fill={fill}
onClick={() => {
if (!disabled) onSelect(region.id);
}}
onKeyDown={(event) => {
if (disabled) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelect(region.id);
}}
role="button"
strokeWidth={1}
tabIndex={disabled ? -1 : 0}
/>
);
}
type StageProps = {
backdrop?: string;
backdropAlt?: string;
current?: QuizQuestion;
disabled: boolean;
feedback?: Feedback;
onSelect: (id: string) => void;
regions: QuizRegion[];
selectedRegionId?: string;
};
function Stage({
backdrop,
backdropAlt,
current,
disabled,
feedback,
onSelect,
regions,
selectedRegionId,
}: StageProps): ReactNode {
return (
<svg
className="block h-full w-full"
preserveAspectRatio="xMidYMid meet"
role="img"
viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}
>
<rect
className="fill-muted"
height={VIEWBOX_HEIGHT}
width={VIEWBOX_WIDTH}
x="0"
y="0"
/>
{backdrop ? (
<image
aria-label={backdropAlt}
height={VIEWBOX_HEIGHT}
href={backdrop}
preserveAspectRatio="xMidYMid slice"
width={VIEWBOX_WIDTH}
x="0"
y="0"
/>
) : null}
{regions.map((region) => (
<RegionShape
disabled={disabled}
feedback={feedback}
isAnswerForCurrent={current?.answerRegionId === region.id}
isSelected={selectedRegionId === region.id}
key={region.id}
onSelect={onSelect}
region={region}
showAnswerFlash={feedback === "incorrect"}
/>
))}
</svg>
);
}
/**
* Prompt slot. Renders the current question text on top of the map.
*
* @public
*/
export const GeographyQuizMapPrompt = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => {
const { current, phase } = useQuizContext();
if (phase !== "playing" || !current) return null;
return (
<div
className={cn(
"absolute inset-x-3 top-3 z-10 rounded-md border bg-background/95 px-3 py-2 text-center text-sm font-medium text-foreground shadow-sm backdrop-blur",
className,
)}
data-quiz-prompt
ref={ref}
{...rest}
>
{current.prompt}
</div>
);
});
GeographyQuizMapPrompt.displayName = "GeographyQuizMapPrompt";
/**
* Score slot. Renders `correct / total · streak%`.
*
* @public
*/
export const GeographyQuizMapScore = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => {
const { answers, totalQuestions } = useQuizContext();
const correct = answers.filter((entry) => entry.correct).length;
const accuracy =
answers.length === 0 ? 0 : Math.round((correct / answers.length) * 100);
return (
<div
className={cn(
"absolute right-3 top-3 z-10 rounded-md border bg-background/95 px-2 py-1 text-xs font-medium text-foreground shadow-sm backdrop-blur",
className,
)}
data-quiz-score
ref={ref}
{...rest}
>
{`${correct.toString()} / ${totalQuestions.toString()} · ${accuracy.toString()}%`}
</div>
);
});
GeographyQuizMapScore.displayName = "GeographyQuizMapScore";
/**
* Results slot. Renders the per-question outcome list once the
* user finishes every question.
*
* @public
*/
export const GeographyQuizMapResults = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => {
const { answers, phase, totalQuestions } = useQuizContext();
if (phase !== "complete") return null;
const correct = answers.filter((entry) => entry.correct).length;
return (
<div
className={cn(
"absolute inset-3 z-20 flex flex-col gap-3 overflow-auto rounded-md border bg-background/95 p-4 text-sm text-foreground shadow-md backdrop-blur",
className,
)}
data-quiz-results
ref={ref}
{...rest}
>
<h3 className="text-base font-semibold">
{`Results · ${correct.toString()} / ${totalQuestions.toString()}`}
</h3>
<ol className="space-y-1">
{answers.map((entry) => (
<li
className={cn(
"flex items-center gap-2 rounded-md border px-2 py-1",
entry.correct
? "border-emerald-300 bg-emerald-500/10"
: "border-red-300 bg-red-500/10",
)}
data-answer-correct={entry.correct ? "true" : "false"}
data-answer-id={entry.question.id}
key={entry.question.id}
>
<span aria-hidden="true">{entry.correct ? "✓" : "✗"}</span>
<span className="flex-1">{entry.question.prompt}</span>
</li>
))}
</ol>
</div>
);
});
GeographyQuizMapResults.displayName = "GeographyQuizMapResults";
type QuizState = {
answers: QuizAnswer[];
feedback?: Feedback;
questionIndex: number;
selectedRegionId?: string;
};
function useQuizState(arguments_: {
onComplete?: (answers: QuizAnswer[]) => void;
questions: QuizQuestion[];
}): {
handleSelect: (regionId: string) => void;
state: QuizState;
} {
const { onComplete, questions } = arguments_;
const [state, setState] = useState<QuizState>({
answers: [],
feedback: undefined,
questionIndex: 0,
selectedRegionId: undefined,
});
const handleSelect = useCallback(
(regionId: string) => {
setState((current) => {
if (current.feedback) return current;
const question = questions[current.questionIndex];
if (!question) return current;
const correct = question.answerRegionId === regionId;
const next: QuizState = {
...current,
answers: [
...current.answers,
{ correct, question, selectedRegionId: regionId },
],
feedback: correct ? "correct" : "incorrect",
selectedRegionId: regionId,
};
scheduleAdvance(setState, onComplete, questions);
return next;
});
},
[onComplete, questions],
);
return { handleSelect, state };
}
function scheduleAdvance(
setState: React.Dispatch<React.SetStateAction<QuizState>>,
onComplete: ((answers: QuizAnswer[]) => void) | undefined,
questions: QuizQuestion[],
): void {
if (typeof window === "undefined") return;
window.setTimeout(() => {
setState((current) => {
const nextIndex = current.questionIndex + 1;
if (nextIndex >= questions.length) {
onComplete?.(current.answers);
return {
...current,
feedback: undefined,
questionIndex: questions.length,
selectedRegionId: undefined,
};
}
return {
...current,
feedback: undefined,
questionIndex: nextIndex,
selectedRegionId: undefined,
};
});
}, FEEDBACK_DURATION_MS);
}
/**
* Interactive map quiz. Identify-mode: each question asks the user to
* click the region matching the prompt. Correct clicks flash green,
* incorrect clicks flash red and reveal the correct region. After the
* final question the {@link GeographyQuizMapResults} slot renders the
* per-question outcome list and `onComplete` fires.
*
* Compose with {@link GeographyQuizMapPrompt}, {@link GeographyQuizMapScore},
* and {@link GeographyQuizMapResults} as children.
*
* @example
* ```tsx
* <GeographyQuizMap
* regions={countries}
* questions={[
* { id: "q1", prompt: "Click on France", answerRegionId: "FR" },
* { id: "q2", prompt: "Click on Germany", answerRegionId: "DE" },
* ]}
* onComplete={(answers) => console.info(answers)}
* >
* <GeographyQuizMapPrompt />
* <GeographyQuizMapScore />
* <GeographyQuizMapResults />
* </GeographyQuizMap>
* ```
*
* @public
*/
export const GeographyQuizMap = forwardRef<HTMLElement, GeographyQuizMapProps>(
(props, ref) => {
const {
backdrop,
backdropAlt,
children,
className,
labels,
onComplete,
questions,
regions,
...rest
} = props;
const titleId = useId();
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const { handleSelect, state } = useQuizState({ onComplete, questions });
const phase: Phase =
state.questionIndex >= questions.length ? "complete" : "playing";
const current = questions[state.questionIndex];
const ctx = useMemo<QuizCtx>(
() => ({
answers: state.answers,
current,
feedback: state.feedback,
phase,
questionIndex: state.questionIndex,
totalQuestions: questions.length,
}),
[
current,
phase,
questions.length,
state.answers,
state.feedback,
state.questionIndex,
],
);
return (
<QuizContext.Provider value={ctx}>
<section
aria-labelledby={titleId}
className={cn(
"relative aspect-[2/1] w-full overflow-hidden rounded-2xl border bg-background text-foreground",
className,
)}
ref={ref}
{...rest}
>
<span className="sr-only" id={titleId}>
{resolvedLabels.region}
</span>
<Stage
backdrop={backdrop}
backdropAlt={backdropAlt}
current={current}
disabled={phase === "complete" || Boolean(state.feedback)}
feedback={state.feedback}
onSelect={handleSelect}
regions={regions}
selectedRegionId={state.selectedRegionId}
/>
{children}
</section>
</QuizContext.Provider>
);
},
);
GeographyQuizMap.displayName = "GeographyQuizMap";
typescript