# Tile matcher

> Data-driven memory / matching game for interactive curricula. Builds a deck from pairs, flips tiles to reveal, and fires callbacks on match and completion.

- Category: game
- Status: beta (since 0.2.0)
- Tokens: --dur-base, --ease-out, --success-color, --success-background, --highlight-color, --highlight-background
- Playground: https://design.freecodecamp.org/playground#tile-matcher
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `TileMatcher.tsx` → `src/ui/tile-matcher/TileMatcher.tsx` (raw: https://design.freecodecamp.org/registry/tile-matcher/TileMatcher.tsx)
  - `tile-matcher.css` → `src/ui/tile-matcher/tile-matcher.css` (raw: https://design.freecodecamp.org/registry/tile-matcher/tile-matcher.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/tile-matcher/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/tile-matcher/tile-matcher.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

A concentration-style game for reinforcement drills. Supply a list of `pairs`;
the deck size is `2 × pairs.length`. Give a pair **one** face for a classic
identical match, or **two** faces for a related match (term ↔ definition,
image ↔ label). Faces accept any node - text, an `<img>`, `<Image>`, or an icon.

## Keyboard

| Key           | Action                     |
| ------------- | -------------------------- |
| Tab           | Moves focus between tiles. |
| Space / Enter | Flips the focused tile.    |

## Accessibility

Each tile is a native `<button>` with an `aria-label` that reads "Hidden tile"
while face-down and the face content once revealed. Matched tiles are
`disabled` and drop out of the tab order. A visually-hidden `aria-live`
region announces matches and board completion. The flip animation is
suppressed under `prefers-reduced-motion`.

## Example

```tsx
import { TileMatcher } from './ui/tile-matcher/TileMatcher';

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

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

## Props

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

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

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

## HTML / vanilla variant

```html
<!-- TileMatcher is a stateful React component.
     Use the React package for curriculum embeds. -->
<div class="tile-matcher">
  <div class="tile-matcher__grid">
    <button class="tile-matcher__tile" data-state="down">…</button>
  </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.
