# Illustration - 4

A dim grid of bars. The bars nearest the pointer light up to white and the brightness falls away in a diamond.

- Slug: `glow` · Category: grids · Free (MIT) · v1.0.0
- What moves: Bars brighten under the pointer in a diamond that fades four cells out, and dim again when it leaves.
- Triggers: hover, focus, pointer-move · Touch: static · Reduced motion: static
- Aliases: spotlight, torch, reveal · Use cases: background, card · Tags: grid, bars, light
- Rails: Measured 2026-10-09 for 1.1.0: 1.78 KB js, p95 5.06 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 750

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

## Install

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

```tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { engine } from "./engine";
import { BAR_H, BAR_W, BASE, COLS, DIAG1, DX, DY, EASE_MS, GROW, H, LEVELS, ROWS, W, X0, Y0 } from "./geometry";

/**
 * Glow (Figma 16:110): a dim grid of bars. The bar under the pointer lights up to full ink and
 * grows taller; the bars around it take the design's exact levels by whole cells, a rectangle
 * of light four cells wide. Each bar eases to its level, so moving the pointer steps the glow
 * from cell to cell. Opacity and one scaleY; at rest nothing runs.
 */
export type GlowProps = {
  tokens?: Partial<Record<"--shape-ink" | "--shape-field", string>>;
  className?: string;
  static?: boolean;
};

/** The design's level for a bar `dc` columns and `dr` rows from the lit one. */
const level = (dc: number, dr: number): number => {
  const c = Math.max(dc, dr);
  if (c === 1 && dc === 1 && dr === 1) return DIAG1;
  return LEVELS[c] ?? BASE;
};

export function Glow({ tokens, className = "", static: isStatic = false }: GlowProps) {
  const ref = useRef<HTMLDivElement>(null);
  const rects = useRef<(SVGRectElement | null)[]>([]);
  const cur = useRef<Float32Array>(new Float32Array(COLS * ROWS).fill(BASE)); // eased opacity per bar
  const grow = useRef(0); // eased growth of the lit bar
  const lit = useRef(-1);

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    return engine(
      root,
      ({ x, y, active }, dt) => {
        // the pointer snaps to the nearest bar; that cell is lit and the levels step out from it
        const col = Math.min(COLS - 1, Math.max(0, Math.round((x - X0 - BAR_W / 2) / DX)));
        const row = Math.min(ROWS - 1, Math.max(0, Math.round((y - Y0 - BAR_H / 2) / DY)));
        const k = 1 - Math.exp((-dt * 1000) / EASE_MS);
        const v = cur.current;
        let more = false;
        for (let n = 0; n < COLS * ROWS; n++) {
          const t = BASE + (level(Math.abs((n % COLS) - col), Math.abs(Math.floor(n / COLS) - row)) - BASE) * active;
          const next = v[n]! + (t - v[n]!) * k;
          if (Math.abs(next - t) > 0.002) more = true;
          if (Math.abs(next - v[n]!) < 0.0005) continue;
          v[n] = next;
          const el = rects.current[n];
          if (el) el.style.opacity = next.toFixed(3);
        }
        // only the lit bar grows; a bar that stops being lit shrinks back
        const litNow = row * COLS + col;
        if (lit.current !== litNow && lit.current >= 0) {
          const old = rects.current[lit.current];
          if (old) old.style.transform = "";
        }
        lit.current = litNow;
        const g = grow.current + (active - grow.current) * k;
        if (Math.abs(g - active) > 0.002) more = true;
        grow.current = g;
        const el = rects.current[litNow];
        if (el) el.style.transform = g > 0.002 ? `scaleY(${(1 + GROW * g).toFixed(3)})` : "";
        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="glow" 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">
        {Array.from({ length: COLS * ROWS }, (_, n) => (
          <rect
            key={n}
            ref={(el) => {
              rects.current[n] = el;
            }}
            x={X0 + (n % COLS) * DX}
            y={Y0 + Math.floor(n / COLS) * DY}
            width={BAR_W}
            height={BAR_H}
            style={{ opacity: BASE, transformBox: "fill-box", transformOrigin: "center" }}
          />
        ))}
      </svg>
    </div>
  );
}

export default Glow;
```

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

