# Tiles

A dim field of small tiles. The four tiles under the pointer light up to white and the rings around them step down in a square of light, cell by cell.

- Slug: `tiles` · Category: grids · Free (MIT) · v1.1.0
- What moves: The four tiles under the pointer light up and the rings around them step down in a square of light, cell by cell; all dim again when it leaves.
- Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: static
- Aliases: pixels, mosaic, lantern · Use cases: background, card, hero · Tags: grid, tiles, light
- Rails: Measured 2026-10-18 for 1.1.0: 2.65 KB js, p95 10.58 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 2464

## Tokens

- `--texture-ink` (color, default `var(--foreground)`): Ink of the tiles
- `--texture-field` (color, default `var(--background)`): The panel behind them

## Placement

**Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative.

### contained

A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it.

```css
.host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; }
```

```tsx
<a className="host" href="/s/tiles">
  <Tiles />
  <span className="sr-only">Tiles</span>
</a>
```

### background

A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to.

```css
.section { position: relative; }
.section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.section > .content { position: relative; z-index: 1; }
```

```tsx
<section className="section">
  <div className="host" aria-hidden="true"><Tiles /></div>
  <div className="content">…</div>
</section>
```

## Install

- MCP: `get_texture({ ref: "tiles" })` (server: /mcp)
- Registry: `npx shadcn@latest add @textures/tiles` (React; the @textures registry in components.json, see below)
- Markdown: /s/tiles.md (free code included; Pro with an API key as the bearer token)
- Prompt: "Implement this hover animation from Textures. @/s/tiles"
- After installing: view the host at the sizes it will actually appear in, mobile and desktop.
- Suggested commit: `feat: add Tiles hover texture (textures.dev/s/tiles)` (a suggestion the agent and the human can edit, never a write).

## Code

Write these files into the project, then mount the component (React) or call the attach function (vanilla) on a host with position: relative and a size. Pick one variant.

### React

### `textures/tiles/react/Texture.tsx`

