# Pattern



- Slug: `yohan3` · Category: lines · Free (MIT) · v0.1.0
- What moves: 
- Triggers: hover, focus, pointer-move · Touch: static · Reduced motion: static
- Aliases: — · Use cases: — · Tags: —
- Rails: Measured 2026-10-09 for 1.0.0: 1.67 KB js, p95 7.12 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge
- Estimated tokens: 0

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

## Install

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

```tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { engine } from "./engine";
import { BAR_H, BAR_W, COLS, H, W } from "./geometry";

/**
 * Settle (Figma 16:330): twenty columns of thin bars, the left half piled toward the floor and the
 * right half toward the ceiling. Under the pointer the bars gather toward it along their own
 * column, never closer than a bar's gap, and settle back when it leaves. Motion is a CSS
 * translate on each bar; at rest nothing runs.
 *
 * Host needs: `position: relative` and a size. The panel is 732 × 516 and covers the host.
 */
export type SettleProps = {
  /** Token overrides; see shape.json → tokens. */
  tokens?: Partial<Record<"--shape-ink" | "--shape-field", string>>;
  className?: string;
  /** Draw the resting frame only (thumbnails, server lists). */
  static?: boolean;
};

const GAP = BAR_H + 2;
const SIGMA_X = 90;
const SIGMA_Y = 130;
const PULL = 0.85;
const EASE = 0.32;

export function Settle({ tokens, className = "", static: isStatic = false }: SettleProps) {
  const ref = useRef<HTMLDivElement>(null);
  const rects = useRef<(SVGRectElement | null)[][]>(COLS.map(() => []));

  useEffect(() => {
    const root = ref.current;
    if (!root || isStatic) return;
    const ys = COLS.map(([, tops]) => [...tops]);
    const targets = COLS.map(([, tops]) => [...tops]);
    return engine(
      root,
      ({ x, y, active, reduced }, dt) => {
        let moving = false;
        const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60);
        COLS.forEach(([cx, tops], i) => {
          const t = targets[i]!;
          const cur = ys[i]!;
          const dx = cx + BAR_W / 2 - x;
          const wx = Math.exp(-(dx * dx) / (2 * SIGMA_X * SIGMA_X));
          tops.forEach((top, j) => {
            const dy = y - (top + BAR_H / 2);
            const wy = Math.exp(-(dy * dy) / (2 * SIGMA_Y * SIGMA_Y));
            t[j] = top + dy * wx * wy * PULL * active;
          });
          for (let j = 1; j < t.length; j++) if (t[j]! < t[j - 1]! + GAP) t[j] = t[j - 1]! + GAP;
          for (let j = t.length - 2; j >= 0; j--) if (t[j]! > t[j + 1]! - GAP) t[j] = t[j + 1]! - GAP;
          for (let j = 0; j < t.length; j++) {
            const d = t[j]! - cur[j]!;
            const el = rects.current[i]![j];
            if (Math.abs(d) < 0.03) {
              if (cur[j] !== t[j]) {
                cur[j] = t[j]!;
                if (el) el.style.transform = `translateY(${(cur[j]! - tops[j]!).toFixed(2)}px)`;
              }
              continue;
            }
            cur[j] = cur[j]! + d * k;
            if (el) el.style.transform = `translateY(${(cur[j]! - tops[j]!).toFixed(2)}px)`;
            moving = true;
          }
        });
        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="settle"
      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">
        {COLS.map(([x, tops], i) =>
          tops.map((top, j) => (
            <rect
              key={`${i}-${j}`}
              ref={(el) => {
                rects.current[i]![j] = el;
              }}
              x={x}
              y={top}
              width={BAR_W}
              height={BAR_H}
              style={{ transformBox: "fill-box", transformOrigin: "center" }}
            />
          )),
        )}
      </svg>
    </div>
  );
}

export default Settle;
```

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

