# Beam

A field of hairlines. The pointer carries a beam across them: the lines under it swell into bars and part to make room, and the beam glides after the pointer.

- Slug: `beam` · Category: lines · Free (MIT) · v1.1.0
- What moves: A beam glides after the pointer: the lines under it swell into bars and part to make room, and all settle back to hairlines when it leaves.
- Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: static
- Aliases: lens, focus, barcode, scan · Use cases: background, hero, card · Tags: lines, beam, light
- Rails: Measured 2026-10-18 for 1.1.0: 2.03 KB js, p95 5.21 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 2037

## Tokens

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

## Placement

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

### contained

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

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

```tsx
<a className="host" href="/s/beam">
  <Beam />
  <span className="sr-only">Beam</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"><Beam /></div>
  <div className="content">…</div>
</section>
```

## Install

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

## Code

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

### React

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

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

"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { engine } from "./engine";
import { EASE_MS, FAR, FLOOR, FULL, H, LINE_H, N, PITCH, MARGIN, SETTLE, SIGMA, SPREAD, W, WIDEN, X0, Y0 } from "./geometry";

/**
 * Beam (Figma 32:19002): a field of hairlines. The pointer carries a beam of light across them:
 * the lines under it swell into bars and part to make room, the ink is full around the beam and
 * fades toward the far edges, and the beam glides after the pointer so the field moves like a
 * wave. At rest every line is a dim hairline. Transform and opacity only; at rest nothing runs.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type BeamProps = {
  tokens?: Partial<Record<"--texture-ink" | "--texture-field", string>>;
  className?: string;
  static?: boolean;
};

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const smooth = (u: number) => u * u * (3 - 2 * u);
/** Extra width of a line `dx` px from the beam: a bell, WIDEN at the centre. */
const widen = (dx: number) => WIDEN * Math.exp(-(dx * dx) / (SIGMA * SIGMA));
/** Half the extra width of every line between the beam and a point `a` px out: how far that point must move to stay clear. */
const ROOM = (WIDEN * SIGMA * Math.sqrt(Math.PI)) / (4 * PITCH);
/** How far a line `dx` px from the beam is pushed outward: the room the lines inside it take (with a margin), back to 0 by SETTLE. */
const shift = (dx: number) => {
  const a = Math.abs(dx);
  const room = MARGIN * ROOM * Math.tanh((1.2 * a) / SIGMA);
  const decay = 1 - smooth(clamp((a - SPREAD) / (SETTLE - SPREAD), 0, 1));
  return Math.sign(dx) * room * decay;
};
/** Ink of a line `dx` px from the beam: 1 within FULL, down to FLOOR at FAR. */
const ink = (dx: number) => clamp(1 - (1 - FLOOR) * ((Math.abs(dx) - FULL) / (FAR - FULL)), FLOOR, 1);

export function Beam({ tokens, className = "", static: isStatic = false }: BeamProps) {
  const ref = useRef<HTMLDivElement>(null);
  const lines = useRef<(SVGRectElement | null)[]>([]);

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    let bx = W / 2; // the beam's own position: it glides after the pointer
    return engine(
      root,
      ({ x, active, reduced }, dt) => {
        const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
        bx += (x - bx) * k;
        const more = Math.abs(x - bx) > 0.05 && active > 0.001;
        for (let i = 0; i < N; i++) {
          const el = lines.current[i];
          if (!el) continue;
          const dx = X0 + i * PITCH + 0.5 - bx;
          const w = 1 + widen(dx) * active;
          const s = shift(dx) * active;
          el.style.transform = w > 1.001 || Math.abs(s) > 0.01 ? `translate(${s.toFixed(2)}px, 0) scaleX(${w.toFixed(3)})` : "";
          el.style.opacity = (FLOOR + (ink(dx) - FLOOR) * active).toFixed(3);
        }
        return more;
      },
      [160, 112],
    );
  }, [isStatic]);

  const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)" } as CSSProperties;
  const line = { opacity: FLOOR, transformBox: "fill-box", transformOrigin: "center" } as CSSProperties;
  return (
    <div ref={ref} style={style} data-texture="beam" 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: N }, (_, i) => (
          <rect
            key={i}
            ref={(el) => {
              lines.current[i] = el;
            }}
            x={X0 + i * PITCH}
            y={Y0}
            width={1}
            height={LINE_H}
            style={line}
          />
        ))}
      </svg>
    </div>
  );
}

export default Beam;
```

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

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

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

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

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

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

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

/**
 * Beam geometry (Figma 32:19002, "13"): 51 hairlines, 1 × 497, on a 14 px pitch from x = 11. The
 * beam is centred on the pointer's x and follows it with the EASE_MS time constant: lines within
 * it widen (WIDEN extra px at the centre, a bell of SIGMA px) and are pushed outward by the room
 * the wider lines inside them take (times MARGIN, so the bars keep a gap), easing back to their
 * homes between SPREAD and SETTLE px out. Every line is full ink (FLOOR is 1: the ink ramp is
 * off, kept for a dimmer variant). Transform (translate + scaleX) only.
 */
export const W = 732;
export const H = 516;
export const N = 51;
export const X0 = 11;
export const PITCH = 14;
export const Y0 = 9;
export const LINE_H = 497;
export const WIDEN = 15;
export const SIGMA = 25;
export const MARGIN = 1.3;
export const SPREAD = 80;
export const SETTLE = 220;
export const FULL = 229;
export const FAR = 355;
export const FLOOR = 1;
export const EASE_MS = 45; // 90 until 2026-10-19 (Lukas: "sluggish"): the beam follows the pointer twice as fast
```