```tsx
// Tiles — a hover texture from textures.dev/s/tiles?via=code
// More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt
// License: textures.dev/license (MIT for free Textures; per-person license for Pro)

"use client";

import { useEffect, useId, useRef, useSyncExternalStore, type CSSProperties } from "react";
import { engine } from "./engine";
import { BASE, COLS, DX, DY, EASE_MS, FIELD_H, FIELD_W, H, LEVELS, POOL, RINGS, ROWS, TILE_H, TILE_W, W, X0, Y0 } from "./geometry";

/**
 * Tiles (Figma 16:4445): a dim field of small tiles. The pointer snaps to the nearest corner
 * between four tiles; that 2 × 2 block lights up to full ink and the rings around it take the
 * design's levels by whole tiles (80, 70, 35, 15, 10, 8 %), a square of light that steps from
 * cell to cell as the pointer moves and dims again when it leaves. The static field is one
 * pattern-filled rect (zero nodes); only the tiles inside the glow are elements, each easing its
 * own opacity. Opacity only; at rest nothing runs.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type TilesProps = {
  tokens?: Partial<Record<"--texture-ink" | "--texture-field", string>>;
  className?: string;
  static?: boolean;
};

type Slot = { cell: number; o: number; t: number; i: number };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** The live tile's own opacity that, painted over the resting field, composes to `level`. */
const over = (level: number) => Math.max(0, (level - BASE) / (1 - BASE));
/** Ring of tile (c, r) around the 2 × 2 block whose top-left tile is (bc, br). */
const ring = (c: number, r: number, bc: number, br: number) => Math.max(c < bc ? bc - c : c > bc + 1 ? c - bc - 1 : 0, r < br ? br - r : r > br + 1 ? r - br - 1 : 0);

const subscribeNever = () => () => {};

export function Tiles({ tokens, className = "", static: isStatic = false }: TilesProps) {
  const ref = useRef<HTMLDivElement>(null);
  const tiles = useRef<(SVGRectElement | null)[]>([]);
  // the pool is invisible at rest, so it is not in the server HTML: it mounts on the client, and only when the engine runs
  const poolLive = useSyncExternalStore(subscribeNever, () => true, () => false) && !isStatic;
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const patternId = `tiles-field-${uid}`;

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, o: 0, t: 0, i }));
    const byCell = new Map<number, Slot>();
    const acquire = (cell: number): Slot | null => {
      const have = byCell.get(cell);
      if (have) return have;
      // a free slot, or else the dimmest slot that is already fading out (fast sweeps light more tiles than fade in time)
      let s = slots.find((x) => x.cell === -1) ?? null;
      if (!s) {
        for (const x of slots) if (x.t === 0 && (!s || x.o < s.o)) s = x;
        if (!s) return null;
        byCell.delete(s.cell);
      }
      s.cell = cell;
      s.o = s.t = 0;
      byCell.set(cell, s);
      const el = tiles.current[s.i];
      if (el) {
        el.setAttribute("x", (X0 + (cell % COLS) * DX).toFixed(2));
        el.setAttribute("y", (Y0 + Math.floor(cell / COLS) * DY).toFixed(2));
      }
      return s;
    };
    return engine(
      root,
      ({ x, y, active, reduced }, dt) => {
        // the 2 × 2 block whose shared corner is nearest the pointer
        const bc = clamp(Math.round((x - X0 - TILE_W / 2 - DX / 2) / DX), 0, COLS - 2);
        const br = clamp(Math.round((y - Y0 - TILE_H / 2 - DY / 2) / DY), 0, ROWS - 2);
        for (const s of byCell.values()) s.t = 0;
        if (active > 0.001) {
          for (let r = Math.max(0, br - RINGS); r <= Math.min(ROWS - 1, br + 1 + RINGS); r++) {
            for (let c = Math.max(0, bc - RINGS); c <= Math.min(COLS - 1, bc + 1 + RINGS); c++) {
              const s = acquire(r * COLS + c);
              if (s) s.t = over(LEVELS[ring(c, r, bc, br)]!) * active;
            }
          }
        }
        const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
        let more = false;
        for (const s of [...byCell.values()]) {
          const next = s.o + (s.t - s.o) * k;
          const done = Math.abs(next - s.t) < 0.002;
          s.o = done ? s.t : next;
          if (!done) more = true;
          const el = tiles.current[s.i];
          if (el) el.style.opacity = s.o.toFixed(3);
          if (done && s.t === 0) {
            byCell.delete(s.cell);
            s.cell = -1;
          }
        }
        return more;
      },
      [160, 112],
    );
  }, [isStatic]);

  const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)" } as CSSProperties;
  return (
    <div ref={ref} style={style} data-texture="tiles" className={`relative isolate h-full w-full overflow-clip [contain:layout_paint_style] [container-type:size] ${className}`}>
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false" fill="currentColor" className="absolute inset-0 block h-full w-full">
        <defs>
          <pattern id={patternId} patternUnits="userSpaceOnUse" x={X0} y={Y0} width={DX} height={DY}>
            <rect width={TILE_W} height={TILE_H} fill="currentColor" style={{ opacity: BASE }} />
          </pattern>
        </defs>
        <rect x={X0} y={Y0} width={FIELD_W.toFixed(2)} height={FIELD_H} fill={`url(#${patternId})`} />
        <g fill="currentColor">
          {poolLive ? Array.from({ length: POOL }, (_, i) => (
            <rect
              key={i}
              ref={(el) => {
                tiles.current[i] = el;
              }}
              width={TILE_W}
              height={TILE_H}
              style={{ opacity: 0 }}
            />
          )) : null}
        </g>
      </svg>
    </div>
  );
}

export default Tiles;
```

### `textures/tiles/react/engine.ts`

```ts
// Tiles — a hover texture from textures.dev/s/tiles?via=code
// More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt
// License: textures.dev/license (MIT for free Textures; per-person license for Pro)

/**
 * Pointer engine shared by the panel Textures. Reads the panel's box once on pointer enter (and on
 * focus, resize and, while hovered, scroll), never in the move handler; maps the pointer into the 732 × 516 panel; fades
 * an on/off weight on a short spring; runs one requestAnimationFrame loop that stops when the
 * Texture settles. Keyboard focus on the host counts as a pointer at the centre. Under reduced
 * motion or a slow display everything snaps. Below min_box the Texture stays static.
 *
 * Mobile motion (textures.dev, 2026-10-18): a screen without hover, or with a coarse pointer, has no hover to answer,
 * so there the Texture plays by itself while it is on screen: the pointer wanders on a smooth loop at AMBIENT_SPEED
 * times the desktop pace, phase-offset per instance so a page never pulses in sync; it pauses off screen and in a
 * hidden tab and resumes at once. Capability media queries, re-read on change (a mouse plugged into a tablet); a touch
 * never counts as a hover; reduced motion keeps the resting frame. Desktop behaviour is untouched.
 */
