# Magnet

A field of small dots. The pointer is a magnet: the twenty-five nearest dots leave their cells and snap together into one solid block under it, then spring back out when it moves on or leaves.

- Slug: `magnet` · Category: grids · Free (MIT) · v1.0.0
- What moves: Dots near the pointer snap together into a solid block and spring back to the grid when it leaves.
- Triggers: hover, focus, pointer-move · Touch: static · Reduced motion: static
- Aliases: snap, cluster, attract · Use cases: hero, background, card · Tags: dots, grid, magnet
- Rails: Measured 2026-10-09 for 1.1.0: 3.15 KB js, p95 20.53 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 800

## Tokens

- `--shape-ink` (color, default `var(--foreground)`): Ink of the bars and dots
- `--shape-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/magnet">
  <Magnet />
  <span className="sr-only">Magnet</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"><Magnet /></div>
  <div className="content">…</div>
</section>
```

## Install

- CLI: `npx shadcn@latest add @textures/magnet`
- MCP: `get_shape({ ref: "magnet" })` (server: /mcp)
- Prompt: "Implement this hover animation from Textures. @/s/magnet"
- After installing: run `npx textures check` and view the host at the sizes it will actually appear in, mobile and desktop.

## 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/magnet/react/Shape.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useSyncExternalStore, type CSSProperties } from "react";
import { engine } from "./engine";
import { COLS, DOT, DRIFT, DX, DY, FIELD_H, FIELD_W, H, PACK, POOL, PULL, REACH, ROWS, W, X0, Y0 } from "./geometry";

/**
 * Magnet (Figma 16:2321): a field of small dots. The pointer is a magnet: the twenty-five dots
 * nearest it leave their cells and snap together into one solid block under it, and every dot out
 * to eleven cells leans toward it, less with distance, so most of the field answers while the edges
 * stay put. All spring home when it moves on or leaves. The static field is one pattern-filled
 * rect (zero nodes); a mask hides the cells of dots that are away from home; only live dots are
 * elements, each translated by its own spring so the pieces arrive at different times.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type MagnetProps = {
  tokens?: Partial<Record<"--shape-ink" | "--shape-field", string>>;
  className?: string;
  static?: boolean;
};

type Slot = { cell: number; hx: number; hy: number; x: number; y: number; vx: number; vy: number; tx: number; ty: number; k: number; d: number; i: number };

const STIFFNESS = 760;
const ZETA = 0.66;
const SPREAD = 0.35;
const noise = (seed: number) => {
  let t = (seed + 0x6d2b79f5) | 0;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const homeX = (c: number) => X0 + c * DX;
const homeY = (r: number) => Y0 + r * DY;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** The lean of a dot `d` cells from the magnet: PULL just outside the block, fading to none at DRIFT. */
const lean = (d: number) => {
  const u = clamp((DRIFT - d) / (DRIFT - REACH), 0, 1);
  return PULL * u * u * (3 - 2 * u);
};

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