### Vanilla

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

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

import { engine } from "./engine.js";
import { EASE_MS, FAR, FLOOR, FULL, N, MARGIN, PITCH, SETTLE, SIGMA, SPREAD, W, WIDEN, X0 } from "./geometry.js";

/**
 * Beam: a field of hairlines. The pointer carries a beam of light across them: the lines under it
 * swell into bars and part to make room, the ink is full around the beam and fades toward the far
 * edges, and the beam glides after the pointer. Transform and opacity only.
 */
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const smooth = (u) => u * u * (3 - 2 * u);
const widen = (dx) => WIDEN * Math.exp(-(dx * dx) / (SIGMA * SIGMA));
const ROOM = (WIDEN * SIGMA * Math.sqrt(Math.PI)) / (4 * PITCH);
const shift = (dx) => {
  const a = Math.abs(dx);
  const room = MARGIN * ROOM * Math.tanh((1.2 * a) / SIGMA);
  const decay = 1 - smooth(clamp((a - SPREAD) / (SETTLE - SPREAD), 0, 1));
  return Math.sign(dx) * room * decay;
};
const ink = (dx) => clamp(1 - (1 - FLOOR) * ((Math.abs(dx) - FULL) / (FAR - FULL)), FLOOR, 1);

export function beam(root) {
  const lines = [...root.querySelectorAll("rect[data-n]")];
  let bx = W / 2;
  return engine(
    root,
    ({ x, active, reduced }, dt) => {
      const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS);
      bx += (x - bx) * k;
      const more = Math.abs(x - bx) > 0.05 && active > 0.001;
      for (let i = 0; i < N; i++) {
        const dx = X0 + i * PITCH + 0.5 - bx;
        const w = 1 + widen(dx) * active;
        const s = shift(dx) * active;
        lines[i].style.transform = w > 1.001 || Math.abs(s) > 0.01 ? `translate(${s.toFixed(2)}px, 0) scaleX(${w.toFixed(3)})` : "";
        lines[i].style.opacity = (FLOOR + (ink(dx) - FLOOR) * active).toFixed(3);
      }
      return more;
    },
    [160, 112],
  );
}

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

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

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

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

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

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

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

/**
 * Beam geometry (Figma 32:19002, "13"): 51 hairlines, 1 × 497, on a 14 px pitch from x = 11. The
 * beam is centred on the pointer's x and follows it with the EASE_MS time constant: lines within
 * it widen (WIDEN extra px at the centre, a bell of SIGMA px) and are pushed outward by the room
 * the wider lines inside them take (times MARGIN, so the bars keep a gap), easing back to their
 * homes between SPREAD and SETTLE px out. Every line is full ink (FLOOR is 1: the ink ramp is
 * off, kept for a dimmer variant). Transform (translate + scaleX) only.
 */
export const W = 732;
export const H = 516;
export const N = 51;
export const X0 = 11;
export const PITCH = 14;
export const Y0 = 9;
export const LINE_H = 497;
export const WIDEN = 15;
export const SIGMA = 25;
export const MARGIN = 1.3;
export const SPREAD = 80;
export const SETTLE = 220;
export const FULL = 229;
export const FAR = 355;
export const FLOOR = 1;
export const EASE_MS = 45; // 90 until 2026-10-19 (Lukas: "sluggish"): the beam follows the pointer twice as fast
```

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

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

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

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

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

## Agent brief

## 1. What it is

A field of 51 hairlines, all full ink. The pointer carries a beam across them: the lines under it swell into bars (up to 16 px) and part to make room, and the beam glides after the pointer so the whole field moves like a wave. At rest every line is a hairline. 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:** 51 hairlines. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the on/off weight springs to 1 in about 80 ms; the beam forms where the pointer is.
- **Pointer move:** the pointer is read on a passive listener and applied once per frame; the beam's position glides after it (about 90 ms), and every line's width and offset follow from its distance to the beam. 51 elements, one transform each.
- **Hover-out:** the weight springs back to 0 and the lines settle to hairlines; interruptible at any point.
- **Reduced motion / slow display:** the same states without easing: everything snaps.
- **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover.

## 3. Integration

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

## 4. Customisation

- Change the two tokens. Do not change the beam's texture, its glide or the reduced-motion branch.

## 5. Accessibility

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


## Related
Pairs with: Settle (hero) · Swell (hero) — same family, same tokens.
npx shadcn@latest add https://textures.dev/r/settle.json?via=code

## Using Textures from an agent

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

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

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