export type Pointer = { x: number; y: number; active: number; reduced: boolean };

/** Mobile motion tokens: the pace multiplier against the desktop feel (1.25–2 is sane) and the loop's base period in seconds (3 s at 1.5×). */
export const AMBIENT_SPEED = 1.5;
export const AMBIENT_CYCLE = 4.5;
export type EngineOptions = { ambientSpeed?: number };

export function engine(root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}): () => void {
  const svg = root.querySelector("svg");
  if (!svg) return () => {};
  const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches;
  // ambient: on when the screen has no hover (or a coarse pointer) and the Texture is on screen in a visible tab;
  // data-motion="ambient" | "hover" on an ancestor forces it (the backoffice preview toggle, tests)
  const touchMq = matchMedia("(hover: none), (pointer: coarse)");
  const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null;
  const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches);
  const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1);
  const w = (2 * Math.PI) / AMBIENT_CYCLE;
  const phase = Math.random() * 2 * Math.PI;
  let onScreen = false;
  let ambient = false;
  let clock = 0; // the loop's own time, so pauses leave no seam
  let hold = 0; // until when a synthetic drive (a demo sweep, the site's own autoplay) owns the pointer
  let box: { left: number; top: number; s: number; ox: number; oy: number } | null = null;
  let px = 366;
  let py = 258;
  let cx = 0; // last pointer position on screen, so a scroll under a still pointer re-maps it
  let cy = 0;
  let active = 0;
  let target = 0;
  let vel = 0;
  let frame = 0;
  let last = 0;
  const measure = () => {
    const r = svg.getBoundingClientRect();
    if (r.width < min[0] || r.height < min[1]) {
      box = null;
      return;
    }
    const s = Math.max(r.width / 732, r.height / 516);
    box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 };
  };
  const tick = (now: number) => {
    const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30);
    last = now;
    let moving = false;
    const reduced = mq();
    if (ambient && now > hold) {
      if (reduced) target = 0; // the resting frame, never a loop
      else {
        clock += dt * speed;
        const t = clock;
        px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase));
        py = 258 + 258 * (0.72 * Math.sin(0.75 * w * t + 0.8 + phase) + 0.2 * Math.sin(1.37 * w * t + 2.1 + phase));
        target = 1;
        moving = true;
      }
    }
    if (reduced) {
      active = target;
      vel = 0;
    } else {
      // the exact critically damped spring (ω = 30/s: stiffness 900, damping 60), stable at any frame length. The old
      // explicit step diverged once a frame took longer than ~22 ms (a reload or a scroll with many Textures on screen),
      // and `active` grew without bound: the giant bars (Lukas, 2026-10-17)
      const decay = Math.exp(-30 * dt);
      const off = active - target;
      const lead = vel + 30 * off;
      active = target + (off + lead * dt) * decay;
      vel = (vel - 30 * lead * dt) * decay;
      if (Math.abs(vel) < 0.02 && Math.abs(active - target) < 0.02) {
        active = target;
        vel = 0;
      } else moving = true;
    }
    const more = draw({ x: px, y: py, active, reduced }, dt);
    if (moving || more) frame = requestAnimationFrame(tick);
    else {
      frame = 0;
      last = 0;
    }
  };
  const wake = () => {
    if (!frame) frame = requestAnimationFrame(tick);
  };
  const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches; // a hybrid (hover: hover) keeps its finger
  const onEnter = (e: PointerEvent) => {
    if (touchIsAmbient(e)) return;
    measure();
  };
  const onMove = (e: PointerEvent) => {
    if (touchIsAmbient(e) || !box) return;
    if (!e.isTrusted) hold = performance.now() + 1000;
    cx = e.clientX;
    cy = e.clientY;
    px = (cx - box.left - box.ox) / box.s;
    py = (cy - box.top - box.oy) / box.s;
    target = 1;
    wake();
  };
  const onLeave = (e?: Event) => {
    if (e && touchIsAmbient(e)) return;
    if (e && !e.isTrusted) hold = 0; // a synthetic drive letting go hands the pointer straight back to the loop
    if (ambient && performance.now() > hold) return; // the loop keeps going
    target = 0;
    wake();
  };
  const onFocus = () => {
    measure();
    if (!box) return;
    px = 366;
    py = 258;
    target = 1;
    wake();
  };
  const onResize = () => {
    if (box || ambient) measure();
    if (ambient && !box) ambient = false; // shrunk under min_box: the loop ends, the spring rests it
    settle();
  };
  // the page scrolled under a hovering pointer: the panel moved, the pointer did not, so re-measure and re-map
  const onScroll = () => {
    if (!box || target !== 1 || (ambient && performance.now() > hold)) return;
    measure();
    if (!box) return;
    px = (cx - box.left - box.ox) / box.s;
    py = (cy - box.top - box.oy) / box.s;
    wake();
  };
  const settle = () => {
    const next = onScreen && !document.hidden && wantsAmbient();
    if (next === ambient) return;
    ambient = next;
    if (ambient) {
      measure();
      if (!box) {
        ambient = false;
        return;
      }
      wake();
    } else {
      target = 0;
      wake();
    }
  };
  const io = typeof IntersectionObserver === "function" ? new IntersectionObserver(([entry]) => {
    onScreen = !!entry?.isIntersecting;
    settle();
  }, { threshold: 0.2 }) : null;
  io?.observe(svg);
  if (!io) {
    onScreen = true;
    settle();
  }
  const onVisibility = () => settle();
  const onCapability = () => settle();
  const reducedMq = matchMedia("(prefers-reduced-motion: reduce)");
  const onReduced = () => wake(); // reduce switched off: the loop (or a settled frame) picks up again
  document.addEventListener("visibilitychange", onVisibility);
  touchMq.addEventListener?.("change", onCapability); // Safari 14+ has it; older ones keep the hover-only behaviour
  reducedMq.addEventListener?.("change", onReduced);
  const host: Element = root.closest("a, button, [tabindex]") ?? root.parentElement ?? root;
  root.addEventListener("pointerenter", onEnter);
  root.addEventListener("pointermove", onMove, { passive: true });
  root.addEventListener("pointerleave", onLeave);
  root.addEventListener("pointercancel", onLeave);
  host.addEventListener("focusin", onFocus);
  host.addEventListener("focusout", onLeave);
  window.addEventListener("resize", onResize, { passive: true });
  window.addEventListener("scroll", onScroll, { passive: true, capture: true });
  return () => {
    root.removeEventListener("pointerenter", onEnter);
    root.removeEventListener("pointermove", onMove);
    root.removeEventListener("pointerleave", onLeave);
    root.removeEventListener("pointercancel", onLeave);
    host.removeEventListener("focusin", onFocus);
    host.removeEventListener("focusout", onLeave);
    window.removeEventListener("resize", onResize);
    window.removeEventListener("scroll", onScroll, { capture: true });
    document.removeEventListener("visibilitychange", onVisibility);
    touchMq.removeEventListener?.("change", onCapability);
    reducedMq.removeEventListener?.("change", onReduced);
    io?.disconnect();
    if (frame) cancelAnimationFrame(frame);
  };
}
```

### `textures/tiles/react/geometry.ts`

```ts
// Tiles — a hover texture from textures.dev/s/tiles?via=code
// More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt
// License: textures.dev/license (MIT for free Textures; per-person license for Pro)

