# Illustration - 6

A field of short dashes. The dashes near the pointer turn to look at it and swing back to the grid when it moves on or leaves.

- Slug: `gaze` · Category: lines · Free (MIT) · v1.0.0
- What moves: Dashes near the pointer turn to point at it, fully within two cells and less further out, and turn back to the grid when it leaves.
- Triggers: hover, focus, pointer-move · Touch: static · Reduced motion: static
- Aliases: look, follow, compass · Use cases: background, hero, card · Tags: grid, dashes, follow
- Rails: Measured 2026-10-09 for 1.0.0: 3.18 KB js, p95 11.19 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 1100

## Tokens

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

## Install

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

```tsx
"use client";

import { useEffect, useId, useRef, useSyncExternalStore, type CSSProperties } from "react";
import { engine } from "./engine";
import { BASE, COLS, DASH_H, DASH_W, DX, DY, EASE_MS, FIELD_H, FIELD_W, GLOW, H, MASKS, NEAR, POOL, REACH, ROWS, STEP, W, X0, Y0 } from "./geometry";

/**
 * Gaze (Figma 32:15695): a dim field of short dashes. Under the pointer the dashes go to full ink
 * and turn to look at it; the ink falls away 10 % per cell to the 20 % field, and the turn fades
 * out over about four cells. Everything eases back when the pointer moves on or leaves. The static
 * field is one pattern-filled rect (zero nodes); only dashes that brighten or turn are elements
 * (a pool), and a turning dash hides its resting twin with a mask square. Opacity and rotation
 * only; at rest nothing runs.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type GazeProps = {
  tokens?: Partial<Record<"--shape-ink" | "--shape-field", string>>;
  className?: string;
  static?: boolean;
};

type Slot = { cell: number; a: number; ta: number; o: number; to: number; m: number; i: number };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** How much a dash `d` cells from the pointer turns: 1 inside NEAR, 0 at REACH, smooth between. */
const turn = (d: number) => {
  const u = clamp((REACH - d) / (REACH - NEAR), 0, 1);
  return u * u * (3 - 2 * u);
};
/** The ink level `d` cells from the pointer: full under it, STEP less per cell, never below BASE. */
const level = (d: number) => clamp(1 - STEP * Math.max(0, d - 0.5), BASE, 1);
/** The shortest signed arc between two dash angles; a dash is the same shape every 180°. */
const arc = (from: number, to: number) => ((((to - from) % 180) + 270) % 180) - 90;

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

export function Gaze({ tokens, className = "", static: isStatic = false }: GazeProps) {
  const ref = useRef<HTMLDivElement>(null);
  const dashes = 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 = `gaze-field-${uid}`;
  const maskId = `gaze-hole-${uid}`;

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, a: 0, ta: 0, o: 0, to: 0, m: -1, i }));
    const byCell = new Map<number, Slot>();
    const freeMasks = Array.from({ length: MASKS }, (_, i) => i);
    const home = (cell: number) => [X0 + (cell % COLS) * DX, Y0 + Math.floor(cell / COLS) * DY] as const;
    const acquire = (cell: number): Slot | null => {
      const have = byCell.get(cell);
      if (have) return have;
      // a free slot, or else the dimmest slot on its way back (fast sweeps touch more dashes than settle in time)
      let s = slots.find((x) => x.cell === -1) ?? null;
      if (!s) {
        for (const x of slots) if (x.to === 0 && x.ta === 0 && (!s || x.o < s.o)) s = x;
        if (!s) return null;
        unmask(s);
        byCell.delete(s.cell);
      }
      s.cell = cell;
      s.a = s.ta = s.o = s.to = 0;
      byCell.set(cell, s);
      const [hx, hy] = home(cell);
      const el = dashes.current[s.i];
      if (el) {
        el.setAttribute("x", hx.toFixed(2));
        el.setAttribute("y", hy.toFixed(2));
        el.style.opacity = "0";
      }
      return s;
    };
    // a turning dash sits over a hole in the field; a dash that only brightens paints over its twin
    const mask = (s: Slot) => {
      if (s.m >= 0) return;
      const m = freeMasks.pop();
      if (m === undefined) return;
      s.m = m;
      const [hx, hy] = home(s.cell);
      const el = masks.current[m];
      if (el) {
        el.setAttribute("x", (hx - 0.75).toFixed(2)); // a hair larger than the dash: hides the anti-aliased fringe too
        el.setAttribute("y", (hy - 0.75).toFixed(2));
        el.style.opacity = "1";
      }
    };
    const unmask = (s: Slot) => {
      if (s.m < 0) return;
      const el = masks.current[s.m];
      if (el) el.style.opacity = "0";
      freeMasks.push(s.m);
      s.m = -1;
    };
    const release = (s: Slot) => {
      unmask(s);
      byCell.delete(s.cell);
      s.cell = -1;
      const el = dashes.current[s.i];
      if (el) {
        el.style.opacity = "0";
        el.style.transform = "";
      }
    };
    return engine(
      root,
      ({ x, y, active, reduced }, dt) => {
        for (const s of byCell.values()) s.ta = s.to = 0;
        if (active > 0.001) {
          const pc = (x - X0 - DASH_W / 2) / DX;
          const pr = (y - Y0 - DASH_H / 2) / DY;
          const r0 = clamp(Math.floor(pr - GLOW), 0, ROWS - 1);
          const r1 = clamp(Math.ceil(pr + GLOW), 0, ROWS - 1);
          const c0 = clamp(Math.floor(pc - GLOW), 0, COLS - 1);
          const c1 = clamp(Math.ceil(pc + GLOW), 0, COLS - 1);
          for (let r = r0; r <= r1; r++) {
            for (let c = c0; c <= c1; c++) {
              const d = Math.hypot(pc - c, pr - r);
              const lift = (level(d) - BASE) * active; // ink above the field
              const w = turn(d) * active;
              if (lift <= 0.001 && w <= 0.001) continue;
              const s = acquire(r * COLS + c);
              if (!s) continue;
              s.to = lift;
              if (w > 0.001) s.ta = arc(0, (Math.atan2((pr - r) * DY, (pc - c) * DX) * 180) / Math.PI) * w;
            }
          }
        }
        const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
        let more = false;
        for (const s of [...byCell.values()]) {
          const step = arc(s.a, s.ta);
          const aDone = Math.abs(step) < 0.05;
          s.a = aDone ? s.ta : s.a + step * k;
          const next = s.o + (s.to - s.o) * k;
          const oDone = Math.abs(next - s.to) < 0.002;
          s.o = oDone ? s.to : next;
          if (!aDone || !oDone) more = true;
          if (aDone && oDone && s.ta === 0 && s.to === 0) {
            release(s);
            continue;
          }
          if (s.a !== 0) mask(s);
          else if (aDone) unmask(s);
          const el = dashes.current[s.i];
          if (el) {
            // masked: the dash is the only ink in its cell, so it carries the whole level; unmasked: it adds to the twin
            el.style.opacity = (s.m >= 0 ? BASE + s.o : s.o / (1 - BASE)).toFixed(3);
            el.style.transform = s.a !== 0 ? `rotate(${s.a.toFixed(2)}deg)` : "";
          }
        }
        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;
  const live = { opacity: 0, transformBox: "fill-box", transformOrigin: "center" } as CSSProperties;
  return (
    <div ref={ref} style={style} data-shape="gaze" 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={DASH_W} height={DASH_H} fill="currentColor" style={{ opacity: BASE }} />
          </pattern>
          <mask id={maskId}>
            <rect x="0" y="0" width={W} height={H} fill="white" />
            <g fill="black">
              {poolLive ? Array.from({ length: MASKS }, (_, i) => (
                <rect
                  key={i}
                  ref={(el) => {
                    masks.current[i] = el;
                  }}
                  width={DASH_W + 1.5}
                  height={DASH_H + 1.5}
                  style={{ opacity: 0 }}
                />
              )) : null}
            </g>
          </mask>
        </defs>
        <rect x={X0} y={Y0} width={FIELD_W} height={FIELD_H} fill={`url(#${patternId})`} mask={`url(#${maskId})`} />
        <g fill="currentColor">
          {poolLive ? Array.from({ length: POOL }, (_, i) => (
            <rect
              key={i}
              ref={(el) => {
                dashes.current[i] = el;
              }}
              width={DASH_W}
              height={DASH_H}
              style={live}
            />
          )) : null}
        </g>
      </svg>
    </div>
  );
}

