# Tile matcher

Data-driven memory / matching game for interactive curricula. Flip to reveal, match pairs, fire callbacks.

Preview: https://design.freecodecamp.org/playground#tile-matcher

## 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/tile-matcher/tile-matcher.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 { TileMatcher, type TileMatcherPair } from './ui/tile-matcher/TileMatcher';

const pairs: TileMatcherPair[] = [
  { id: 'html', faces: ['HTML', 'Structure'] },
  { id: 'css', faces: ['CSS', 'Style'] },
  { id: 'js', faces: ['JS', 'Behavior'] }
];

export function Drill() {
  return (
    <TileMatcher
      pairs={pairs}
      columns={3}
      seed={1}
      onMatch={id => console.log('matched', id)}
      onComplete={({ moves }) => console.log('done in', moves, 'moves')}
    />
  );
}
```

## Component source

### src/ui/tile-matcher/TileMatcher.tsx

Source: https://design.freecodecamp.org/registry/tile-matcher/TileMatcher.tsx

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

export type TileFace =
  | React.ReactNode
  | {
      /** Tile content: text, `<img>`, `<Image>`, an icon - any node. */
      content: React.ReactNode;
      /** Accessible name, used when `content` is non-text (e.g. an image). */
      label?: string;
    };

export interface TileMatcherPair {
  /** Stable pair identity. Two tiles match when their pair `id` is equal. */
  id: string;
  /**
   * One face → duplicated into an identical pair (classic concentration).
   * Two faces → a related pair (e.g. term ↔ definition).
   */
  faces: [TileFace] | [TileFace, TileFace];
}

export interface TileMatcherProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'onChange'
> {
  /** Deck definition. Tile count is `2 × pairs.length`. */
  pairs: TileMatcherPair[];
  /** Fixed column count. Omit for a responsive auto-fit grid. */
  columns?: number;
  /** Flip animation on reveal. `false` swaps faces instantly. Default `true`. */
  animateFlip?: boolean;
  /** Start tiles face-down (memory game). `false` shows every face. Default `true`. */
  faceDown?: boolean;
  /** Delay before a mismatched pair flips back, in ms. Default `900`. */
  mismatchDelay?: number;
  /** Lock the whole board (no flips). */
  disabled?: boolean;
  /** Shuffle the deck. Default `true`. */
  shuffle?: boolean;
  /** Seed for a deterministic shuffle (tests, visual snapshots). */
  seed?: number;
  /** Fires when a pair is matched. */
  onMatch?: (pairId: string, tileIds: [string, string]) => void;
  /** Fires when two flipped tiles do not match. */
  onMismatch?: (tileIds: [string, string]) => void;
  /** Fires once every pair is matched. */
  onComplete?: (stats: { moves: number; matches: number }) => void;
}

interface Tile {
  tileId: string;
  pairId: string;
  content: React.ReactNode;
  label?: string;
}

function isFaceObject(
  face: TileFace
): face is { content: React.ReactNode; label?: string } {
  return (
    typeof face === 'object' &&
    face !== null &&
    !React.isValidElement(face) &&
    !Array.isArray(face) &&
    'content' in face
  );
}

/** Deterministic PRNG (mulberry32) for seeded shuffles. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function buildDeck(
  pairs: TileMatcherPair[],
  shuffle: boolean,
  seed?: number
): Tile[] {
  const tiles: Tile[] = [];
  for (const pair of pairs) {
    const faces =
      pair.faces.length === 1 ? [pair.faces[0], pair.faces[0]] : pair.faces;
    faces.forEach((face, i) => {
      tiles.push({
        tileId: `${pair.id}#${i}`,
        pairId: pair.id,
        content: isFaceObject(face) ? face.content : face,
        label: isFaceObject(face) ? face.label : undefined
      });
    });
  }
  if (!shuffle) return tiles;
  const rng = seed === undefined ? Math.random : mulberry32(seed);
  for (let i = tiles.length - 1; i > 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    const tmp = tiles[i] as Tile;
    tiles[i] = tiles[j] as Tile;
    tiles[j] = tmp;
  }
  return tiles;
}