/**
 * Tiles geometry (Figma 16:4445, "005"): a 33 × 25 field of 18 × 16 tiles resting at 5 %. The
 * pointer snaps to the nearest corner between four tiles; that 2 × 2 block goes to full ink and the
 * rings around it take the design's levels by whole tiles (LEVELS by ring, then BASE). The static
 * field is one pattern-filled rect (zero nodes); only the tiles inside the glow are elements, drawn
 * over the field at the opacity that composes to the design's level. EASE_MS is each tile's time
 * constant, so the glow steps from cell to cell.
 */
export const W = 732;
export const H = 516;
export const COLS = 33;
export const ROWS = 25;
export const X0 = 11;
export const Y0 = 11;
export const DX = 21.63;
export const DY = 20;
export const TILE_W = 18.03;
export const TILE_H = 16;
export const BASE = 0.05;
export const LEVELS = [1, 0.8, 0.7, 0.35, 0.15, 0.1, 0.08];
export const RINGS = LEVELS.length - 1;
export const POOL = 320;
export const EASE_MS = 45;
export const FIELD_W = (COLS - 1) * DX + TILE_W;
export const FIELD_H = (ROWS - 1) * DY + TILE_H;
```

### Vanilla

### `textures/tiles/vanilla/texture.js`

```js
/* Tiles — a hover texture from textures.dev/s/tiles?via=code */
/* More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt */
/* License: textures.dev/license (MIT for free Textures; per-person license for Pro) */