export default Gaze;
```

### `textures/gaze/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/gaze/react/geometry.ts`

```ts
/**
 * Gaze geometry (Figma 32:15695, "10"): a 32 × 25 field of 18 × 4 dashes resting at BASE. Dashes
 * within REACH cells of the pointer turn to point at it (full turn inside NEAR, fading to none at
 * REACH); every dash within GLOW cells brightens, from full ink under the pointer down by STEP per
 * cell to BASE. Each eases with the EASE_MS time constant. The static field is one pattern-filled
 * rect (zero nodes); only dashes that turn or brighten are elements (a pool of POOL), and a turning
 * dash hides its resting twin with one of MASKS mask squares.
 */
export const W = 732;
export const H = 516;
export const COLS = 32;
export const ROWS = 25;
export const X0 = 16;
export const Y0 = 16;
export const DX = 22;
export const DY = 20;
export const DASH_W = 18;
export const DASH_H = 4;
export const BASE = 0.2;
export const STEP = 0.1;
export const GLOW = 8;
export const NEAR = 2.75;
export const REACH = 5.25;
export const POOL = 256;
export const MASKS = 128;
export const EASE_MS = 35;
export const FIELD_W = (COLS - 1) * DX + DASH_W;
export const FIELD_H = (ROWS - 1) * DY + DASH_H;
```

### Vanilla

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

```js
import { engine } from "./engine.js";
import { BASE, COLS, DASH_H, DASH_W, DX, DY, EASE_MS, GLOW, MASKS, NEAR, POOL, REACH, ROWS, STEP, X0, Y0 } from "./geometry.js";

