# Hotspots

Clickable regions overlaid on a background image or diagram. Quiz mode with target, feedback, and hints.

Preview: https://design.freecodecamp.org/playground#hotspots

## Add to your project

Use React and TypeScript. Required packages: `react@>=18 <20`. No freeCodeCamp package is needed.

Copy the files below to the indicated paths, relative to your project root. If you change the layout, update relative imports too.

Import the CSS once from your application entry. For an entry in src/:

```ts
import './ui/theme/tokens.css';
import './ui/theme/base.css';
import './ui/hotspots/hotspots.css';
```

The theme is shared: reuse it if already installed. Fonts use /fonts/ URLs on your host. Download the font files listed in https://design.freecodecamp.org/registry/starter.md or change those URLs in your copied tokens.css.

## Example

```tsx
import { Hotspots, type HotspotItem } from './ui/hotspots/Hotspots';
import { RectHotspot, EllipseHotspot } from './ui/hotspot-shapes/HotspotShapes';

const Diagram = () => (
  <svg viewBox='0 0 200 140' role='img' aria-label='Three shapes'>
    <rect x='33' y='25' width='29' height='92' fill='currentColor' />
    <ellipse cx='100' cy='75' rx='30' ry='45' fill='currentColor' />
    <rect x='138' y='25' width='29' height='92' fill='currentColor' />
  </svg>
);

const HOTSPOTS: HotspotItem[] = [
  {
    id: 'bracket-left',
    label: 'Opening Paren',
    shape: <RectHotspot x={33} y={25} width={29} height={92} />
  },
  {
    id: 'fire',
    label: 'Ellipse',
    shape: <EllipseHotspot cx={100} cy={75} rx={30} ry={45} />
  },
  {
    id: 'bracket-right',
    label: 'Closing Paren',
    shape: <RectHotspot x={138} y={25} width={29} height={92} />
  }
];

export function HotspotsDemo() {
  return (
    <div style={{ width: '100%', maxWidth: 360, margin: '0 auto' }}>
      <Hotspots
        background={<Diagram />}
        width={200}
        height={140}
        hotspots={HOTSPOTS}
        targetId='fire'
        prompt='Click the ellipse'
        onCorrect={id => console.log('correct', id)}
      />
    </div>
  );
}
```

## Component source

### src/ui/hotspots/Hotspots.tsx

Source: https://design.freecodecamp.org/registry/hotspots/Hotspots.tsx

