# Illustration - 7

A field of small white containers, each holding a dark circle resting in its corner. The pointer pulls the circles toward it like a magnet, with a wide reach.

- Slug: `lift` · Category: circles · Free (MIT) · v1.0.0
- What moves: Circles near the pointer slide toward it inside their containers, climbing as it passes above and sinking as it passes below, and settle back when it leaves.
- Triggers: hover, focus, pointer-move · Touch: static · Reduced motion: static
- Aliases: orbs, rise, attract · Use cases: background, hero, card · Tags: grid, circles, magnet
- Rails: Measured 2026-10-09 for 1.0.0: 2.67 KB js, p95 7.7 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 1200

## Tokens

- `--shape-ink` (color, default `var(--foreground)`): The containers
- `--shape-field` (color, default `var(--background)`): The panel and the circles

## 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/lift">
  <Lift />
  <span className="sr-only">Lift</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"><Lift /></div>
  <div className="content">…</div>
</section>
```

## Install

- CLI: `npx shadcn@latest add @textures/lift`
- MCP: `get_shape({ ref: "lift" })` (server: /mcp)
- Prompt: "Implement this hover animation from Textures. @/s/lift"
- 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/lift/react/Shape.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useSyncExternalStore, type CSSProperties } from "react";
import { engine } from "./engine";
import { COLS, CX, CY, EASE_MS, FIELD_H, FIELD_W, H, NEAR, PITCH, POOL, PULL, R, REACH, ROWS, TILE, W, X0, Y0 } from "./geometry";

/**
 * Lift (Figma 32:17333): a field of small white containers, each holding a dark circle that rests
 * low in its lower-right corner. The pointer is a magnet with a wide reach: every circle near it
 * slides toward it inside its container, so the circles climb as the pointer passes above them and
 * sink as it passes below, and all settle back when it leaves. The static field is one
 * pattern-filled rect (zero nodes); only moving tiles are elements, each a nested svg that clips
 * its own circle. One translate per circle; at rest nothing runs.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type LiftProps = {
  tokens?: Partial<Record<"--shape-ink" | "--shape-field", string>>;
  className?: string;
  static?: boolean;
};

type Slot = { cell: number; x: number; y: number; tx: number; ty: number; i: number };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** How hard the magnet pulls a circle `d` tiles away: 1 inside NEAR, 0 at REACH, smooth between. */
const pull = (d: number) => {
  const u = clamp((REACH - d) / (REACH - NEAR), 0, 1);
  return u * u * (3 - 2 * u);
};

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

export function Lift({ tokens, className = "", static: isStatic = false }: LiftProps) {
  const ref = useRef<HTMLDivElement>(null);
  const tiles = useRef<(SVGSVGElement | null)[]>([]);
  const circles = useRef<(SVGCircleElement | 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 = `lift-field-${uid}`;

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, x: 0, y: 0, tx: 0, ty: 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 slot nearest rest on its way home (fast sweeps touch more tiles than settle in time)
      let s = slots.find((x) => x.cell === -1) ?? null;
      if (!s) {
        for (const x of slots) if (x.tx === 0 && x.ty === 0 && (!s || Math.hypot(x.x, x.y) < Math.hypot(s.x, s.y))) s = x;
        if (!s) return null;
        byCell.delete(s.cell);
      }
      s.cell = cell;
      s.x = s.y = s.tx = s.ty = 0;
      byCell.set(cell, s);
      const el = tiles.current[s.i];
      if (el) {
        el.setAttribute("x", String(X0 + (cell % COLS) * PITCH));
        el.setAttribute("y", String(Y0 + Math.floor(cell / COLS) * PITCH));
        el.style.opacity = "1";
      }
      return s;
    };
    const release = (s: Slot) => {
      byCell.delete(s.cell);
      s.cell = -1;
      const el = tiles.current[s.i];
      const c = circles.current[s.i];
      if (el) el.style.opacity = "0";
      if (c) c.style.transform = "";
    };
    return engine(
      root,
      ({ x, y, active, reduced }, dt) => {
        for (const s of byCell.values()) s.tx = s.ty = 0;
        if (active > 0.001) {
          // tile coordinates of the pointer, measured from the circles' resting centres
          const pc = (x - X0 - CX) / PITCH;
          const pr = (y - Y0 - CY) / PITCH;
          const r0 = clamp(Math.floor(pr - REACH), 0, ROWS - 1);
          const r1 = clamp(Math.ceil(pr + REACH), 0, ROWS - 1);
          const c0 = clamp(Math.floor(pc - REACH), 0, COLS - 1);
          const c1 = clamp(Math.ceil(pc + REACH), 0, COLS - 1);
          for (let r = r0; r <= r1; r++) {
            for (let c = c0; c <= c1; c++) {
              const dx = pc - c;
              const dy = pr - r;
              const d = Math.hypot(dx, dy);
              const k = pull(d) * active;
              if (k <= 0.001) continue;
              const s = acquire(r * COLS + c);
              if (!s) continue;
              const len = Math.max(d, 0.001);
              s.tx = (dx / len) * PULL * k; // toward the pointer, by up to PULL px
              s.ty = (dy / len) * PULL * k;
            }
          }
        }
        const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
        let more = false;
        for (const s of [...byCell.values()]) {
          const nx = s.x + (s.tx - s.x) * k;
          const ny = s.y + (s.ty - s.y) * k;
          const done = Math.abs(nx - s.tx) < 0.02 && Math.abs(ny - s.ty) < 0.02;
          s.x = done ? s.tx : nx;
          s.y = done ? s.ty : ny;
          if (!done) more = true;
          if (done && s.tx === 0 && s.ty === 0) {
            release(s);
            continue;
          }
          const c = circles.current[s.i];
          if (c) c.style.transform = `translate(${s.x.toFixed(2)}px, ${s.y.toFixed(2)}px)`;
        }
        return more;
      },
      [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="lift" 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" className="absolute inset-0 block h-full w-full">
        <defs>
          <pattern id={patternId} patternUnits="userSpaceOnUse" x={X0} y={Y0} width={PITCH} height={PITCH}>
            <svg width={TILE} height={TILE} viewBox={`0 0 ${TILE} ${TILE}`} overflow="hidden">
              <rect width={TILE} height={TILE} fill="var(--shape-ink)" />
              <circle cx={CX} cy={CY} r={R} fill="var(--shape-field)" />
            </svg>
          </pattern>
        </defs>
        <rect x={X0} y={Y0} width={FIELD_W} height={FIELD_H} fill={`url(#${patternId})`} />
        {poolLive ? Array.from({ length: POOL }, (_, i) => (
          // a nested svg clips its circle to the container; it sits exactly over the pattern tile
          <svg
            key={i}
            viewBox={`0 0 ${TILE} ${TILE}`}
            width={TILE}
            height={TILE}
            overflow="hidden"
            style={{ opacity: 0 }}
            ref={(el) => {
              tiles.current[i] = el;
            }}
          >
            <rect width={TILE} height={TILE} fill="var(--shape-ink)" />
            <circle
              ref={(el) => {
                circles.current[i] = el;
              }}
              cx={CX}
              cy={CY}
              r={R}
              fill="var(--shape-field)"
            />
          </svg>
        )) : null}
      </svg>
    </div>
  );
}

