# Hotspots

> Clickable regions overlaid on a background image, diagram, or SVG. Quiz mode compares the pick to a target and surfaces feedback plus a hint after repeated misses.

- Category: game
- Status: beta (since 0.2.0)
- Tokens: --highlight-color, --success-color, --danger-color, --dur-fast, --ease-out
- Playground: https://design.freecodecamp.org/playground#hotspots
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `Hotspots.tsx` → `src/ui/hotspots/Hotspots.tsx` (raw: https://design.freecodecamp.org/registry/hotspots/Hotspots.tsx)
  - `hotspots.css` → `src/ui/hotspots/hotspots.css` (raw: https://design.freecodecamp.org/registry/hotspots/hotspots.css)

## Install (copy source)

1. Ensure the theme is installed once per project - tokens.css + base.css imported globally, fonts available. See https://design.freecodecamp.org/registry/theme.md and https://design.freecodecamp.org/registry/starter.md.
2. Copy the files below into `src/ui/hotspots/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/hotspots/hotspots.css';`.
3. Colors, spacing and type come from tokens - tailor the component by editing the copied source; recolour by editing tokens.css, not the component CSS.

## Usage

Overlay clickable regions on any background - an image `src`, a component, or an
inline `<svg>`. Hotspot geometry is expressed against a `width` × `height`
coordinate space, so shapes stay aligned as the widget scales. Use the exported
shape primitives (`CircleHotspot`, `RectHotspot`, `EllipseHotspot`,
`PolygonHotspot`) or supply any custom SVG node. Give a `targetId` for a
"find the region" quiz, or omit it for free selection.

## Keyboard

| Key           | Action                        |
| ------------- | ----------------------------- |
| Tab           | Moves focus between hotspots. |
| Space / Enter | Selects the focused hotspot.  |

## Accessibility

Each hotspot is a `role="button"` with `tabindex="0"`, an `aria-label`, and
`aria-pressed` reflecting selection. A visually-hidden `aria-live` region
announces each pick and the result. When `background` is a string, the `<img>`
carries `backgroundAlt`. State colour is paired with a stroke change so it
does not rely on colour alone. The shape transition is suppressed under
`prefers-reduced-motion`.

## Example

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

const Diagram = (): JSX.Element => (
  <img src='/favicon.svg' alt='freecodecamp logo' />
);

const HOTSPOTS: HotspotItem[] = [
  {
    id: 'bracket-left',
    label: 'Opening Paren',
    shape: <RectHotspot x={33} y={25} width={29} height={92} />
  },
  {
    id: 'fire',
    label: 'Fire',
    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(): JSX.Element {
  return (
    <div style={{ width: '100%', maxWidth: 360, margin: '0 auto' }}>
      <Hotspots
        background={<Diagram />}
        width={200}
        height={140}
        hotspots={HOTSPOTS}
        targetId='fire'
        prompt='Click the fire'
        onCorrect={id => console.log('correct', id)}
      />
    </div>
  );
}
```

## Props

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

## Source: 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';
```

## Source: 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;
  }
}
```

## HTML / vanilla variant

```html
<!-- Hotspots is a stateful React component.
     Use the React package for curriculum embeds. -->
<div class="hotspots">
  <div class="hotspots__stage">
    <div class="hotspots__background"><img src="/bird.png" alt="A songbird" /></div>
    <svg class="hotspots__overlay" viewBox="0 0 200 140" preserveAspectRatio="none">
      <g class="hotspots__hotspot" role="button" aria-label="Head">
        <circle class="hotspots__shape" cx="55" cy="55" r="24" />
      </g>
    </svg>
  </div>
</div>
```

Interactive behaviours for plain HTML come from the vanilla runtime (data-uikit-* attributes): https://design.freecodecamp.org/registry/vanilla.md - or download https://design.freecodecamp.org/cdn/uikit.global.js once and self-host it (do not hotlink).

## For coding agents

This library is distributed as copyable source, not an npm package. Start at https://design.freecodecamp.org/registry/starter.md, discover components via https://design.freecodecamp.org/llms.txt, and copy files into the consuming project. Keep token names intact; recolour by editing the copied tokens.css.