export function Magnet({ tokens, className = "", static: isStatic = false }: MagnetProps) {
  const ref = useRef<HTMLDivElement>(null);
  const dots = useRef<(SVGRectElement | null)[]>([]);
  const masks = 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 = `magnet-dots-${uid}`;
  const maskId = `magnet-hole-${uid}`;

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, hx: 0, hy: 0, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, k: 0, d: 0, i }));
    const byCell = new Map<number, Slot>();
    let prev = -1;
    const acquire = (cell: number): Slot | null => {
      const have = byCell.get(cell);
      if (have) return have;
      // a free slot, or else the homing slot nearest home (fast sweeps touch more dots than settle in time)
      let s = slots.find((t) => t.cell === -1) ?? null;
      if (!s) {
        for (const t of slots) if (t.tx === t.hx && t.ty === t.hy && (!s || Math.hypot(t.x - t.hx, t.y - t.hy) < Math.hypot(s.x - s.hx, s.y - s.hy))) s = t;
        if (!s) return null;
        byCell.delete(s.cell);
      }
      const c = cell % COLS;
      const r = Math.floor(cell / COLS);
      s.cell = cell;
      s.hx = s.x = s.tx = homeX(c);
      s.hy = s.y = s.ty = homeY(r);
      s.vx = s.vy = 0;
      s.k = STIFFNESS * (1 + SPREAD * (noise(cell) * 2 - 1));
      s.d = 2 * ZETA * Math.sqrt(s.k);
      byCell.set(cell, s);
      const m = masks.current[s.i];
      const el = dots.current[s.i];
      if (m) {
        // the mask square is a hair larger than the dot so the pattern dot's anti-aliased fringe is hidden too
        m.setAttribute("x", (s.hx - 0.75).toFixed(2));
        m.setAttribute("y", (s.hy - 0.75).toFixed(2));
        m.style.opacity = "1";
      }
      if (el) {
        el.setAttribute("x", s.hx.toFixed(2));
        el.setAttribute("y", s.hy.toFixed(2));
        el.style.transform = "";
        el.style.opacity = "1";
      }
      return s;
    };
    const release = (s: Slot) => {
      byCell.delete(s.cell);
      s.cell = -1;
      const m = masks.current[s.i];
      const el = dots.current[s.i];
      if (m) m.style.opacity = "0";
      if (el) {
        el.style.opacity = "0";
        el.style.transform = "";
      }
    };
    return engine(
      root,
      ({ x, y, active, reduced }, dt) => {
        const on = active > 0.01;
        const mx = clamp(x, homeX(REACH) + DOT / 2, homeX(COLS - 1 - REACH) + DOT / 2);
        const my = clamp(y, homeY(REACH) + DOT / 2, homeY(ROWS - 1 - REACH) + DOT / 2);
        const cc = on ? Math.round((mx - DOT / 2 - X0) / DX) : -1;
        const cr = on ? Math.round((my - DOT / 2 - Y0) / DY) : -1;
        const key = cr * COLS + cc;
        if (key !== prev) {
          for (const s of byCell.values()) {
            s.tx = s.hx;
            s.ty = s.hy;
          }
          prev = key;
        }
        if (on) {
          for (let i = -DRIFT; i <= DRIFT; i++) {
            for (let j = -DRIFT; j <= DRIFT; j++) {
              const c = cc + i;
              const r = cr + j;
              if (c < 0 || c >= COLS || r < 0 || r >= ROWS) continue;
              const d = Math.hypot(i, j);
              if (d > DRIFT) continue;
              const s = acquire(r * COLS + c);
              if (!s) continue;
              if (Math.abs(i) <= REACH && Math.abs(j) <= REACH) {
                // the block: packed under the magnet
                s.tx = mx + i * PACK - DOT / 2;
                s.ty = my + j * PACK - DOT / 2;
              } else {
                // the field: a lean toward the magnet that fades with distance
                const ox = mx - DOT / 2 - s.hx;
                const oy = my - DOT / 2 - s.hy;
                const len = Math.hypot(ox, oy) || 1;
                const l = lean(d);
                s.tx = s.hx + (ox / len) * l;
                s.ty = s.hy + (oy / len) * l;
              }
            }
          }
        }
        let moving = false;
        for (const s of [...byCell.values()]) {
          if (reduced) {
            s.x = s.tx;
            s.y = s.ty;
            s.vx = s.vy = 0;
          } else {
            s.vx += (-s.k * (s.x - s.tx) - s.d * s.vx) * dt;
            s.vy += (-s.k * (s.y - s.ty) - s.d * s.vy) * dt;
            s.x += s.vx * dt;
            s.y += s.vy * dt;
          }
          const settled = Math.abs(s.x - s.tx) < 0.02 && Math.abs(s.y - s.ty) < 0.02 && Math.abs(s.vx) < 0.5 && Math.abs(s.vy) < 0.5;
          if (settled) {
            s.x = s.tx;
            s.y = s.ty;
            s.vx = s.vy = 0;
            if (s.tx === s.hx && s.ty === s.hy) {
              release(s);
              continue;
            }
          } else moving = true;
          const el = dots.current[s.i];
          if (el) el.style.transform = `translate(${(s.x - s.hx).toFixed(2)}px, ${(s.y - s.hy).toFixed(2)}px)`;
        }
        return moving;
      },
      [160, 112],
    );
  }, [isStatic]);

  const style = { "--shape-ink": "var(--foreground)", "--shape-field": "var(--background)", ...tokens, color: "var(--shape-ink)", background: "var(--shape-field)" } as CSSProperties;
  return (
    <div ref={ref} style={style} data-shape="magnet" 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={DOT} height={DOT} fill="currentColor" />
          </pattern>
          <mask id={maskId}>
            <rect x="0" y="0" width={W} height={H} fill="white" />
            <g fill="black">
              {poolLive ? Array.from({ length: POOL }, (_, i) => (
                <rect
                  key={i}
                  ref={(el) => {
                    masks.current[i] = el;
                  }}
                  width={DOT + 1.5}
                  height={DOT + 1.5}
                  style={{ opacity: 0 }}
                />
              )) : null}
            </g>
          </mask>
        </defs>
        <rect x={X0} y={Y0} width={FIELD_W.toFixed(2)} height={FIELD_H} fill={`url(#${patternId})`} mask={`url(#${maskId})`} />
        <g fill="currentColor">
          {poolLive ? Array.from({ length: POOL }, (_, i) => (
            <rect
              key={i}
              ref={(el) => {
                dots.current[i] = el;
              }}
              width={DOT}
              height={DOT}
              style={{ opacity: 0 }}
            />
          )) : null}
        </g>
      </svg>
    </div>
  );
}