```tsx
import React, { forwardRef, useCallback, useState } from 'react';

export interface HotspotItem {
  /** Stable id. In quiz mode this is compared against `targetId`. */
  id: string;
  /** Accessible name for the region (announced to screen readers). */
  label: string;
  /**
   * The clickable shape - a `CircleHotspot` / `RectHotspot` / `EllipseHotspot`
   * / `PolygonHotspot`, or any custom SVG node carrying `hotspots__shape`.
   */
  shape: React.ReactNode;
  disabled?: boolean;
}

export interface HotspotsProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'onSelect'
> {
  /** Background layer: an image `src` string, or any node (component, `<svg>`). */
  background: React.ReactNode;
  /** Alt text used when `background` is an image `src` string. */
  backgroundAlt?: string;
  /** Coordinate space width - hotspot geometry is expressed against this. */
  width: number;
  /** Coordinate space height. Also sets the container aspect ratio. */
  height: number;
  /** Clickable regions overlaid on the background. */
  hotspots: HotspotItem[];
  /** Quiz mode: the id of the correct hotspot. Omit for free selection. */
  targetId?: string;
  /** Instruction shown above the image (quiz mode). */
  prompt?: React.ReactNode;
  /** Reveal a hint naming the target after this many wrong attempts. Default `3`. */
  hintAfter?: number;
  /** Controlled selection. Omit for uncontrolled. */
  selectedId?: string | null;
  /** Lock the whole widget. */
  disabled?: boolean;
  /** Fires on every pick with the chosen hotspot id. */
  onSelect?: (id: string) => void;
  /** Quiz mode: fires when the target is picked. */
  onCorrect?: (id: string) => void;
  /** Quiz mode: fires when a non-target is picked. */
  onIncorrect?: (id: string) => void;
}

export const Hotspots = forwardRef<HTMLDivElement, HotspotsProps>(
  (
    {
      background,
      backgroundAlt = '',
      width,
      height,
      hotspots,
      targetId,
      prompt,
      hintAfter = 3,
      selectedId,
      disabled = false,
      onSelect,
      onCorrect,
      onIncorrect,
      className = '',
      style,
      ...rest
    },
    ref
  ) => {
    const isControlled = selectedId !== undefined;
    const [internal, setInternal] = useState<string | null>(null);
    const [attempts, setAttempts] = useState(0);
    const [announcement, setAnnouncement] = useState('');
    const selected = isControlled ? selectedId : internal;

    const isQuiz = targetId !== undefined;
    const correct = isQuiz && selected != null && selected === targetId;
    const wrong = isQuiz && selected != null && selected !== targetId;
    const targetLabel = hotspots.find(h => h.id === targetId)?.label;

    const handlePick = useCallback(
      (item: HotspotItem) => {
        if (disabled || item.disabled) return;
        if (!isControlled) setInternal(item.id);
        setAttempts(a => a + 1);
        onSelect?.(item.id);
        if (isQuiz) {
          if (item.id === targetId) {
            setAnnouncement(`Correct: ${item.label}`);
            onCorrect?.(item.id);
          } else {
            setAnnouncement('Not quite - try again');
            onIncorrect?.(item.id);
          }
        } else {
          setAnnouncement(`Selected: ${item.label}`);
        }
      },
      [
        disabled,
        isControlled,
        isQuiz,
        targetId,
        onSelect,
        onCorrect,
        onIncorrect
      ]
    );

    const hotspotState = (item: HotspotItem): string => {
      if (selected !== item.id) return 'idle';
      if (!isQuiz) return 'selected';
      return item.id === targetId ? 'correct' : 'incorrect';
    };

    const classes = ['hotspots', className].filter(Boolean).join(' ');
    const showHint = isQuiz && !correct && attempts >= hintAfter;

    return (
      <div
        ref={ref}
        className={classes}
        aria-disabled={disabled || undefined}
        style={style}
        {...rest}
      >
        {prompt !== undefined && <p className='hotspots__prompt'>{prompt}</p>}
        <div
          className='hotspots__stage'
          style={{ aspectRatio: `${width} / ${height}` }}
        >
          <div className='hotspots__background'>
            {typeof background === 'string' ? (
              <img src={background} alt={backgroundAlt} />
            ) : (
              background
            )}
          </div>
          <svg
            className='hotspots__overlay'
            viewBox={`0 0 ${width} ${height}`}
            preserveAspectRatio='none'
            aria-hidden={hotspots.length === 0 || undefined}
          >
            {hotspots.map(item => {
              const itemDisabled = disabled || item.disabled;
              return (
                <g
                  key={item.id}
                  className='hotspots__hotspot'
                  data-state={hotspotState(item)}
                  data-hotspot-id={item.id}
                  role='button'
                  tabIndex={itemDisabled ? -1 : 0}
                  aria-label={item.label}
                  aria-pressed={selected === item.id}
                  aria-disabled={itemDisabled || undefined}
                  onClick={() => handlePick(item)}
                  onKeyDown={e => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      e.preventDefault();
                      handlePick(item);
                    }
                  }}
                >
                  {item.shape}
                </g>
              );
            })}
          </svg>
        </div>
        <div className='hotspots__status'>
          {correct && (
            <p className='hotspots__feedback hotspots__feedback--correct'>
              ✓ Correct{targetLabel ? ` - ${targetLabel}` : ''}.
            </p>
          )}
          {wrong && (
            <p className='hotspots__feedback hotspots__feedback--incorrect'>
              Not quite. Try again - look for the region in the prompt.
            </p>
          )}
          {showHint && targetLabel && (
            <p className='hotspots__feedback hotspots__feedback--hint'>
              Hint: look for <strong>{targetLabel}</strong>.
            </p>
          )}
        </div>
        <span className='hotspots__sr-status' role='status' aria-live='polite'>
          {announcement}
        </span>
      </div>
    );
  }
);
Hotspots.displayName = 'Hotspots';
```

### src/ui/hotspots/hotspots.css

