# Swell

Eighteen columns of hairline dashes stacked with equal gaps. Under the pointer the dashes thicken into a diamond-shaped bump that squeezes its neighbours, and the grid flattens out again when it leaves.

- Slug: `swell` · Category: lines · Free (MIT) · v1.0.0
- What moves: Dashes under the pointer swell up to four times their thickness and squeeze their neighbours; the grid flattens again when it leaves.
- Triggers: hover, focus, pointer-move · Touch: static · Reduced motion: static
- Aliases: bulge, squeeze, thicken · Use cases: hero, background, card · Tags: grid, dashes, bump
- Rails: Measured 2026-10-09 for 1.0.0: 1.65 KB js, p95 8.57 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 800

## Tokens

- `--shape-ink` (color, default `var(--foreground)`): Ink of the bars and dots
- `--shape-field` (color, default `var(--background)`): The panel behind them

## Placement

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

### contained

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

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

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

## Install

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

```tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { engine } from "./engine";
import { COLS, DASH_W, DX, H, MAX, MIN, ROWS, ROW_PITCH, SPAN, STEP_C, STEP_R, W, X0, Y0 } from "./geometry";

/**
 * Swell (Figma 16:454): eighteen columns of twenty-one hairline dashes. Each column is an equal-gap
 * stack, so as dashes thicken under the pointer their neighbours squeeze together; the bump follows
 * the pointer and the grid flattens again when it leaves. Motion is a translate + scaleY on each
 * dash; at rest nothing runs.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type SwellProps = {
  tokens?: Partial<Record<"--shape-ink" | "--shape-field", string>>;
  className?: string;
  static?: boolean;
};

const EASE = 0.35;
const height = (c: number, r: number, bc: number, br: number) => Math.max(MIN, Math.min(MAX, MAX - STEP_C * Math.max(0, Math.abs(c - bc) - 0.5) - STEP_R * Math.abs(r - br)));

export function Swell({ tokens, className = "", static: isStatic = false }: SwellProps) {
  const ref = useRef<HTMLDivElement>(null);
  const rects = useRef<(SVGRectElement | null)[][]>(Array.from({ length: COLS }, () => []));

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    const hs = Array.from({ length: COLS }, () => Array.from({ length: ROWS }, () => MIN));
    return engine(
      root,
      ({ x, y, active, reduced }, dt) => {
        const bc = (x - X0 - DASH_W / 2) / DX;
        const br = (y - Y0 - MIN / 2) / ROW_PITCH;
        const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60);
        let moving = false;
        for (let c = 0; c < COLS; c++) {
          const col = hs[c]!;
          let sum = 0;
          for (let r = 0; r < ROWS; r++) {
            const d = MIN + (height(c, r, bc, br) - MIN) * active - col[r]!;
            if (Math.abs(d) < 0.02) col[r] = col[r]! + d;
            else {
              col[r] = col[r]! + d * k;
              moving = true;
            }
            sum += col[r]!;
          }
          const gap = (SPAN - sum) / (ROWS - 1);
          let yy = Y0;
          for (let r = 0; r < ROWS; r++) {
            const h = col[r]!;
            const dy = yy + h / 2 - (Y0 + r * ROW_PITCH + MIN / 2);
            const el = rects.current[c]![r];
            if (el) el.style.transform = `translateY(${dy.toFixed(2)}px) scaleY(${(h / MIN).toFixed(3)})`;
            yy += h + gap;
          }
        }
        return moving;
      },
      [160, 112],
    );
  }, [isStatic]);

  const style = { "--shape-ink": "var(--foreground)", "--shape-field": "var(--background)", ...tokens, color: "var(--shape-ink)", background: "var(--shape-field)" } as CSSProperties;
  return (
    <div ref={ref} style={style} data-shape="swell" 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 }, (_, c) =>
          Array.from({ length: ROWS }, (_, r) => (
            <rect
              key={`${c}-${r}`}
              ref={(el) => {
                rects.current[c]![r] = el;
              }}
              x={(X0 + c * DX).toFixed(2)}
              y={(Y0 + r * ROW_PITCH).toFixed(2)}
              width={DASH_W}
              height={MIN}
              style={{ transformBox: "fill-box", transformOrigin: "center" }}
            />
          )),
        )}
      </svg>
    </div>
  );
}

export default Swell;
```

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