```ts
/**
 * Glow geometry (Figma 16:110): a 9 × 24 grid of 67 × 5 bars resting at 10 %. The pointer snaps to
 * the nearest bar; that bar goes to full ink and grows from 5 to 11 (GROW is the extra scaleY), and
 * the bars around it take the design's exact values by whole cells: LEVELS by Chebyshev distance
 * (0, 1, 2, 3; beyond that BASE), with the four diagonal neighbours at DIAG1. Each bar eases to
 * its value with the EASE_MS time constant, so the glow steps cell by cell, never a round blob.
 */
export const W = 732;
export const H = 516;
export const COLS = 9;
export const ROWS = 24;
export const X0 = 17;
export const DX = 79;
export const Y0 = 14;
export const DY = 21;
export const BAR_W = 67;
export const BAR_H = 5;
export const BASE = 0.1;
export const LEVELS = [1, 0.8, 0.4, 0.2];
export const DIAG1 = 0.7;
export const GROW = 1.2;
export const EASE_MS = 45;
```

### Vanilla

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

```js
import { engine } from "./engine.js";
import { BAR_H, BAR_W, BASE, COLS, DIAG1, DX, DY, EASE_MS, GROW, LEVELS, ROWS, X0, Y0 } from "./geometry.js";

/** Glow: the bar under the pointer lights up and grows taller; the bars around it take the design's levels by whole cells. Opacity and one scaleY. */
const level = (dc, dr) => {
  const c = Math.max(dc, dr);
  if (c === 1 && dc === 1 && dr === 1) return DIAG1;
  return LEVELS[c] ?? BASE;
};

export function glow(root) {
  const rects = [...root.querySelectorAll("rect[data-n]")];
  const cur = new Float32Array(COLS * ROWS).fill(BASE);
  let grow = 0;
  let lit = -1;
  return engine(
    root,
    ({ x, y, active }, dt) => {
      const col = Math.min(COLS - 1, Math.max(0, Math.round((x - X0 - BAR_W / 2) / DX)));
      const row = Math.min(ROWS - 1, Math.max(0, Math.round((y - Y0 - BAR_H / 2) / DY)));
      const k = 1 - Math.exp((-dt * 1000) / EASE_MS);
      let more = false;
      for (let n = 0; n < COLS * ROWS; n++) {
        const t = BASE + (level(Math.abs((n % COLS) - col), Math.abs(Math.floor(n / COLS) - row)) - BASE) * active;
        const next = cur[n] + (t - cur[n]) * k;
        if (Math.abs(next - t) > 0.002) more = true;
        if (Math.abs(next - cur[n]) < 0.0005) continue;
        cur[n] = next;
        rects[n].style.opacity = next.toFixed(3);
      }
      const litNow = row * COLS + col;
      if (lit !== litNow && lit >= 0) rects[lit].style.transform = "";
      lit = litNow;
      grow += (active - grow) * k;
      if (Math.abs(grow - active) > 0.002) more = true;
      rects[litNow].style.transform = grow > 0.002 ? `scaleY(${(1 + GROW * grow).toFixed(3)})` : "";
      return more;
    },
    [160, 112],
  );
}

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

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

```js
/**
 * Glow geometry (Figma 16:110): a 9 × 24 grid of 67 × 5 bars resting at 10 %. The pointer snaps to
 * the nearest bar; that bar goes to full ink and grows from 5 to 11 (GROW is the extra scaleY), and
 * the bars around it take the design's exact values by whole cells: LEVELS by Chebyshev distance
 * (0, 1, 2, 3; beyond that BASE), with the four diagonal neighbours at DIAG1. Each bar eases to
 * its value with the EASE_MS time constant, so the glow steps cell by cell, never a round blob.
 */
export const W = 732;
export const H = 516;
export const COLS = 9;
export const ROWS = 24;
export const X0 = 17;
export const DX = 79;
export const Y0 = 14;
export const DY = 21;
export const BAR_W = 67;
export const BAR_H = 5;
export const BASE = 0.1;
export const LEVELS = [1, 0.8, 0.4, 0.2];
export const DIAG1 = 0.7;
export const GROW = 1.2;
export const EASE_MS = 45;
```

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

## Agent brief

## 1. What it is

A dim grid of bars. The bar under the pointer lights up to white and grows taller, and the bars around it take the design's levels by whole cells, a rectangle of light four cells wide 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. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the on/off weight springs to 1 in about 80 ms; the bar under the pointer goes to full ink and grows from 5 to 11; its neighbours step to 80 % (70 % on the diagonals), then 40 %, then 20 %, then rest.
- **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/glow` (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.