function tileLabel(tile: Tile, revealed: boolean): string {
  if (!revealed) return 'Hidden tile';
  if (tile.label) return tile.label;
  if (typeof tile.content === 'string' || typeof tile.content === 'number') {
    return String(tile.content);
  }
  return 'Tile';
}

export const TileMatcher = forwardRef<HTMLDivElement, TileMatcherProps>(
  (
    {
      pairs,
      columns,
      animateFlip = true,
      faceDown = true,
      mismatchDelay = 900,
      disabled = false,
      shuffle = true,
      seed,
      onMatch,
      onMismatch,
      onComplete,
      className = '',
      style,
      ...rest
    },
    ref
  ) => {
    const deck = useMemo(
      () => buildDeck(pairs, shuffle, seed),
      [pairs, shuffle, seed]
    );

    const [flipped, setFlipped] = useState<string[]>([]);
    const [matched, setMatched] = useState<Set<string>>(new Set());
    const [moves, setMoves] = useState(0);
    const [locked, setLocked] = useState(false);
    const [announcement, setAnnouncement] = useState('');

    const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
    useEffect(
      () => () => {
        if (timeoutRef.current !== null) clearTimeout(timeoutRef.current);
      },
      []
    );

    const completedRef = useRef(false);
    useEffect(() => {
      if (
        pairs.length > 0 &&
        matched.size === pairs.length &&
        !completedRef.current
      ) {
        completedRef.current = true;
        setAnnouncement('Board complete');
        onComplete?.({ moves, matches: matched.size });
      }
    }, [matched, pairs.length, moves, onComplete]);

    const handleFlip = useCallback(
      (tile: Tile) => {
        if (
          disabled ||
          locked ||
          matched.has(tile.pairId) ||
          flipped.includes(tile.tileId) ||
          flipped.length === 2
        ) {
          return;
        }

        const next = [...flipped, tile.tileId];
        setFlipped(next);
        if (next.length < 2) return;

        setMoves(m => m + 1);
        const [aId, bId] = next as [string, string];
        const a = deck.find(t => t.tileId === aId);
        const b = deck.find(t => t.tileId === bId);

        if (a && b && a.pairId === b.pairId) {
          setMatched(prev => new Set(prev).add(a.pairId));
          setFlipped([]);
          setAnnouncement(`Matched: ${tileLabel(a, true)}`);
          onMatch?.(a.pairId, [aId, bId]);
        } else {
          setLocked(true);
          setAnnouncement('No match');
          onMismatch?.([aId, bId]);
          timeoutRef.current = setTimeout(() => {
            setFlipped([]);
            setLocked(false);
            timeoutRef.current = null;
          }, mismatchDelay);
        }
      },
      [
        deck,
        disabled,
        locked,
        matched,
        flipped,
        mismatchDelay,
        onMatch,
        onMismatch
      ]
    );

    const classes = [
      'tile-matcher',
      !animateFlip && 'tile-matcher--no-flip',
      !faceDown && 'tile-matcher--open',
      className
    ]
      .filter(Boolean)
      .join(' ');

    const gridStyle = columns
      ? ({ '--tm-cols': String(columns) } as React.CSSProperties)
      : undefined;

    return (
      <div
        ref={ref}
        className={classes}
        aria-disabled={disabled || undefined}
        style={style}
        {...rest}
      >
        <div className='tile-matcher__grid' style={gridStyle}>
          {deck.map(tile => {
            const isMatched = matched.has(tile.pairId);
            const isFlipped = flipped.includes(tile.tileId);
            const revealed = !faceDown || isFlipped || isMatched;
            const state = isMatched ? 'matched' : revealed ? 'up' : 'down';
            return (
              <button
                key={tile.tileId}
                type='button'
                className='tile-matcher__tile'
                data-state={state}
                data-pair-id={tile.pairId}
                aria-label={tileLabel(tile, revealed)}
                aria-pressed={isFlipped}
                disabled={disabled || isMatched}
                onClick={() => handleFlip(tile)}
              >
                <span className='tile-matcher__inner'>
                  <span className='tile-matcher__face tile-matcher__face--back'>
                    <span className='tile-matcher__cover' aria-hidden='true' />
                  </span>
                  <span className='tile-matcher__face tile-matcher__face--front'>
                    {tile.content}
                  </span>
                </span>
              </button>
            );
          })}
        </div>
        <span
          className='tile-matcher__sr-status'
          role='status'
          aria-live='polite'
        >
          {announcement}
        </span>
      </div>
    );
  }
);
TileMatcher.displayName = 'TileMatcher';
```

### src/ui/tile-matcher/tile-matcher.css

Source: https://design.freecodecamp.org/registry/tile-matcher/tile-matcher.css

```css
/* Tile Matcher - interactive memory / matching game */
.tile-matcher {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.tile-matcher__grid {
  display: grid;
  grid-template-columns: repeat(var(--tm-cols, auto-fit), minmax(96px, 1fr));
  gap: var(--space-3);
}
.tile-matcher__tile {
  position: relative;
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  background: transparent;
  perspective: 800px;
  cursor: pointer;
}
.tile-matcher__tile:disabled {
  cursor: default;
}
.tile-matcher__tile:focus-visible {
  outline: var(--focus-outline-width) solid var(--focus-outline-color);
  outline-offset: 2px;
}
.tile-matcher__inner {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform var(--dur-base) var(--ease-out);
}
.tile-matcher__tile[data-state='up'] .tile-matcher__inner,
.tile-matcher__tile[data-state='matched'] .tile-matcher__inner {
  transform: rotateY(180deg);
}
.tile-matcher__face {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--space-3);
  border: var(--border-width-default) solid var(--foreground-quaternary);
  backface-visibility: hidden;
  font-size: var(--fs-md);
  font-weight: var(--fw-bold);
  line-height: var(--lh-snug);
  overflow: hidden;
}
.tile-matcher__face--back {
  background: var(--background-tertiary);
  color: var(--foreground-secondary);
}
.tile-matcher__face--front {
  background: var(--background-secondary);
  color: var(--foreground-primary);
  transform: rotateY(180deg);
}
.tile-matcher__cover {
  width: 40%;
  height: 40%;
  border: var(--border-width-thick) solid var(--foreground-quaternary);
  border-radius: 50%;
}
.tile-matcher__tile:hover:not(:disabled) .tile-matcher__face--back {
  border-color: var(--highlight-color);
  color: var(--foreground-primary);
}
.tile-matcher__tile[data-state='up'] .tile-matcher__face--front {
  border-color: var(--highlight-color);
  background: var(--highlight-background);
}
.tile-matcher__tile[data-state='matched'] .tile-matcher__face--front {
  border-color: var(--success-color);
  background: var(--success-background);
  color: var(--success-color);
}
.tile-matcher__face--front img,
.tile-matcher__face--front svg {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}
/* No-flip mode: swap faces instantly, no 3D rotation. */
.tile-matcher--no-flip .tile-matcher__inner {
  transform: none;
  transition: none;
}
.tile-matcher--no-flip
  .tile-matcher__tile[data-state='up']
  .tile-matcher__inner,
.tile-matcher--no-flip
  .tile-matcher__tile[data-state='matched']
  .tile-matcher__inner {
  transform: none;
}
.tile-matcher--no-flip .tile-matcher__face--front {
  transform: none;
}
.tile-matcher--no-flip
  .tile-matcher__tile[data-state='down']
  .tile-matcher__face--front,
.tile-matcher--open
  .tile-matcher__tile[data-state='down']
  .tile-matcher__face--front {
  opacity: 0;
}
.tile-matcher--no-flip
  .tile-matcher__tile[data-state='down']
  .tile-matcher__face--back {
  opacity: 1;
}
/* Faces-shown mode: front always visible; back only masks the down state. */
.tile-matcher--open .tile-matcher__inner {
  transform: none;
}
.tile-matcher--open .tile-matcher__face--front {
  transform: none;
}
.tile-matcher--open
  .tile-matcher__tile[data-state='down']
  .tile-matcher__face--back {
  opacity: 0;
}
.tile-matcher__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) {
  .tile-matcher__inner {
    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;
}
```

## 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