Source: https://design.freecodecamp.org/registry/hotspots/hotspots.css

```css
/* Hotspots - clickable regions overlaid on a background image / component */
.hotspots {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.hotspots__prompt {
  margin: 0;
  font-size: var(--fs-md);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
}
.hotspots__stage {
  position: relative;
  width: 100%;
  border: var(--border-width-default) solid var(--foreground-quaternary);
  background: var(--background-secondary);
  overflow: hidden;
}
.hotspots__background {
  position: absolute;
  inset: 0;
}
.hotspots__background img,
.hotspots__background svg {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.hotspots__overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.hotspots__hotspot {
  cursor: pointer;
  outline: none;
}
.hotspots__hotspot[aria-disabled='true'] {
  cursor: not-allowed;
}
.hotspots__shape {
  fill: color-mix(in srgb, var(--highlight-color) 18%, transparent);
  stroke: color-mix(in srgb, var(--highlight-color) 60%, transparent);
  stroke-width: 2;
  transition:
    fill var(--dur-fast) var(--ease-out),
    stroke var(--dur-fast) var(--ease-out);
}
.hotspots__hotspot:hover:not([aria-disabled='true']) .hotspots__shape {
  fill: color-mix(in srgb, var(--highlight-color) 30%, transparent);
}
.hotspots__hotspot:focus-visible .hotspots__shape {
  stroke: var(--focus-outline-color);
  stroke-width: 3;
}
.hotspots__hotspot[data-state='selected'] .hotspots__shape {
  fill: color-mix(in srgb, var(--highlight-color) 35%, transparent);
  stroke: var(--highlight-color);
}
.hotspots__hotspot[data-state='correct'] .hotspots__shape {
  fill: color-mix(in srgb, var(--success-color) 45%, transparent);
  stroke: var(--success-color);
}
.hotspots__hotspot[data-state='incorrect'] .hotspots__shape {
  fill: color-mix(in srgb, var(--danger-color) 45%, transparent);
  stroke: var(--danger-color);
}
.hotspots__status {
  min-height: var(--space-5);
  font-size: var(--fs-sm);
}
.hotspots__feedback {
  margin: 0;
}
.hotspots__feedback--correct {
  color: var(--success-color);
}
.hotspots__feedback--incorrect,
.hotspots__feedback--hint {
  color: var(--foreground-secondary);
}
.hotspots__sr-status {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
@media (prefers-reduced-motion: reduce) {
  .hotspots__shape {
    transition: none;
  }
}
```

## Shared source: Theme

### src/ui/theme/tokens.css

Source: https://design.freecodecamp.org/registry/theme/tokens.css