import { engine } from "./engine.js";
import { BASE, COLS, DX, DY, EASE_MS, LEVELS, POOL, RINGS, ROWS, TILE_H, TILE_W, X0, Y0 } from "./geometry.js";

/**
 * Tiles: a dim field of small tiles. The pointer snaps to the nearest corner between four tiles;
 * that 2 × 2 block lights up and the rings around it take the design's levels by whole tiles. The
 * static field is one pattern-filled rect; only the tiles inside the glow are elements, each
 * easing its own opacity over the field. Opacity only.
 */
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const over = (level) => Math.max(0, (level - BASE) / (1 - BASE));
const ring = (c, r, bc, br) => Math.max(c < bc ? bc - c : c > bc + 1 ? c - bc - 1 : 0, r < br ? br - r : r > br + 1 ? r - br - 1 : 0);

export function tiles(root) {
  const els = [...root.querySelectorAll("rect.t")];
  const slots = Array.from({ length: POOL }, (_, i) => ({ cell: -1, o: 0, t: 0, i }));
  const byCell = new Map();
  const acquire = (cell) => {
    const have = byCell.get(cell);
    if (have) return have;
    // a free slot, or else the dimmest slot that is already fading out (fast sweeps light more tiles than fade in time)
    let s = slots.find((x) => x.cell === -1) ?? null;
    if (!s) {
      for (const x of slots) if (x.t === 0 && (!s || x.o < s.o)) s = x;
      if (!s) return null;
      byCell.delete(s.cell);
    }
    s.cell = cell;
    s.o = s.t = 0;
    byCell.set(cell, s);
    els[s.i].setAttribute("x", (X0 + (cell % COLS) * DX).toFixed(2));
    els[s.i].setAttribute("y", (Y0 + Math.floor(cell / COLS) * DY).toFixed(2));
    return s;
  };
  return engine(
    root,
    ({ x, y, active, reduced }, dt) => {
      const bc = clamp(Math.round((x - X0 - TILE_W / 2 - DX / 2) / DX), 0, COLS - 2);
      const br = clamp(Math.round((y - Y0 - TILE_H / 2 - DY / 2) / DY), 0, ROWS - 2);
      for (const s of byCell.values()) s.t = 0;
      if (active > 0.001) {
        for (let r = Math.max(0, br - RINGS); r <= Math.min(ROWS - 1, br + 1 + RINGS); r++) {
          for (let c = Math.max(0, bc - RINGS); c <= Math.min(COLS - 1, bc + 1 + RINGS); c++) {
            const s = acquire(r * COLS + c);
            if (s) s.t = over(LEVELS[ring(c, r, bc, br)]) * active;
          }
        }
      }
      const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
      let more = false;
      for (const s of [...byCell.values()]) {
        const next = s.o + (s.t - s.o) * k;
        const done = Math.abs(next - s.t) < 0.002;
        s.o = done ? s.t : next;
        if (!done) more = true;
        els[s.i].style.opacity = s.o.toFixed(3);
        if (done && s.t === 0) {
          byCell.delete(s.cell);
          s.cell = -1;
        }
      }
      return more;
    },
    [160, 112],
  );
}

for (const root of document.querySelectorAll('[data-texture="tiles"]')) tiles(root);
```

### `textures/tiles/vanilla/engine.js`

```js
/* Tiles — a hover texture from textures.dev/s/tiles?via=code */
/* More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt */
/* License: textures.dev/license (MIT for free Textures; per-person license for Pro) */

/**
 * Pointer engine shared by the panel Textures. Reads the panel's box once on pointer enter (and on
 * focus, resize and, while hovered, scroll), never in the move handler; maps the pointer into the 732 × 516 panel; fades
 * an on/off weight on a short spring; runs one requestAnimationFrame loop that stops when the
 * Texture settles. Keyboard focus on the host counts as a pointer at the centre. Under reduced
 * motion or a slow display everything snaps. Below min_box the Texture stays static.
 *
 * Mobile motion (textures.dev, 2026-10-18): a screen without hover, or with a coarse pointer, has no hover to answer,
 * so there the Texture plays by itself while it is on screen: the pointer wanders on a smooth loop at AMBIENT_SPEED
 * times the desktop pace, phase-offset per instance so a page never pulses in sync; it pauses off screen and in a
 * hidden tab and resumes at once. Capability media queries, re-read on change (a mouse plugged into a tablet); a touch
 * never counts as a hover; reduced motion keeps the resting frame. Desktop behaviour is untouched.
 */