```ts
/** Settle geometry (Figma 16:330): columns as [x, bar tops] in the 732 × 516 panel, sorted top to bottom. */
export const W = 732;
export const H = 516;
export const BAR_W = 31.287;
export const BAR_H = 8.683;
export const COLS: [number, number[]][] = [[17,[47.17,100.05,150.79,197.26,241.59,282.18,320.1,353.22,383.13,411.44,434.4,455.23,473.93,491.29]],[52.04,[64.54,116.88,167.09,214.62,257.35,299.55,336.4,370.58,401.56,428.8,452.3,473.67,491.29]],[87.08,[26.61,81.09,135.57,186.32,232.78,277.11,318.78,355.63,388.74,419.72,448.03,469.93,491.29]],[122.11,[49.58,103.53,156.4,207.68,254.68,298.48,340.14,376.46,410.64,441.09,469.39,491.29]],[157.15,[73.08,129.7,183.64,233.32,280.85,323.05,365.24,401.56,437.35,467.26,491.29]],[192.19,[42.1,102.46,158.01,211.42,262.16,308.63,352.96,392.48,432.01,464.59,491.29]],[227.23,[73.08,132.9,188.98,242.93,293.67,341.21,383.4,424.53,462.45,491.29]],[262.26,[44.24,104.6,166.02,222.1,276.05,325.72,373.25,416.52,458.18,491.29]],[297.3,[17,81.09,144.12,202.34,257.89,363.11,408.51,450.7,491.29]],[332.34,[54.92,120.62,184.18,245.07,300.08,351.89,403.17,448.56,491.29]],[367.38,[17,59.73,105.13,156.4,208.21,263.23,324.11,387.67,453.37]],[402.41,[17,57.59,99.79,145.19,250.41,305.96,364.17,427.2,491.29]],[437.45,[17,50.11,91.78,135.04,182.57,232.25,286.19,342.28,403.7,464.05]],[472.49,[17,45.84,83.76,124.89,167.09,214.62,265.36,319.31,375.39,435.21]],[507.53,[17,43.7,76.29,115.81,155.33,199.67,246.13,296.87,350.29,405.83,466.19]],[542.56,[17,41.04,70.95,106.73,143.05,185.25,227.44,274.98,324.65,378.6,435.21]],[577.6,[17,38.9,67.21,97.65,131.83,168.15,209.82,253.61,300.61,351.89,404.77,458.71]],[612.64,[17,38.36,60.26,88.57,119.55,152.66,189.52,231.18,275.51,321.98,372.72,427.2,481.68]],[647.68,[17,34.63,55.99,79.49,106.73,137.71,171.89,208.75,250.94,293.67,341.21,391.41,443.76]],[682.71,[17,34.36,53.06,73.89,96.86,125.16,155.08,188.19,226.11,266.71,311.04,357.5,408.24,461.12]]];
```

### Vanilla

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