```css
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Light.woff') format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Italic.woff') format('woff');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-BoldItalic.woff') format('woff');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Black.woff') format('woff');
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Italic.woff2') format('woff2');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-BoldItalic.woff2') format('woff2');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

:root {
  --gray-00: #ffffff;
  --gray-00-translucent: rgba(255, 255, 255, 0.85);
  --gray-05: #f5f6f7;
  --gray-10: #dfdfe2;
  --gray-15: #d0d0d5;
  --gray-45: #858591;
  --gray-75: #3b3b4f;
  --gray-80: #2a2a40;
  --gray-85: #1b1b32;
  --gray-90: #0a0a23;
  --gray-90-translucent: rgba(10, 10, 35, 0.85);

  --purple-light: #dbb8ff;
  --purple-mid: #9400d3;
  --purple-dark: #5a01a7;
  --yellow-light: #ffc300;
  --yellow-gold: #ffbf00;
  --yellow-style: #f1be32;
  --yellow-dark: #4d3800;
  --blue-light: #99c9ff;
  --blue-light-translucent: rgba(153, 201, 255, 0.3);
  --blue-mid: #198eee;
  --blue-dark: #002ead;
  --blue-dark-translucent: rgba(0, 46, 173, 0.3);
  --green-light: #acd157;
  --green-dark: #00471b;
  --red-light: #ffadad;
  --red-dark: #850000;
  --love-light: #f8577c;
  --love-dark: #f82153;
  --orange: #eda971;

  --editor-background-light: #fffffe;
  --editor-background-dark: #2a2b40;

  --syntax-keyword: #dbb8ff;
  --syntax-fn: #99c9ff;
  --syntax-string: #acd157;
  --syntax-class: #f1be32;
  --syntax-number: #f78c6c;
  --syntax-tag: #f07178;
  --syntax-operator: #89ddff;
  --syntax-invalid: #ff5370;
  --syntax-comment: #858591;
  --syntax-plain: #eeffff;

  --font-sans:
    'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-mono: 'Hack-ZeroSlash', 'Fira Mono', Menlo, Consolas, monospace;

  --fs-base: 18px;
  --fs-sm: 16px;
  --fs-md: 18px;
  --fs-lg: 24px;
  --fs-xl: 32px;
  --fs-2xl: 42px;
  --fs-3xl: 56px;
  --fs-display: clamp(2.5rem, 5vw, 3.75rem);

  --lh-tight: 1.2;
  --lh-snug: 1.33;
  --lh-base: 1.42857143;
  --lh-loose: 1.6;

  --fw-light: 300;
  --fw-regular: 400;
  --fw-bold: 700;
  --fw-black: 900;

  --space-0: 0;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --border-width-hair: 1px;
  --border-width-default: 2px;
  --border-width-thick: 3px;
  --radius-none: 0;
  --radius-sm: 2px;

  --focus-outline-color: var(--blue-mid);
  --focus-outline-width: 3px;

  --z-breadcrumbs: 100;
  --z-flash: 150;
  --z-site-header: 200;
  --z-modal: 1050;

  --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-fast: 120ms;
  --dur-base: 180ms;
  --dur-slow: 260ms;

  --header-height: 48px;
  --breadcrumbs-height: 32px;
  --sidebar-width: 288px;
  --content-max: 1040px;

  color-scheme: dark;
}

.dark-palette,
:root {
  color-scheme: dark;
  --foreground-primary: var(--gray-00);
  --foreground-secondary: var(--gray-05);
  --foreground-tertiary: var(--gray-10);
  --foreground-quaternary: var(--gray-15);
  --foreground-muted: #b0b0bd;

  --background-primary: var(--gray-90);
  --background-primary-translucent: var(--gray-90-translucent);
  --background-secondary: var(--gray-85);
  --background-tertiary: #33334f;
  --background-quaternary: #4b4b66;

  --highlight-color: var(--blue-light);
  --highlight-background: var(--blue-dark);
  --selection-color: var(--blue-light-translucent);

  --success-color: var(--green-light);
  --success-background: var(--green-dark);
  --danger-color: var(--red-light);
  --danger-background: var(--red-dark);
  --warning-color: var(--yellow-light);
  --warning-background: var(--yellow-dark);
  --purple-color: var(--purple-light);
  --purple-background: var(--purple-dark);
  --love-color: var(--love-light);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(255, 255, 255, 0.045);
  --surface-elevation-2: rgba(255, 255, 255, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);
}

.light-palette {
  --foreground-primary: var(--gray-90);
  --foreground-secondary: var(--gray-85);
  --foreground-tertiary: var(--gray-80);
  --foreground-quaternary: var(--gray-75);
  --foreground-muted: #5a5a68;

  --background-primary: var(--gray-00);
  --background-primary-translucent: var(--gray-00-translucent);
  --background-secondary: var(--gray-05);
  --background-tertiary: #c5c5cc;
  --background-quaternary: #a8a8b4;

  --highlight-color: var(--blue-dark);
  --highlight-background: var(--blue-light);
  --selection-color: var(--blue-dark-translucent);

  --success-color: var(--green-dark);
  --success-background: var(--green-light);
  --danger-color: var(--red-dark);
  --danger-background: var(--red-light);
  --warning-color: var(--yellow-dark);
  --warning-background: var(--yellow-light);
  --purple-color: var(--purple-dark);
  --purple-background: var(--purple-light);
  --love-color: var(--love-dark);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(10, 10, 35, 0.05);
  --surface-elevation-2: rgba(10, 10, 35, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);

  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: var(--fs-md);
  font-family: var(--font-sans);
  line-height: var(--lh-base);
  color: var(--foreground-primary);
  background: var(--background-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-height) + 24px);
}

body {
  margin: 0;
  font-family: var(--font-sans);
  color: var(--foreground-primary);
  background: var(--background-primary);
}

::selection {
  background: var(--selection-color);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
  line-height: var(--lh-snug);
  margin: 0 0 12px 0;
}
h1 {
  font-size: var(--fs-3xl);
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
}
h2 {
  font-size: var(--fs-2xl);
  letter-spacing: -0.005em;
}
h3 {
  font-size: var(--fs-xl);
}
h4 {
  font-size: var(--fs-lg);
}
h5 {
  font-size: var(--fs-md);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
h6 {
  font-size: var(--fs-sm);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--foreground-muted);
  font-family: var(--font-mono);
}

p {
  margin: 0 0 12px 0;
}

a {
  color: var(--highlight-color);
  text-decoration: underline;
  text-underline-position: under;
  text-underline-offset: 0.1em;
}
a:hover {
  color: var(--foreground-primary);
}

code,
pre,
kbd,
samp {
  font-family: var(--font-mono);
  font-size: 16px;
}
code {
  background: var(--background-tertiary);
  color: var(--foreground-tertiary);
}
:not(pre) > code {
  border: 1px solid var(--background-quaternary);
  padding: 1px 4px;
  overflow-wrap: anywhere;
  word-break: break-word;
}
pre {
  background: var(--editor-background);
  color: var(--foreground-tertiary);
  padding: 14px 16px;
  font-size: 14px;
  line-height: var(--lh-base);
  max-width: 100%;
  overflow-x: auto;
  margin: 0;
}
pre code {
  display: block;
  width: max-content;
  min-width: 100%;
  background: transparent;
  border: 0;
  padding: 0;
}

:focus-visible {
  outline: var(--focus-outline-width) solid var(--focus-outline-color);
  outline-offset: 0;
}

hr {
  border: 0;
  border-top: 1px solid var(--background-quaternary);
  margin: 24px 0;
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: var(--background-primary);
}
::-webkit-scrollbar-thumb {
  background: var(--background-quaternary);
  border: 2px solid var(--background-primary);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--foreground-muted);
}
```