export default Lift;
```

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

```ts
/**
 * 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.
 */
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 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 (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;
    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 = () => {
    target = 0;
    wake();
  };
  const onFocus = () => {
    measure();
    if (!box) return;
    px = 366;
    py = 258;
    target = 1;
    wake();
  };
  const onResize = () => {
    if (box) measure();
  };
  // 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) return;
    measure();
    if (!box) return;
    px = (cx - box.left - box.ox) / box.s;
    py = (cy - box.top - box.oy) / box.s;
    wake();
  };
  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 });
    if (frame) cancelAnimationFrame(frame);
  };
}
```

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

```ts
/**
 * Lift geometry (Figma 32:17333, "12"): a 23 × 16 field of 27 px tiles on a 31 px pitch, each a
 * container for a circle of radius R that rests at (CX, CY) inside it, peeking in from the lower
 * right. The pointer is a magnet: within REACH tiles each circle slides toward it by up to PULL
 * px (full inside NEAR, fading to none at REACH), so it climbs up its container as the pointer
 * passes above (Figma 32:20035) and sinks when it passes below (32:20033). The static field is one
 * pattern-filled rect (zero nodes); only moving tiles are elements, each a nested svg that clips
 * its own circle. EASE_MS is each circle's time constant.
 */
export const W = 732;
export const H = 516;
export const COLS = 23;
export const ROWS = 16;
export const X0 = 11;
export const Y0 = 12;
export const PITCH = 31;
export const TILE = 27;
export const R = 15.5;
export const CX = 19.5;
export const CY = 24.5;
export const PULL = 9;
export const NEAR = 1;
export const REACH = 5.5;
export const POOL = 112;
export const EASE_MS = 60;
export const FIELD_W = (COLS - 1) * PITCH + TILE;
export const FIELD_H = (ROWS - 1) * PITCH + TILE;
```

### Vanilla

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

```js
import { engine } from "./engine.js";
import { COLS, CX, CY, EASE_MS, NEAR, PITCH, POOL, PULL, REACH, ROWS, X0, Y0 } from "./geometry.js";