/** Mobile motion tokens: the pace multiplier against the desktop feel (1.25–2 is sane) and the loop's base period in seconds (3 s at 1.5×). */
export const AMBIENT_SPEED = 1.5;
export const AMBIENT_CYCLE = 4.5;

export function engine(root, draw, min, options = {}) {
  const svg = root.querySelector("svg");
  if (!svg) return () => {};
  const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches;
  // ambient: on when the screen has no hover (or a coarse pointer) and the Texture is on screen in a visible tab;
  // data-motion="ambient" | "hover" on an ancestor forces it (the backoffice preview toggle, tests)
  const touchMq = matchMedia("(hover: none), (pointer: coarse)");
  const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null;
  const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches);
  const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1);
  const w = (2 * Math.PI) / AMBIENT_CYCLE;
  const phase = Math.random() * 2 * Math.PI;
  let onScreen = false;
  let ambient = false;
  let clock = 0; // the loop's own time, so pauses leave no seam
  let hold = 0; // until when a synthetic drive (a demo sweep, the site's own autoplay) owns the pointer
  let box = null;
  let px = 366;
  let py = 258;
  let cx = 0; // last pointer position on screen, so a scroll under a still pointer re-maps it
  let cy = 0;
  let active = 0;
  let target = 0;
  let vel = 0;
  let frame = 0;
  let last = 0;
  const measure = () => {
    const r = svg.getBoundingClientRect();
    if (r.width < min[0] || r.height < min[1]) {
      box = null;
      return;
    }
    const s = Math.max(r.width / 732, r.height / 516);
    box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 };
  };
  const tick = (now) => {
    const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30);
    last = now;
    let moving = false;
    const reduced = mq();
    if (ambient && now > hold) {
      if (reduced) target = 0; // the resting frame, never a loop
      else {
        clock += dt * speed;
        const t = clock;
        px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase));
        py = 258 + 258 * (0.72 * Math.sin(0.75 * w * t + 0.8 + phase) + 0.2 * Math.sin(1.37 * w * t + 2.1 + phase));
        target = 1;
        moving = true;
      }
    }
    if (reduced) {
      active = target;
      vel = 0;
    } else {
      // the exact critically damped spring (ω = 30/s: stiffness 900, damping 60), stable at any frame length. The old
      // explicit step diverged once a frame took longer than ~22 ms (a reload or a scroll with many Textures on screen),
      // and `active` grew without bound: the giant bars (Lukas, 2026-10-17)
      const decay = Math.exp(-30 * dt);
      const off = active - target;
      const lead = vel + 30 * off;
      active = target + (off + lead * dt) * decay;
      vel = (vel - 30 * lead * dt) * decay;
      if (Math.abs(vel) < 0.02 && Math.abs(active - target) < 0.02) {
        active = target;
        vel = 0;
      } else moving = true;
    }
    const more = draw({ x: px, y: py, active, reduced }, dt);
    if (moving || more) frame = requestAnimationFrame(tick);
    else {
      frame = 0;
      last = 0;
    }
  };
  const wake = () => {
    if (!frame) frame = requestAnimationFrame(tick);
  };
  const touchIsAmbient = (e) => e.pointerType === "touch" && touchMq.matches; // a hybrid (hover: hover) keeps its finger
  const onEnter = (e) => {
    if (touchIsAmbient(e)) return;
    measure();
  };
  const onMove = (e) => {
    if (touchIsAmbient(e) || !box) return;
    if (!e.isTrusted) hold = performance.now() + 1000;
    cx = e.clientX;
    cy = e.clientY;
    px = (cx - box.left - box.ox) / box.s;
    py = (cy - box.top - box.oy) / box.s;
    target = 1;
    wake();
  };
  const onLeave = (e) => {
    if (e && touchIsAmbient(e)) return;
    if (e && !e.isTrusted) hold = 0; // a synthetic drive letting go hands the pointer straight back to the loop
    if (ambient && performance.now() > hold) return; // the loop keeps going
    target = 0;
    wake();
  };
  const onFocus = () => {
    measure();
    if (!box) return;
    px = 366;
    py = 258;
    target = 1;
    wake();
  };
  const onResize = () => {
    if (box || ambient) measure();
    if (ambient && !box) ambient = false; // shrunk under min_box: the loop ends, the spring rests it
    settle();
  };
  // the page scrolled under a hovering pointer: the panel moved, the pointer did not, so re-measure and re-map
  const onScroll = () => {
    if (!box || target !== 1 || (ambient && performance.now() > hold)) return;
    measure();
    if (!box) return;
    px = (cx - box.left - box.ox) / box.s;
    py = (cy - box.top - box.oy) / box.s;
    wake();
  };
  const settle = () => {
    const next = onScreen && !document.hidden && wantsAmbient();
    if (next === ambient) return;
    ambient = next;
    if (ambient) {
      measure();
      if (!box) {
        ambient = false;
        return;
      }
      wake();
    } else {
      target = 0;
      wake();
    }
  };
  const io = typeof IntersectionObserver === "function" ? new IntersectionObserver(([entry]) => {
    onScreen = !!entry?.isIntersecting;
    settle();
  }, { threshold: 0.2 }) : null;
  io?.observe(svg);
  if (!io) {
    onScreen = true;
    settle();
  }
  const onVisibility = () => settle();
  const onCapability = () => settle();
  const reducedMq = matchMedia("(prefers-reduced-motion: reduce)");
  const onReduced = () => wake(); // reduce switched off: the loop (or a settled frame) picks up again
  document.addEventListener("visibilitychange", onVisibility);
  touchMq.addEventListener?.("change", onCapability); // Safari 14+ has it; older ones keep the hover-only behaviour
  reducedMq.addEventListener?.("change", onReduced);
  const host = root.closest("a, button, [tabindex]") ?? root.parentElement ?? root;
  root.addEventListener("pointerenter", onEnter);
  root.addEventListener("pointermove", onMove, { passive: true });
  root.addEventListener("pointerleave", onLeave);
  root.addEventListener("pointercancel", onLeave);
  host.addEventListener("focusin", onFocus);
  host.addEventListener("focusout", onLeave);
  window.addEventListener("resize", onResize, { passive: true });
  window.addEventListener("scroll", onScroll, { passive: true, capture: true });
  return () => {
    root.removeEventListener("pointerenter", onEnter);
    root.removeEventListener("pointermove", onMove);
    root.removeEventListener("pointerleave", onLeave);
    root.removeEventListener("pointercancel", onLeave);
    host.removeEventListener("focusin", onFocus);
    host.removeEventListener("focusout", onLeave);
    window.removeEventListener("resize", onResize);
    window.removeEventListener("scroll", onScroll, { capture: true });
    document.removeEventListener("visibilitychange", onVisibility);
    touchMq.removeEventListener?.("change", onCapability);
    reducedMq.removeEventListener?.("change", onReduced);
    io?.disconnect();
    if (frame) cancelAnimationFrame(frame);
  };
}
```

### `textures/tiles/vanilla/geometry.js`

```js
/* Tiles — a hover texture from textures.dev/s/tiles?via=code */
/* More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt */
/* License: textures.dev/license (MIT for free Textures; per-person license for Pro) */