export default Magnet;
```

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

```ts
/**
 * Pointer engine shared by the panel Textures. Reads the panel's box once on pointer enter (and on
 * focus and resize), 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.
 */
export type Pointer = { x: number; y: number; active: number; reduced: boolean };

export function engine(root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number]): () => void {
  const svg = root.querySelector("svg");
  if (!svg) return () => {};
  const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches;
  let box: { left: number; top: number; s: number; ox: number; oy: number } | null = null;
  let px = 366;
  let py = 258;
  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 (reduced) {
      active = target;
      vel = 0;
    } else {
      vel += (-900 * (active - target) - 60 * vel) * dt;
      active += vel * dt;
      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 onEnter = () => measure();
  const onMove = (e: PointerEvent) => {
    if (!box) return;
    px = (e.clientX - box.left - box.ox) / box.s;
    py = (e.clientY - box.top - box.oy) / box.s;
    target = 1;
    wake();
  };
  const onLeave = () => {
    target = 0;
    wake();
  };
  const onFocus = () => {
    measure();
    if (!box) return;
    px = 366;
    py = 258;
    target = 1;
    wake();
  };
  const onResize = () => {
    if (box) measure();
  };
  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 });
  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);
    if (frame) cancelAnimationFrame(frame);
  };
}
```

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

```ts
/**
 * Magnet geometry (Figma 16:2321): a 51 × 35 field of 4 px dots. The twenty-five dots within REACH
 * cells snap into a block under the pointer; every dot out to DRIFT cells leans toward it by up to
 * PULL px, less with distance and nothing at DRIFT, so most of the field answers while the edges
 * stay put. The static field is one pattern-filled rect (zero nodes); only moving dots are
 * elements (a pool of POOL, each over a mask square that hides its resting twin).
 */
export const W = 732;
export const H = 516;
export const COLS = 51;
export const ROWS = 35;
export const X0 = 18;
export const Y0 = 18;
export const DX = 13.84;
export const DY = 14;
export const DOT = 4;
export const REACH = 2;
export const DRIFT = 11;
export const PULL = 5.5;
export const PACK = 3.7;
export const POOL = 384;
export const FIELD_W = (COLS - 1) * DX + DOT;
export const FIELD_H = (ROWS - 1) * DY + DOT;
```

### Vanilla

### `textures/magnet/vanilla/shape.js`

```js
import { engine } from "./engine.js";
import { COLS, DOT, DRIFT, PACK, POOL, PULL, REACH, ROWS, X0, Y0, DX, DY } from "./geometry.js";

/**
 * Magnet: a field of small dots. The pointer is a magnet: the twenty-five dots nearest it leave
 * their cells and snap together into one solid block under it, every dot out to eleven cells leans
 * toward it (less with distance, so the edges stay put), and all spring home when it moves on or
 * leaves. The static field is one pattern-filled rect (zero
 * nodes); a mask hides the cells of dots that are away from home; only live dots are elements,
 * each translated by its own spring so the pieces arrive at different times.
 */