/**
 * Lift: a field of small containers, each holding a dark circle that rests low in its corner. The
 * pointer is a magnet with a wide reach: circles near it slide toward it inside their containers
 * and settle back when it leaves. The static field is one pattern-filled rect; only moving tiles
 * are elements, each a nested svg that clips its own circle.
 */
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const pull = (d) => {
  const u = clamp((REACH - d) / (REACH - NEAR), 0, 1);
  return u * u * (3 - 2 * u);
};

export function lift(root) {
  const tiles = [...root.querySelectorAll("svg.t")];
  const circles = tiles.map((t) => t.querySelector("circle"));
  const slots = Array.from({ length: POOL }, (_, i) => ({ cell: -1, x: 0, y: 0, tx: 0, ty: 0, i }));
  const byCell = new Map();
  const acquire = (cell) => {
    const have = byCell.get(cell);
    if (have) return have;
    // a free slot, or else the slot nearest rest on its way home (fast sweeps touch more tiles than settle in time)
    let s = slots.find((x) => x.cell === -1) ?? null;
    if (!s) {
      for (const x of slots) if (x.tx === 0 && x.ty === 0 && (!s || Math.hypot(x.x, x.y) < Math.hypot(s.x, s.y))) s = x;
      if (!s) return null;
      byCell.delete(s.cell);
    }
    s.cell = cell;
    s.x = s.y = s.tx = s.ty = 0;
    byCell.set(cell, s);
    tiles[s.i].setAttribute("x", String(X0 + (cell % COLS) * PITCH));
    tiles[s.i].setAttribute("y", String(Y0 + Math.floor(cell / COLS) * PITCH));
    tiles[s.i].style.opacity = "1";
    return s;
  };
  const release = (s) => {
    byCell.delete(s.cell);
    s.cell = -1;
    tiles[s.i].style.opacity = "0";
    circles[s.i].style.transform = "";
  };
  return engine(
    root,
    ({ x, y, active, reduced }, dt) => {
      for (const s of byCell.values()) s.tx = s.ty = 0;
      if (active > 0.001) {
        const pc = (x - X0 - CX) / PITCH;
        const pr = (y - Y0 - CY) / PITCH;
        const r0 = clamp(Math.floor(pr - REACH), 0, ROWS - 1);
        const r1 = clamp(Math.ceil(pr + REACH), 0, ROWS - 1);
        const c0 = clamp(Math.floor(pc - REACH), 0, COLS - 1);
        const c1 = clamp(Math.ceil(pc + REACH), 0, COLS - 1);
        for (let r = r0; r <= r1; r++) {
          for (let c = c0; c <= c1; c++) {
            const dx = pc - c;
            const dy = pr - r;
            const d = Math.hypot(dx, dy);
            const k = pull(d) * active;
            if (k <= 0.001) continue;
            const s = acquire(r * COLS + c);
            if (!s) continue;
            const len = Math.max(d, 0.001);
            s.tx = (dx / len) * PULL * k;
            s.ty = (dy / len) * PULL * k;
          }
        }
      }
      const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
      let more = false;
      for (const s of [...byCell.values()]) {
        const nx = s.x + (s.tx - s.x) * k;
        const ny = s.y + (s.ty - s.y) * k;
        const done = Math.abs(nx - s.tx) < 0.02 && Math.abs(ny - s.ty) < 0.02;
        s.x = done ? s.tx : nx;
        s.y = done ? s.ty : ny;
        if (!done) more = true;
        if (done && s.tx === 0 && s.ty === 0) {
          release(s);
          continue;
        }
        circles[s.i].style.transform = `translate(${s.x.toFixed(2)}px, ${s.y.toFixed(2)}px)`;
      }
      return more;
    },
    [160, 112],
  );
}

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