/**
 * Tiles geometry (Figma 16:4445, "005"): a 33 × 25 field of 18 × 16 tiles resting at 5 %. The
 * pointer snaps to the nearest corner between four tiles; that 2 × 2 block goes to full ink and the
 * rings around it take the design's levels by whole tiles (LEVELS by ring, then BASE). The static
 * field is one pattern-filled rect (zero nodes); only the tiles inside the glow are elements, drawn
 * over the field at the opacity that composes to the design's level. EASE_MS is each tile's time
 * constant, so the glow steps from cell to cell.
 */
export const W = 732;
export const H = 516;
export const COLS = 33;
export const ROWS = 25;
export const X0 = 11;
export const Y0 = 11;
export const DX = 21.63;
export const DY = 20;
export const TILE_W = 18.03;
export const TILE_H = 16;
export const BASE = 0.05;
export const LEVELS = [1, 0.8, 0.7, 0.35, 0.15, 0.1, 0.08];
export const RINGS = LEVELS.length - 1;
export const POOL = 320;
export const EASE_MS = 45;
export const FIELD_W = (COLS - 1) * DX + TILE_W;
export const FIELD_H = (ROWS - 1) * DY + TILE_H;
```

### `textures/tiles/vanilla/texture.css`

```css
/* Tiles — a hover texture from textures.dev/s/tiles?via=code */
/* More like this: npx shadcn@latest add https://textures.dev/r/<slug>.json?via=code · textures.dev/llms.txt */
/* License: textures.dev/license (MIT for free Textures; per-person license for Pro) */