### src/ui/theme/base.css

Source: https://design.freecodecamp.org/registry/theme/base.css

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
```

## Shared source: HotspotShapes

### src/ui/hotspot-shapes/HotspotShapes.tsx

Source: https://design.freecodecamp.org/registry/hotspot-shapes/HotspotShapes.tsx

```tsx
import React from 'react';

/**
 * Primitive hotspot shapes for `<Hotspots />`. Each is a thin wrapper over an
 * SVG shape that carries the `hotspots__shape` class so the parent controls
 * fill/stroke by state. Pass geometry via the native SVG attributes and place
 * inside a `HotspotItem.shape`.
 */

const shapeClass = (className?: string): string =>
  ['hotspots__shape', className].filter(Boolean).join(' ');

export type CircleHotspotProps = React.SVGProps<SVGCircleElement>;
export function CircleHotspot({
  className,
  ...rest
}: CircleHotspotProps): React.ReactElement {
  return <circle className={shapeClass(className)} {...rest} />;
}

export type EllipseHotspotProps = React.SVGProps<SVGEllipseElement>;
export function EllipseHotspot({
  className,
  ...rest
}: EllipseHotspotProps): React.ReactElement {
  return <ellipse className={shapeClass(className)} {...rest} />;
}

export type RectHotspotProps = React.SVGProps<SVGRectElement>;
export function RectHotspot({
  className,
  ...rest
}: RectHotspotProps): React.ReactElement {
  return <rect className={shapeClass(className)} {...rest} />;
}

export type PolygonHotspotProps = React.SVGProps<SVGPolygonElement>;
export function PolygonHotspot({
  className,
  ...rest
}: PolygonHotspotProps): React.ReactElement {
  return <polygon className={shapeClass(className)} {...rest} />;
}
```

## Adapting this component

Keep the component's semantics and keyboard behavior. Use the CSS variables to change its appearance. Check the result in your project; copied source does not receive automatic updates.

Source revision: 37aae52 (2026-09-08). Component source: BSD-3-Clause. Preserve the license notice: https://design.freecodecamp.org/license.txt.

Design rules: https://design.freecodecamp.org/handbook.md