### `textures/lift/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 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 (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;
    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 = () => {
    target = 0;
    wake();
  };
  const onFocus = () => {
    measure();
    if (!box) return;
    px = 366;
    py = 258;
    target = 1;
    wake();
  };
  const onResize = () => {
    if (box) measure();
  };
  // 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) return;
    measure();
    if (!box) return;
    px = (cx - box.left - box.ox) / box.s;
    py = (cy - box.top - box.oy) / box.s;
    wake();
  };
  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 });
    if (frame) cancelAnimationFrame(frame);
  };
}
```

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

```js
/**
 * Lift geometry (Figma 32:17333, "12"): a 23 × 16 field of 27 px tiles on a 31 px pitch, each a
 * container for a circle of radius R that rests at (CX, CY) inside it, peeking in from the lower
 * right. The pointer is a magnet: within REACH tiles each circle slides toward it by up to PULL
 * px (full inside NEAR, fading to none at REACH), so it climbs up its container as the pointer
 * passes above (Figma 32:20035) and sinks when it passes below (32:20033). The static field is one
 * pattern-filled rect (zero nodes); only moving tiles are elements, each a nested svg that clips
 * its own circle. EASE_MS is each circle's time constant.
 */
export const W = 732;
export const H = 516;
export const COLS = 23;
export const ROWS = 16;
export const X0 = 11;
export const Y0 = 12;
export const PITCH = 31;
export const TILE = 27;
export const R = 15.5;
export const CX = 19.5;
export const CY = 24.5;
export const PULL = 9;
export const NEAR = 1;
export const REACH = 5.5;
export const POOL = 112;
export const EASE_MS = 60;
export const FIELD_W = (COLS - 1) * PITCH + TILE;
export const FIELD_H = (ROWS - 1) * PITCH + TILE;
```

### `textures/lift/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, .shape circle { 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/lift. JSON twin: /s/lift.json.

## Agent brief

## 1. What it is

A field of small white containers, each holding a dark circle that rests low in its lower-right corner. The pointer is a magnet with a wide reach: every circle within about five tiles slides toward it inside its container, fully within one tile and less further out, so the circles climb as the pointer passes above them and sink as it passes below. All settle back when it leaves. Use it as a hero or background panel, or inside a card. Not for small icons: below 160 × 112 it stays static.

## 2. Behaviour timeline

- **Idle:** the resting field, one pattern-filled rect. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the on/off weight springs to 1 in about 80 ms; circles within reach slide toward the pointer by up to 9 px, each easing in about 60 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 moving tiles are elements (a pool of 112; on a fast sweep the tile nearest rest is reused), each a nested svg that clips its own circle; the rest is the pattern.
- **Hover-out:** the weight springs back to 0 and every circle slides home; 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/lift` (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 containers) and `--shape-field` (the panel and the circles); both default to the shadcn `--foreground` and `--background`.

## 4. Customisation

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