/**
 * Gaze: a dim field of short dashes. Under the pointer the dashes go to full ink and turn to look at
 * it; the ink falls away 10 % per cell to the 20 % field, the turn fades out over about four cells,
 * and everything eases back when the pointer moves on or leaves. The static field is one
 * pattern-filled rect; only dashes that brighten or turn are elements, and a turning dash hides
 * its resting twin with a mask square.
 */
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const turn = (d) => {
  const u = clamp((REACH - d) / (REACH - NEAR), 0, 1);
  return u * u * (3 - 2 * u);
};
const level = (d) => clamp(1 - STEP * Math.max(0, d - 0.5), BASE, 1);
const arc = (from, to) => ((((to - from) % 180) + 270) % 180) - 90;

export function gaze(root) {
  const dashes = [...root.querySelectorAll("rect.d")];
  const maskEls = [...root.querySelectorAll("rect.m")];
  const slots = Array.from({ length: POOL }, (_, i) => ({ cell: -1, a: 0, ta: 0, o: 0, to: 0, m: -1, i }));
  const byCell = new Map();
  const freeMasks = Array.from({ length: MASKS }, (_, i) => i);
  const home = (cell) => [X0 + (cell % COLS) * DX, Y0 + Math.floor(cell / COLS) * DY];
  const unmask = (s) => {
    if (s.m < 0) return;
    maskEls[s.m].style.opacity = "0";
    freeMasks.push(s.m);
    s.m = -1;
  };
  const mask = (s) => {
    if (s.m >= 0) return;
    const m = freeMasks.pop();
    if (m === undefined) return;
    s.m = m;
    const [hx, hy] = home(s.cell);
    maskEls[m].setAttribute("x", (hx - 0.75).toFixed(2)); // a hair larger than the dash: hides the anti-aliased fringe too
    maskEls[m].setAttribute("y", (hy - 0.75).toFixed(2));
    maskEls[m].style.opacity = "1";
  };
  const acquire = (cell) => {
    const have = byCell.get(cell);
    if (have) return have;
    // a free slot, or else the dimmest slot on its way back (fast sweeps touch more dashes than settle in time)
    let s = slots.find((x) => x.cell === -1) ?? null;
    if (!s) {
      for (const x of slots) if (x.to === 0 && x.ta === 0 && (!s || x.o < s.o)) s = x;
      if (!s) return null;
      unmask(s);
      byCell.delete(s.cell);
    }
    s.cell = cell;
    s.a = s.ta = s.o = s.to = 0;
    byCell.set(cell, s);
    const [hx, hy] = home(cell);
    dashes[s.i].setAttribute("x", hx.toFixed(2));
    dashes[s.i].setAttribute("y", hy.toFixed(2));
    dashes[s.i].style.opacity = "0";
    return s;
  };
  const release = (s) => {
    unmask(s);
    byCell.delete(s.cell);
    s.cell = -1;
    dashes[s.i].style.opacity = "0";
    dashes[s.i].style.transform = "";
  };
  return engine(
    root,
    ({ x, y, active, reduced }, dt) => {
      for (const s of byCell.values()) s.ta = s.to = 0;
      if (active > 0.001) {
        const pc = (x - X0 - DASH_W / 2) / DX;
        const pr = (y - Y0 - DASH_H / 2) / DY;
        const r0 = clamp(Math.floor(pr - GLOW), 0, ROWS - 1);
        const r1 = clamp(Math.ceil(pr + GLOW), 0, ROWS - 1);
        const c0 = clamp(Math.floor(pc - GLOW), 0, COLS - 1);
        const c1 = clamp(Math.ceil(pc + GLOW), 0, COLS - 1);
        for (let r = r0; r <= r1; r++) {
          for (let c = c0; c <= c1; c++) {
            const d = Math.hypot(pc - c, pr - r);
            const lift = (level(d) - BASE) * active;
            const w = turn(d) * active;
            if (lift <= 0.001 && w <= 0.001) continue;
            const s = acquire(r * COLS + c);
            if (!s) continue;
            s.to = lift;
            if (w > 0.001) s.ta = arc(0, (Math.atan2((pr - r) * DY, (pc - c) * DX) * 180) / Math.PI) * w;
          }
        }
      }
      const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
      let more = false;
      for (const s of [...byCell.values()]) {
        const step = arc(s.a, s.ta);
        const aDone = Math.abs(step) < 0.05;
        s.a = aDone ? s.ta : s.a + step * k;
        const next = s.o + (s.to - s.o) * k;
        const oDone = Math.abs(next - s.to) < 0.002;
        s.o = oDone ? s.to : next;
        if (!aDone || !oDone) more = true;
        if (aDone && oDone && s.ta === 0 && s.to === 0) {
          release(s);
          continue;
        }
        if (s.a !== 0) mask(s);
        else if (aDone) unmask(s);
        // masked: the dash is the only ink in its cell, so it carries the whole level; unmasked: it adds to the twin
        dashes[s.i].style.opacity = (s.m >= 0 ? BASE + s.o : s.o / (1 - BASE)).toFixed(3);
        dashes[s.i].style.transform = s.a !== 0 ? `rotate(${s.a.toFixed(2)}deg)` : "";
      }
      return more;
    },
    [160, 112],
  );
}

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