```js
import { engine } from "./engine.js";
import { BAR_H, BAR_W, COLS } from "./geometry.js";

/**
 * Settle: twenty columns of thin bars. Under the pointer the bars gather toward it along their
 * own column, never closer than a bar's gap, and settle back when it leaves. Motion is a CSS
 * translate on each bar (RAILS: transforms on SVG children).
 */
const GAP = BAR_H + 2;
const SIGMA_X = 90;
const SIGMA_Y = 130;
const PULL = 0.85;
const EASE = 0.32;

export function settle(root) {
  const rects = COLS.map((_, i) => [...root.querySelectorAll(`rect[data-i="${i}"]`)]);
  const ys = COLS.map(([, tops]) => [...tops]);
  const targets = COLS.map(([, tops]) => [...tops]);
  return engine(
    root,
    ({ x, y, active, reduced }, dt) => {
      let moving = false;
      const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60);
      COLS.forEach(([cx, tops], i) => {
        const t = targets[i];
        const cur = ys[i];
        const dx = cx + BAR_W / 2 - x;
        const wx = Math.exp(-(dx * dx) / (2 * SIGMA_X * SIGMA_X));
        tops.forEach((top, j) => {
          const dy = y - (top + BAR_H / 2);
          const wy = Math.exp(-(dy * dy) / (2 * SIGMA_Y * SIGMA_Y));
          t[j] = top + dy * wx * wy * PULL * active;
        });
        for (let j = 1; j < t.length; j++) if (t[j] < t[j - 1] + GAP) t[j] = t[j - 1] + GAP;
        for (let j = t.length - 2; j >= 0; j--) if (t[j] > t[j + 1] - GAP) t[j] = t[j + 1] - GAP;
        for (let j = 0; j < t.length; j++) {
          const d = t[j] - cur[j];
          if (Math.abs(d) < 0.03) {
            if (cur[j] !== t[j]) {
              cur[j] = t[j];
              rects[i][j].style.transform = `translateY(${(cur[j] - tops[j]).toFixed(2)}px)`;
            }
            continue;
          }
          cur[j] += d * k;
          rects[i][j].style.transform = `translateY(${(cur[j] - tops[j]).toFixed(2)}px)`;
          moving = true;
        }
      });
      return moving;
    },
    [160, 112],
  );
}

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

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

```js
/** Settle geometry (Figma 16:330): columns as [x, bar tops] in the 732 × 516 panel, sorted top to bottom. */
export const W = 732;
export const H = 516;
export const BAR_W = 31.287;
export const BAR_H = 8.683;
export const COLS = [[17,[47.17,100.05,150.79,197.26,241.59,282.18,320.1,353.22,383.13,411.44,434.4,455.23,473.93,491.29]],[52.04,[64.54,116.88,167.09,214.62,257.35,299.55,336.4,370.58,401.56,428.8,452.3,473.67,491.29]],[87.08,[26.61,81.09,135.57,186.32,232.78,277.11,318.78,355.63,388.74,419.72,448.03,469.93,491.29]],[122.11,[49.58,103.53,156.4,207.68,254.68,298.48,340.14,376.46,410.64,441.09,469.39,491.29]],[157.15,[73.08,129.7,183.64,233.32,280.85,323.05,365.24,401.56,437.35,467.26,491.29]],[192.19,[42.1,102.46,158.01,211.42,262.16,308.63,352.96,392.48,432.01,464.59,491.29]],[227.23,[73.08,132.9,188.98,242.93,293.67,341.21,383.4,424.53,462.45,491.29]],[262.26,[44.24,104.6,166.02,222.1,276.05,325.72,373.25,416.52,458.18,491.29]],[297.3,[17,81.09,144.12,202.34,257.89,363.11,408.51,450.7,491.29]],[332.34,[54.92,120.62,184.18,245.07,300.08,351.89,403.17,448.56,491.29]],[367.38,[17,59.73,105.13,156.4,208.21,263.23,324.11,387.67,453.37]],[402.41,[17,57.59,99.79,145.19,250.41,305.96,364.17,427.2,491.29]],[437.45,[17,50.11,91.78,135.04,182.57,232.25,286.19,342.28,403.7,464.05]],[472.49,[17,45.84,83.76,124.89,167.09,214.62,265.36,319.31,375.39,435.21]],[507.53,[17,43.7,76.29,115.81,155.33,199.67,246.13,296.87,350.29,405.83,466.19]],[542.56,[17,41.04,70.95,106.73,143.05,185.25,227.44,274.98,324.65,378.6,435.21]],[577.6,[17,38.9,67.21,97.65,131.83,168.15,209.82,253.61,300.61,351.89,404.77,458.71]],[612.64,[17,38.36,60.26,88.57,119.55,152.66,189.52,231.18,275.51,321.98,372.72,427.2,481.68]],[647.68,[17,34.63,55.99,79.49,106.73,137.71,171.89,208.75,250.94,293.67,341.21,391.41,443.76]],[682.71,[17,34.36,53.06,73.89,96.86,125.16,155.08,188.19,226.11,266.71,311.04,357.5,408.24,461.12]]];
```

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

## Agent brief

## 1. What it is

Twenty columns of thin bars, denser toward the floor on the left and the ceiling on the right. Under the pointer the bars gather toward it along their columns, never overlapping, and relax 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; bars gather under the pointer along their columns.
- **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/settle` (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.