/* Demo host only; not part of the Texture. */
.host { position: relative; display: block; width: 100%; max-width: 48rem; aspect-ratio: 732 / 516; overflow: hidden; border-radius: 4px; }

/* Rail 1: fit anywhere. The Texture sizes from the host; the panel is 732 × 516 and covers the host. */
.texture {
  --texture-ink: var(--foreground);
  --texture-field: var(--background);
  position: relative;
  width: 100%;
  height: 100%;
  isolation: isolate;
  contain: layout paint style;
  container-type: size;
  overflow: clip;
  color: var(--texture-ink);
  background: var(--texture-field);
}
.texture > svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Rail 2: stay light. Motion is CSS transforms on SVG children (and opacity); nothing else animates. */
.texture rect { transform-box: fill-box; transform-origin: center; }

/* Declared degradation: the script reads these media queries and snaps. */
@media (prefers-reduced-motion: reduce) { .texture { --texture-reduced: 1; } }
@media (update: slow) { .texture { --texture-reduced: 1; } }
```. Live preview: /embed/tiles. JSON twin: /s/tiles.json.

## Agent brief

## 1. What it is

A dim field of small tiles. The pointer snaps to the nearest corner between four tiles; that 2 × 2 block lights up to white and the rings around it take the design's levels by whole tiles (80, 70, 35, 15, 10, 8 %), a square of light that steps from cell to cell as the pointer moves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static.

## 2. Behaviour timeline

- **Idle:** the resting field, one pattern-filled rect at 5 %. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the on/off weight springs to 1 in about 80 ms; the block under the pointer goes to full ink and the six rings around it step down to their levels, each tile easing in about 45 ms.
- **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Only the tiles inside the glow are elements (a pool of 320; on a fast sweep the dimmest fading tile is reused); the rest is the pattern.
- **Hover-out:** the weight springs back to 0 and every tile eases back into the field; interruptible at any point.
- **Reduced motion / slow display:** the same states without easing: everything snaps.
- **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover.

## 3. Integration

- Install: write the files `get_texture` returns (React or vanilla), run `npx shadcn@latest add @textures/tiles` (React, with the Textures registry in components.json), or use the code in this Texture's markdown twin (/s/tiles.md).
- The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.**
- Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips.
- Tokens on the root: `--texture-ink` (the tiles) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`.

## 4. Customisation

- Change the two tokens. Do not change the levels, the ring count or the reduced-motion branch.

## 5. Accessibility

- Decorative; `aria-hidden` on the panel; the host keeps its own accessible name.
- Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture.


## Related
Pairs with: Glow (background, pro) · Magnet (hero, pro) — same family, same tokens.
npx shadcn@latest add https://textures.dev/r/glow.json?via=code

## Using Textures from an agent

Textures are curated hover animations delivered as code (React and vanilla). There are four ways in, in this order:

1. **Connected to the Textures MCP server** (https://textures.dev/mcp): call `search_textures` with the user's words, then `get_texture` with the slug you pick, and write the files it returns.
2. **In a project with shadcn (React):** add the registry to `components.json` once, then run `npx shadcn@latest add @textures/<slug>`. For free Textures, no key: `"registries": { "@textures": "https://textures.dev/r/{name}.json" }`. For Pro, send a Textures API key: `"registries": { "@textures": { "url": "https://textures.dev/r/{name}.json", "headers": { "Authorization": "Bearer ${TEXTURES_API_KEY}" } } }` with the key in `TEXTURES_API_KEY` (in `.env.local`).
3. **Not connected, no shadcn:** fetch `https://textures.dev/s/<slug>` as markdown (send `Accept: text/markdown`, or use `https://textures.dev/s/<slug>.md`). Free Textures include their code. For a Pro Texture, send `Authorization: Bearer <key>` with a Textures API key from https://textures.dev/settings/agents (Pro accounts).
4. **A Pro Texture without access:** tell the user it is a Pro Texture (Pro is $19 a month or $180 a year) and give them https://textures.dev/checkout?texture=<slug>. Never retry, guess or reconstruct the code.

Every published Texture is listed at https://textures.dev/llms.txt and https://textures.dev/r/registry.json. Browse them at https://textures.dev/textures. Connect an agent at https://textures.dev/mcp.