```ts
/** Swell geometry (Figma 16:454): eighteen columns of twenty-one dashes; each column is an equal-gap stack of SPAN. */
export const W = 732;
export const H = 516;
export const COLS = 18;
export const ROWS = 21;
export const X0 = 16;
export const DX = 39.18;
export const DASH_W = 34;
export const Y0 = 15.1;
export const SPAN = 485;
export const MIN = 3;
export const MAX = 13;
export const STEP_C = 2.5;
export const STEP_R = 2;
export const ROW_PITCH = (SPAN - MIN) / (ROWS - 1);
```

### Vanilla

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

```js
import { engine } from "./engine.js";
import { COLS, DASH_W, DX, MAX, MIN, ROWS, ROW_PITCH, SPAN, STEP_C, STEP_R, X0, Y0 } from "./geometry.js";

/**
 * Swell: an even grid of hairline dashes. Each column is an equal-gap stack, so as dashes thicken
 * under the pointer their neighbours squeeze together; the bump follows the pointer and the grid
 * flattens again when it leaves. Motion is a translate + scaleY on each dash.
 */
const EASE = 0.35;
const height = (c, r, bc, br) => Math.max(MIN, Math.min(MAX, MAX - STEP_C * Math.max(0, Math.abs(c - bc) - 0.5) - STEP_R * Math.abs(r - br)));

export function swell(root) {
  const rects = Array.from({ length: COLS }, (_, c) => [...root.querySelectorAll(`rect[data-c="${c}"]`)]);
  const hs = Array.from({ length: COLS }, () => Array.from({ length: ROWS }, () => MIN));
  return engine(
    root,
    ({ x, y, active, reduced }, dt) => {
      const bc = (x - X0 - DASH_W / 2) / DX;
      const br = (y - Y0 - MIN / 2) / ROW_PITCH;
      const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60);
      let moving = false;
      for (let c = 0; c < COLS; c++) {
        const col = hs[c];
        let sum = 0;
        for (let r = 0; r < ROWS; r++) {
          const d = MIN + (height(c, r, bc, br) - MIN) * active - col[r];
          if (Math.abs(d) < 0.02) col[r] += d;
          else {
            col[r] += d * k;
            moving = true;
          }
          sum += col[r];
        }
        const gap = (SPAN - sum) / (ROWS - 1);
        let yy = Y0;
        for (let r = 0; r < ROWS; r++) {
          const h = col[r];
          const dy = yy + h / 2 - (Y0 + r * ROW_PITCH + MIN / 2);
          rects[c][r].style.transform = `translateY(${dy.toFixed(2)}px) scaleY(${(h / MIN).toFixed(3)})`;
          yy += h + gap;
        }
      }
      return moving;
    },
    [160, 112],
  );
}

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

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

```js
/** Swell geometry (Figma 16:454): eighteen columns of twenty-one dashes; each column is an equal-gap stack of SPAN. */
export const W = 732;
export const H = 516;
export const COLS = 18;
export const ROWS = 21;
export const X0 = 16;
export const DX = 39.18;
export const DASH_W = 34;
export const Y0 = 15.1;
export const SPAN = 485;
export const MIN = 3;
export const MAX = 13;
export const STEP_C = 2.5;
export const STEP_R = 2;
export const ROW_PITCH = (SPAN - MIN) / (ROWS - 1);
```

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

## Agent brief

## 1. What it is

Eighteen columns of hairline dashes stacked with equal gaps. Under the pointer the dashes thicken into a diamond-shaped bump that squeezes its neighbours, and the grid flattens out again when it leaves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static.

## 2. Behaviour timeline

- **Idle:** the resting field. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the on/off weight springs to 1 in about 80 ms; dashes thicken in a diamond under the pointer.
- **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler.
- **Hover-out:** the weight springs back to 0 and every piece eases home from wherever it is; interruptible at any point.
- **Reduced motion / slow display:** the same states without easing: everything snaps.
- **Touch:** static (no hover on touch).

## 3. Integration

- Install: `npx shadcn@latest add @textures/swell` (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.