const STIFFNESS = 760;
const ZETA = 0.66;
const SPREAD = 0.35;
const noise = (seed) => {
  let t = (seed + 0x6d2b79f5) | 0;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const homeX = (c) => X0 + c * DX;
const homeY = (r) => Y0 + r * DY;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const lean = (d) => {
  const u = clamp((DRIFT - d) / (DRIFT - REACH), 0, 1);
  return PULL * u * u * (3 - 2 * u);
};

export function magnet(root) {
  const dots = [...root.querySelectorAll("rect.d")];
  const masks = [...root.querySelectorAll("rect.m")];
  const slots = Array.from({ length: POOL }, (_, i) => ({ cell: -1, hx: 0, hy: 0, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, k: 0, d: 0, i }));
  const byCell = new Map();
  let prev = -1;
  const acquire = (cell) => {
    const have = byCell.get(cell);
    if (have) return have;
    // a free slot, or else the homing slot nearest home (fast sweeps touch more dots than settle in time)
    let s = slots.find((t) => t.cell === -1) ?? null;
    if (!s) {
      for (const t of slots) if (t.tx === t.hx && t.ty === t.hy && (!s || Math.hypot(t.x - t.hx, t.y - t.hy) < Math.hypot(s.x - s.hx, s.y - s.hy))) s = t;
      if (!s) return null;
      byCell.delete(s.cell);
    }
    const c = cell % COLS;
    const r = Math.floor(cell / COLS);
    s.cell = cell;
    s.hx = s.x = s.tx = homeX(c);
    s.hy = s.y = s.ty = homeY(r);
    s.vx = s.vy = 0;
    s.k = STIFFNESS * (1 + SPREAD * (noise(cell) * 2 - 1));
    s.d = 2 * ZETA * Math.sqrt(s.k);
    byCell.set(cell, s);
    const m = masks[s.i];
    m.setAttribute("x", (s.hx - 0.75).toFixed(2)); // a hair larger than the dot: hides the anti-aliased fringe too
    m.setAttribute("y", (s.hy - 0.75).toFixed(2));
    m.style.opacity = "1";
    const el = dots[s.i];
    el.setAttribute("x", s.hx.toFixed(2));
    el.setAttribute("y", s.hy.toFixed(2));
    el.style.transform = "";
    el.style.opacity = "1";
    return s;
  };
  const release = (s) => {
    byCell.delete(s.cell);
    s.cell = -1;
    masks[s.i].style.opacity = "0";
    dots[s.i].style.opacity = "0";
    dots[s.i].style.transform = "";
  };
  return engine(
    root,
    ({ x, y, active, reduced }, dt) => {
      const on = active > 0.01;
      const mx = clamp(x, homeX(REACH) + DOT / 2, homeX(COLS - 1 - REACH) + DOT / 2);
      const my = clamp(y, homeY(REACH) + DOT / 2, homeY(ROWS - 1 - REACH) + DOT / 2);
      const cc = on ? Math.round((mx - DOT / 2 - X0) / DX) : -1;
      const cr = on ? Math.round((my - DOT / 2 - Y0) / DY) : -1;
      const key = cr * COLS + cc;
      if (key !== prev) {
        for (const s of byCell.values()) {
          s.tx = s.hx;
          s.ty = s.hy;
        }
        prev = key;
      }
      if (on) {
        for (let i = -DRIFT; i <= DRIFT; i++) {
          for (let j = -DRIFT; j <= DRIFT; j++) {
            const c = cc + i;
            const r = cr + j;
            if (c < 0 || c >= COLS || r < 0 || r >= ROWS) continue;
            const d = Math.hypot(i, j);
            if (d > DRIFT) continue;
            const s = acquire(r * COLS + c);
            if (!s) continue;
            if (Math.abs(i) <= REACH && Math.abs(j) <= REACH) {
              s.tx = mx + i * PACK - DOT / 2;
              s.ty = my + j * PACK - DOT / 2;
            } else {
              const ox = mx - DOT / 2 - s.hx;
              const oy = my - DOT / 2 - s.hy;
              const len = Math.hypot(ox, oy) || 1;
              const l = lean(d);
              s.tx = s.hx + (ox / len) * l;
              s.ty = s.hy + (oy / len) * l;
            }
          }
        }
      }
      let moving = false;
      for (const s of [...byCell.values()]) {
        if (reduced) {
          s.x = s.tx;
          s.y = s.ty;
          s.vx = s.vy = 0;
        } else {
          s.vx += (-s.k * (s.x - s.tx) - s.d * s.vx) * dt;
          s.vy += (-s.k * (s.y - s.ty) - s.d * s.vy) * dt;
          s.x += s.vx * dt;
          s.y += s.vy * dt;
        }
        const settled = Math.abs(s.x - s.tx) < 0.02 && Math.abs(s.y - s.ty) < 0.02 && Math.abs(s.vx) < 0.5 && Math.abs(s.vy) < 0.5;
        if (settled) {
          s.x = s.tx;
          s.y = s.ty;
          s.vx = s.vy = 0;
          if (s.tx === s.hx && s.ty === s.hy) {
            release(s);
            continue;
          }
        } else moving = true;
        dots[s.i].style.transform = `translate(${(s.x - s.hx).toFixed(2)}px, ${(s.y - s.hy).toFixed(2)}px)`;
      }
      return moving;
    },
    [160, 112],
  );
}

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

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

```js
/**
 * Pointer engine shared by the panel Textures. Reads the panel's box once on pointer enter (and on
 * focus and resize), 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.
 */
export function engine(root, draw, min) {
  const svg = root.querySelector("svg");
  if (!svg) return () => {};
  const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches;
  let box = null;
  let px = 366;
  let py = 258;
  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 (reduced) {
      active = target;
      vel = 0;
    } else {
      vel += (-900 * (active - target) - 60 * vel) * dt;
      active += vel * dt;
      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 onEnter = () => measure();
  const onMove = (e) => {
    if (!box) return;
    px = (e.clientX - box.left - box.ox) / box.s;
    py = (e.clientY - box.top - box.oy) / box.s;
    target = 1;
    wake();
  };
  const onLeave = () => {
    target = 0;
    wake();
  };
  const onFocus = () => {
    measure();
    if (!box) return;
    px = 366;
    py = 258;
    target = 1;
    wake();
  };
  const onResize = () => {
    if (box) measure();
  };
  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 });
  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);
    if (frame) cancelAnimationFrame(frame);
  };
}
```

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

```js
/**
 * Magnet geometry (Figma 16:2321): a 51 × 35 field of 4 px dots. The twenty-five dots within REACH
 * cells snap into a block under the pointer; every dot out to DRIFT cells leans toward it by up to
 * PULL px, less with distance and nothing at DRIFT, so most of the field answers while the edges
 * stay put. The static field is one pattern-filled rect (zero nodes); only moving dots are
 * elements (a pool of POOL, each over a mask square that hides its resting twin).
 */
export const W = 732;
export const H = 516;
export const COLS = 51;
export const ROWS = 35;
export const X0 = 18;
export const Y0 = 18;
export const DX = 13.84;
export const DY = 14;
export const DOT = 4;
export const REACH = 2;
export const DRIFT = 11;
export const PULL = 5.5;
export const PACK = 3.7;
export const POOL = 384;
export const FIELD_W = (COLS - 1) * DX + DOT;
export const FIELD_H = (ROWS - 1) * DY + DOT;
```

### `textures/magnet/vanilla/shape.css`

```css
/* 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. */
.shape {
  --shape-ink: var(--foreground);
  --shape-field: var(--background);
  position: relative;
  width: 100%;
  height: 100%;
  isolation: isolate;
  contain: layout paint style;
  container-type: size;
  overflow: clip;
  color: var(--shape-ink);
  background: var(--shape-field);
}
.shape > 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. */
.shape rect { transform-box: fill-box; transform-origin: center; }

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

## Agent brief

## 1. What it is

A field of small dots. The pointer is a magnet: the twenty-five nearest dots leave their cells and snap together into one solid block under it, then spring back out when it moves on or leaves. 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. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the on/off weight springs to 1 in about 80 ms; the nearest dots pack into a block under the pointer.
- **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.
- **Hover-out:** the weight springs back to 0 and every piece eases home from wherever it is; interruptible at any point.
- **Reduced motion / slow display:** the same states without easing: everything snaps.
- **Touch:** static (no hover on touch).

## 3. Integration

- Install: `npx shadcn@latest add @textures/magnet` (React) or copy `vanilla/`.
- 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: `--shape-ink` (the marks) and `--shape-field` (the panel); both default to the shadcn `--foreground` and `--background`.

## 4. Customisation

- Change the two tokens. Do not change the spring, the reach of the pull 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.