### `textures/gaze/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/gaze/vanilla/geometry.js`

```js
/**
 * Gaze geometry (Figma 32:15695, "10"): a 32 × 25 field of 18 × 4 dashes resting at BASE. Dashes
 * within REACH cells of the pointer turn to point at it (full turn inside NEAR, fading to none at
 * REACH); every dash within GLOW cells brightens, from full ink under the pointer down by STEP per
 * cell to BASE. Each eases with the EASE_MS time constant. The static field is one pattern-filled
 * rect (zero nodes); only dashes that turn or brighten are elements (a pool of POOL), and a turning
 * dash hides its resting twin with one of MASKS mask squares.
 */
export const W = 732;
export const H = 516;
export const COLS = 32;
export const ROWS = 25;
export const X0 = 16;
export const Y0 = 16;
export const DX = 22;
export const DY = 20;
export const DASH_W = 18;
export const DASH_H = 4;
export const BASE = 0.2;
export const STEP = 0.1;
export const GLOW = 8;
export const NEAR = 2.75;
export const REACH = 5.25;
export const POOL = 256;
export const MASKS = 128;
export const EASE_MS = 35;
export const FIELD_W = (COLS - 1) * DX + DASH_W;
export const FIELD_H = (ROWS - 1) * DY + DASH_H;
```

### `textures/gaze/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/gaze. JSON twin: /s/gaze.json.

## Agent brief

## 1. What it is

A dim field of short dashes (20 %). Under the pointer the dashes go to full ink and turn to look at it; the ink falls away 10 % per cell back to the field, and the turn is full within about three cells, less out to five, nothing beyond. Everything eases back when the pointer 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 at 20 %, 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; the dashes within eight cells brighten (10 % less per cell) and those within five turn toward the pointer, each easing in about 35 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 dashes that brighten or turn are elements (a pool of 256; on a fast sweep the dimmest dash on its way back is reused); a turning dash sits over a mask square that hides its resting twin; the rest is the pattern.
- **Hover-out:** the weight springs back to 0 and every dash dims and turns back to the grid; 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/gaze` (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 dashes) and `--shape-field` (the panel); both default to the shadcn `--foreground` and `--background`.

## 4. Customisation

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