# Tar button

> A glossy black button made of sticky tar: drag away from it and it pulls out into strands that sag, twist and snap one by one.

A black pill on a pale field, glossy as wet tar, with reflections that slide across it. Press it and drag away, from any side, and its surface bulges after the pointer and pulls out into thin strands of different thickness that stretch to the hand, sag, twist round each other and snap one by one when pulled too far. Let go and the rest whip back, leaving drips and little hairs that sink into the pill. A plain click, Enter or Space still presses it.

A React component by Bruno Delorence: https://brunodelorence.com/components/tar-button. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Stage**: The field around the pill, with room on every side for the strands. The canvas covers it, and the glove is the cursor over it.
2. **Pill**: A real button over the tar the canvas draws: 46% of the stage’s width, between 196 and 324 px, 10 : 3.
3. **Label**: Bold, tight, near white. It rides the surface: it shifts and leans a little as the tar bulges.

## When to use

- One call to action that should be remembered: a contact or “let’s talk” button at the end of a portfolio or a landing page.
- A page with room around the button, about 400 px tall or more, so the strands have somewhere to go.
- A playful brand, where touching the button is part of the message.
- A pointer or touch screen; the button still works as a plain button by keyboard.

## When not to use

- Forms, toolbars and dialogs, or anywhere several buttons sit together. Use a plain button.
- A destructive or serious action, such as paying or deleting: the play gets in the way.
- Tight spaces, under about 360 × 300 px: the strands need room.
- A page that already moves a lot; one tar button is plenty.

## Installation

Install it with the shadcn CLI, into components/tar-button of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/tar-button.json
```

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/tar-button
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
'use client';

import { TarButton } from './tar-button';

export function Contact() {
  return (
    <TarButton
      label="let’s collaborate"
      strands={7}
      viscosity={0.5}
      onPress={() => window.open('mailto:hello@example.com')}
      autoplay={false}
    />
  );
}
```

## States

- **Rest**: Glossy black, a window’s reflection at each end, a bright bevel along the bottom and a soft shadow.
- **Pulled from below**: Held below and to the right: the strands sag between the edge and the hand, and the edge bulges after them.
- **Pulled from above**: Held above the right end: the strands rise out of the top, crossing as they twist.
- **Dark**: The tar stays black on a dark field; a hairline of light round its edge and its strands lets it read.

## How it works

- The pill and the strands are drawn on one canvas over the whole stage, at the screen’s density. The button is a real <button> laid over the pill by the stylesheet, transparent but for its words; the script measures it and draws the tar under it.
- The pill’s edge is walked by arc length round the capsule. Its outline is 128 points of that walk, each pushed out by springs: one rounded swelling centred on the pull, mostly along the edge’s normal so it eases back into the capsule on both sides, and a small bump at the root of each strand, along it. The springs are underdamped, so the surface wobbles when let go.
- Each strand is a verlet rope of 18 nodes, pinned at its root on the deformed edge and at the hand, each at a small offset that turns so the strands twist round each other. Its rest length trails the distance to the hand at a rate set by the viscosity: a quick pull draws them taut, a slow one leaves them slack, and gravity sags them.
- A strand snaps when the hand goes past its own reach, or when it is yanked well past its rest length. It splits: the part on the pill shrinks to a stub, whips back and hangs as a drip or stands as a hair (a pull along the edge’s normal fights gravity), then sinks in; the part in the hand shrinks and fades. Letting go does the same to the strands still held.
- A strand is drawn as a filled outline, thinner the more it is stretched, flared into a fillet at its root and a bead at a free end, with a glint along the thick ones; thin filaments join neighbours until they part too far. The pill gets a long soft shadow, a window’s reflection at each end, soft bands of light along the top that slide as the surface moves, and a bright bevel along the bottom.
- Frames run only while something moves and the stage is on screen. The canvas reads its colours from two hidden probes that carry the tokens, and repaints when a 1 ms transition on one says the scheme changed. The glove is drawn in SVG and used as the cursor, and as the demo’s pointer, which grabs from below, from the right and from the left in a loop.

## API reference

- **label** (`string`, default `'let’s collaborate'`): The button’s words.
- **strands** (`number`, default `7`): How many strands a pull draws out of the tar, 1 to 16.
- **viscosity** (`number`, default `0.5`): How thick the tar is, 0 to 1: thicker tar lags further behind the pointer, sags less and stretches further before a strand snaps.
- **onPress** (`() => void`): Called when the button is pressed: a click, a tap, Enter or Space. A pull that ends away from where it started is not a press.
- **onSnap** (`(held: number) => void`): Called when a strand snaps, with how many are still held.
- **onRelease** (`() => void`): Called when the reader lets go of a pull.
- **defaultPull** (`{ x: number; y: number }`): Opens held in a pull toward this point, in pill widths from the pill’s middle (x right, y down), for a still view of the strands; turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a glove pulls at the tar on its own; any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Classes for the stage.

## Accessibility

- The pill is a native button named by its words: Tab reaches it, Enter and Space press it, and a click or a tap still presses it however sticky it looks.
- The pull is decoration on top of the button: nothing depends on it, and it never takes the focus or changes what the button does.
- A visible focus ring follows the pill’s shape. The canvas, the probes and the demo’s glove are hidden from assistive technology.
- Under reduced motion the demo does not play and the springs are critically damped: the tar still follows a pull, without the wobble.
- Touch starting on the pill pulls instead of scrolling the page; touch anywhere else on the stage scrolls as usual.

## Source

### tar-button/tar-button.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { LABEL, STRANDS, VISCOSITY } from './data';
import { DemoPointer, pointerAt, runDemo, type Point } from './demo-pointer';
import { OPEN_TIP, gloveCursor } from './glove';
import { Tar, type Palette } from './tar';
import { useAutoplay } from './use-autoplay';
import styles from './tar-button.module.css';

/*
 * Tar button: a glossy black pill made of sticky tar. Press it and drag away, from any side, and
 * its surface pulls out into strands that stretch to the pointer, sag, twist round each other and
 * snap one by one; let go and the rest whip back, leaving drips and hairs that sink into the pill.
 * A plain click, Enter or Space still presses it.
 *
 * - The pill and the strands are drawn on one canvas over the whole stage (tar.ts); the button is
 *   a real <button> laid over the pill, transparent but for its words, which ride the surface.
 * - The stylesheet lays the pill out; the script measures it, sizes the canvas to the screen's
 *   density and runs frames only while something moves and the stage is on screen.
 * - The canvas cannot read the colour tokens, so two hidden probes carry them as colours; a 1 ms
 *   transition on one tells the script when the scheme changes, and it repaints.
 * - While on screen and untouched, a glove grabs the pill from below, from the right and from the
 *   left, pulls strands out, snaps some and lets go, in a loop. Any input of the reader’s stops it.
 */

/** The probes' properties, and the token each carries. */
const PROBES: [number, keyof CSSStyleDeclaration, keyof Palette][] = [
  [0, 'color', 'tar'],
  [0, 'backgroundColor', 'tarTop'],
  [0, 'borderTopColor', 'tarBottom'],
  [0, 'borderRightColor', 'sheen'],
  [0, 'borderLeftColor', 'rim'],
  [1, 'color', 'edge'],
  [1, 'backgroundColor', 'shadow'],
  [1, 'borderTopColor', 'shadowNear'],
  [1, 'borderRightColor', 'strandShine'],
  [1, 'borderBottomColor', 'strandShadow'],
  [1, 'borderLeftColor', 'web'],
];

function parse(color: string): Palette['tar'] {
  const parts = color.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
  return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, parts[3] ?? 1];
}

/** The stage's cursors: the open glove, and the fist while it holds the tar. */
const CURSORS = {
  '--cursor-open': gloveCursor('open', 36, 'pointer'),
  '--cursor-grab': gloveCursor('grab', 36, 'grabbing'),
} as CSSProperties;

/** How far a press may move, in px, and still count as a click. */
const SLOP = 6;

export function TarButton({
  label = LABEL,
  strands = STRANDS,
  viscosity = VISCOSITY,
  onPress,
  onSnap,
  onRelease,
  defaultPull,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The button's words. */
  label?: string;
  /** How many strands a pull draws out, 1 to 16. */
  strands?: number;
  /** How thick the tar is, 0 to 1: thicker lags and stretches further before it snaps. */
  viscosity?: number;
  /** Called when the button is pressed: a click, a tap, Enter or Space (not a pull). */
  onPress?: () => void;
  /** Called when a strand snaps, with how many are still held. */
  onSnap?: (held: number) => void;
  /** Called when the reader lets go of a pull. */
  onRelease?: () => void;
  /**
   * Opens held in a pull toward this point, in pill widths from the pill's middle (x right, y
   * down), for a still view of the strands; turns the demo off.
   */
  defaultPull?: { x: number; y: number };
  /** While on screen and untouched, a glove pulls at the tar on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader’s ('system'); without it, the page’s color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && playing && !defaultPull;

  const canvas = useRef<HTMLCanvasElement>(null);
  const button = useRef<HTMLButtonElement>(null);
  const words = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const probeA = useRef<HTMLSpanElement>(null);
  const probeB = useRef<HTMLSpanElement>(null);
  const tar = useRef<Tar | null>(null);
  /** Starts the frames, if they are not running. */
  const kick = useRef(() => {});
  const visibleRef = useRef(visible);
  /** The demo's glove holds the tar: the frames follow it. */
  const demoHolds = useRef(false);
  /** The reader's press: where it started, and whether it became a pull. */
  const press = useRef<{ id: number; x: number; y: number; pulled: boolean } | null>(null);
  /** Swallows the click that ends a pull. */
  const swallow = useRef(false);

  const snapped = useEffectEvent((held: number) => onSnap?.(held));

  useEffect(() => {
    visibleRef.current = visible;
    if (visible) kick.current();
  }, [visible]);

  // The canvas: sized to the stage, coloured from the tokens, drawn while anything moves.
  useEffect(() => {
    const stage = root.current;
    const surface = canvas.current;
    const pill = button.current;
    const a = probeA.current;
    const b = probeB.current;
    if (!stage || !surface || !pill || !a || !b) return;
    const engine = new Tar(surface);
    tar.current = engine;
    engine.onSnap = (held) => snapped(held);

    let frame = 0;
    let last = 0;
    const paint = () => {
      engine.draw();
      if (words.current) words.current.style.transform = engine.labelTransform();
    };
    const tick = (now: number) => {
      frame = 0;
      const dt = Math.min((now - last) / 1000, 1 / 30);
      last = now;
      if (demoHolds.current && pointer.current) {
        const at = pointerAt(pointer.current);
        engine.move(at.x, at.y);
      }
      engine.step(dt);
      paint();
      if (visibleRef.current && (engine.active() || demoHolds.current)) {
        frame = requestAnimationFrame(tick);
      }
    };
    kick.current = () => {
      if (frame || !visibleRef.current) return;
      last = performance.now();
      frame = requestAnimationFrame(tick);
    };

    const readColors = () => {
      const styles = [getComputedStyle(a), getComputedStyle(b)];
      const palette = {} as Palette;
      for (const [probe, property, token] of PROBES) {
        palette[token] = parse(String(styles[probe][property]));
      }
      engine.setColors(palette);
      paint();
    };

    const measure = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      engine.layout(
        stage.offsetWidth,
        stage.offsetHeight,
        { x: pill.offsetLeft, y: pill.offsetTop, w: pill.offsetWidth, h: pill.offsetHeight },
        dpr,
      );
      paint();
    };
    measure();
    readColors();
    const resized = new ResizeObserver(measure);
    resized.observe(stage);
    resized.observe(pill);
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const onScheme = () => requestAnimationFrame(readColors);
    a.addEventListener('transitionend', onScheme);
    media.addEventListener('change', onScheme);

    return () => {
      cancelAnimationFrame(frame);
      resized.disconnect();
      a.removeEventListener('transitionend', onScheme);
      media.removeEventListener('change', onScheme);
      kick.current = () => {};
      tar.current = null;
    };
  }, [root]);

  // The props the tar reads (after the canvas effect, which makes it).
  useEffect(() => {
    const engine = tar.current;
    if (!engine) return;
    engine.count = Math.round(Math.min(16, Math.max(1, strands)));
    engine.viscosity = Math.min(1, Math.max(0, viscosity));
    engine.reduced = reduced;
  }, [strands, viscosity, reduced]);

  // A held pull to open with: played through in a moment, then left still.
  const pullKey = defaultPull ? `${defaultPull.x},${defaultPull.y}` : '';
  useEffect(() => {
    const engine = tar.current;
    const pill = button.current;
    if (!engine || !pill || !pullKey) return;
    const [fx, fy] = pullKey.split(',').map(Number);
    const w = pill.offsetWidth;
    const h = pill.offsetHeight;
    const cx = pill.offsetLeft + w / 2;
    const cy = pill.offsetTop + h / 2;
    const from = {
      x: cx + Math.max(-0.4, Math.min(0.4, fx)) * w,
      y: cy + Math.sign(fy) * h * 0.3,
    };
    const to = { x: cx + fx * w, y: cy + fy * w };
    engine.press(from.x, from.y);
    for (let t = 0; t < 1.6; t += 1 / 60) {
      const k = Math.min(1, t / 0.8);
      const ease = k * k * (3 - 2 * k);
      engine.move(from.x + (to.x - from.x) * ease, from.y + (to.y - from.y) * ease);
      engine.step(1 / 60);
    }
    engine.draw();
    if (words.current) words.current.style.transform = engine.labelTransform();
    // The fist holds the strands where they end.
    const glove = pointer.current;
    if (glove) {
      const tip = {
        x: (glove.offsetWidth * OPEN_TIP.x) / 48,
        y: (glove.offsetHeight * OPEN_TIP.y) / 48,
      };
      glove.dataset.glyph = 'grab';
      glove.style.transform = `translate(${to.x - tip.x}px, ${to.y - tip.y}px)`;
      glove.style.opacity = '1';
    }
    return () => {
      if (glove) glove.style.opacity = '0';
      engine.release();
      kick.current();
    };
  }, [pullKey]);

  // The demo: the glove grabs from below, from the right and from the left, in a loop.
  useEffect(() => {
    const stage = root.current;
    const glove = pointer.current;
    const pill = button.current;
    const engine = tar.current;
    if (!demo || !stage || !glove || !pill || !engine) return;
    // Points in pill widths from the pill's middle.
    const P = (fx: number, fy: number): Point => ({
      x: pill.offsetLeft + pill.offsetWidth / 2 + fx * pill.offsetWidth,
      y: pill.offsetTop + pill.offsetHeight / 2 + fy * pill.offsetWidth,
    });
    const stop = runDemo(stage, glove, async ({ wait, glide, glyph }) => {
      const grab = () => {
        glyph('grab');
        const at = pointerAt(glove);
        engine.press(at.x, at.y);
        demoHolds.current = true;
        kick.current();
      };
      const letGo = () => {
        glyph('open');
        demoHolds.current = false;
        engine.release();
        kick.current();
      };
      for (;;) {
        // From below: out, a swing to the left, then far to the right, where strands snap.
        await glide(P(0.18, 0.06), 1000);
        await wait(220);
        grab();
        await wait(160);
        await glide(P(0.24, 0.42), 700);
        await glide(P(0.04, 0.55), 900);
        await glide(P(0.5, 0.68), 1100);
        await wait(300);
        await glide(P(0.34, 0.52), 500);
        letGo();
        await wait(1100);
        // From the right end, round the corner to the top.
        await glide(P(0.43, 0.01), 800);
        await wait(200);
        grab();
        await wait(160);
        await glide(P(0.84, 0.06), 800);
        await glide(P(0.7, -0.45), 1000);
        await glide(P(0.22, -0.6), 900);
        await wait(200);
        await glide(P(-0.08, -0.66), 700);
        letGo();
        await wait(1100);
        // From the left end, down and away until most of them snap.
        await glide(P(-0.3, 0.05), 900);
        await wait(200);
        grab();
        await wait(160);
        await glide(P(-0.5, 0.45), 900);
        await glide(P(-0.86, 0.62), 1000);
        await wait(250);
        letGo();
        await wait(900);
        await glide(P(0.62, 0.62), 1000);
        await wait(600);
      }
    });
    return () => {
      stop();
      if (demoHolds.current) {
        demoHolds.current = false;
        engine.release();
        kick.current();
      }
    };
  }, [demo, root]);

  /** A pointer event's place on the stage, in its own px (the stage may be scaled). */
  const local = (event: ReactPointerEvent) => {
    const stage = root.current!;
    const rect = stage.getBoundingClientRect();
    const k = stage.offsetWidth ? rect.width / stage.offsetWidth : 1;
    return { x: (event.clientX - rect.left) / k, y: (event.clientY - rect.top) / k };
  };

  const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0 || press.current) return;
    const at = local(event);
    event.currentTarget.setPointerCapture(event.pointerId);
    press.current = { id: event.pointerId, x: at.x, y: at.y, pulled: false };
    swallow.current = false;
    root.current!.dataset.holding = '';
    // The reader takes the tar from the demo's glove, or from a still pull.
    if (demoHolds.current || tar.current?.pulling) {
      demoHolds.current = false;
      tar.current?.release();
    }
    tar.current?.press(at.x, at.y);
    kick.current();
  };

  const onPointerMove = (event: ReactPointerEvent) => {
    const at = local(event);
    const held = press.current;
    if (held && held.id === event.pointerId) {
      if (Math.hypot(at.x - held.x, at.y - held.y) > SLOP) held.pulled = true;
      tar.current?.move(at.x, at.y);
    } else if (event.pointerType === 'mouse') tar.current?.hoverAt(at);
    kick.current();
  };

  const end = (event: ReactPointerEvent) => {
    const held = press.current;
    if (!held || held.id !== event.pointerId) return;
    press.current = null;
    delete root.current!.dataset.holding;
    const pulled = held.pulled || Boolean(tar.current?.pulling);
    swallow.current = pulled;
    tar.current?.release();
    if (pulled) onRelease?.();
    kick.current();
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      style={CURSORS}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        // The reader's own pointer on the stage takes over from the demo's.
        if (event.pointerType !== 'touch') take();
        onPointerMove(event);
      }}
      onPointerLeave={() => {
        tar.current?.hoverAt(null);
        kick.current();
      }}
    >
      <canvas ref={canvas} className={styles.canvas} aria-hidden="true" />
      <button
        ref={button}
        type="button"
        className={styles.pill}
        data-part="pill"
        onPointerDown={onPointerDown}
        onPointerUp={end}
        onPointerCancel={end}
        onLostPointerCapture={end}
        onClick={() => {
          // The click that ends a pull is not a press.
          if (swallow.current) {
            swallow.current = false;
            return;
          }
          tar.current?.poke();
          kick.current();
          onPress?.();
        }}
      >
        <span ref={words} className={styles.label} data-part="label">
          {label}
        </span>
      </button>
      <span ref={probeA} className={styles.probeA} aria-hidden="true" />
      <span ref={probeB} className={styles.probeB} aria-hidden="true" />
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          openClass={styles.open}
          grabClass={styles.grab}
          gloveClass={styles.glove}
        />
      </div>
    </div>
  );
}
```

### tar-button/tar-button.module.css

```css
/* Tar button (tar-button.tsx): a near-white field with the black pill in its middle, or at night a
   near-black one where the tar keeps its black and reads by its rims and reflections. The pill is
   half the stage's width (200 to 400 px), 10 : 3; the stage is 72% as tall as it is wide.

   The canvas draws the pill and its strands; it cannot read these tokens, so the two probes at
   the end carry them as colours, and a 1 ms transition on the first tells the script when the
   scheme changes. Colours are light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). */

.root {
  /* The field, and the words on the pill. */
  --stage: #f3f3f3;
  --label: #f2f2f2;
  /* The tar: its body, a little lighter at the top and the bottom; the reflections on it. */
  --tar: #050506;
  --tar-top: #1b1b1d;
  --tar-bottom: #111113;
  --sheen: rgb(214 220 224 / 0.42);
  --rim: rgb(236 238 242 / 0.62);
  /* A hairline of light round the pill and the strands: none by day. */
  --edge: rgb(255 255 255 / 0);
  /* Shadows: the pill's long soft one and its near one, the strands'. */
  --shadow: rgb(0 0 0 / 0.2);
  --shadow-near: rgb(0 0 0 / 0.22);
  --strand-shadow: rgb(0 0 0 / 0.035);
  /* A glint along the thick strands; the filaments between them. */
  --strand-shine: rgb(255 255 255 / 0.2);
  --web: rgb(10 10 12 / 0.75);
  /* The same on both schemes: the focus ring, the glove. */
  --focus: #387bff;
  --glove: #fff;
  --glove-ink: #111;
  --pointer-shadow: rgb(0 0 0 / 0.16);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);

  --pill-w: clamp(200px, 50cqi, 400px);

  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  font-family: var(--sans);
  cursor: var(--cursor-open);
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  -webkit-user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f3f3f3, #141416);
    --label: light-dark(#f2f2f2, #f2f2f2);
    --tar: light-dark(#050506, #030304);
    --tar-top: light-dark(#1b1b1d, #232327);
    --tar-bottom: light-dark(#111113, #1a1a1e);
    --sheen: light-dark(rgb(214 220 224 / 0.42), rgb(214 222 232 / 0.5));
    --rim: light-dark(rgb(236 238 242 / 0.62), rgb(236 240 248 / 0.7));
    --edge: light-dark(rgb(255 255 255 / 0), rgb(210 216 228 / 0.34));
    --shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.7));
    --shadow-near: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.6));
    --strand-shadow: light-dark(rgb(0 0 0 / 0.035), rgb(0 0 0 / 0.35));
    --strand-shine: light-dark(rgb(255 255 255 / 0.2), rgb(255 255 255 / 0.32));
    --web: light-dark(rgb(10 10 12 / 0.75), rgb(190 196 210 / 0.45));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root[data-holding] {
  cursor: var(--cursor-grab);
}

/* The stage: room all round the pill for the strands. */
.canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.root::before {
  content: '';
  display: block;
  grid-area: 1 / 1;
  width: 100%;
  height: clamp(400px, 72cqi, 600px);
}

/* The button: transparent over the pill the canvas draws, with the words on it. */
.pill {
  position: relative;
  z-index: 1;
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  width: var(--pill-w);
  height: calc(var(--pill-w) * 0.3);
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--label);
  font: inherit;
  font-size: calc(var(--pill-w) * 0.093);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1;
  white-space: nowrap;
  cursor: inherit;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}
.pill:focus {
  outline: none;
}
.pill:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 5px;
}
.label {
  display: block;
  padding: 0 0.6em;
  text-shadow: 0 0 0.6px rgb(255 255 255 / 0.4);
  transform-origin: 50% 50%;
  will-change: transform;
}

/* The probes: unseen, each colour a token for the canvas. */
.probeA,
.probeB {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  visibility: hidden;
  pointer-events: none;
}
.probeA {
  color: var(--tar);
  background-color: var(--tar-top);
  border-top-color: var(--tar-bottom);
  border-right-color: var(--sheen);
  border-left-color: var(--rim);
  /* Tells the script that the scheme changed; kept under reduced motion. */
  transition: color 1ms !important;
}
.probeB {
  color: var(--edge);
  background-color: var(--shadow);
  border-top-color: var(--shadow-near);
  border-right-color: var(--strand-shine);
  border-bottom-color: var(--strand-shadow);
  border-left-color: var(--web);
}

/* ---- The demo's glove ------------------------------------------------------------------------ */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
}
.pointer {
  width: clamp(38px, calc(var(--pill-w) * 0.16), 62px);
  height: clamp(38px, calc(var(--pill-w) * 0.16), 62px);
}
.glove {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.grab {
  visibility: hidden;
}
.pointer[data-glyph='grab'] .open {
  visibility: hidden;
}
.pointer[data-glyph='grab'] .grab {
  visibility: visible;
}
```

### tar-button/tar.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The tar: the pill's body, and the strands a pull draws out of it, simulated and drawn on a
 * canvas. No React here; tar-button.tsx feeds it the pointer and a frame clock.
 *
 * - The pill is a capsule, walked by arc length s round its edge (clockwise from the top left).
 *   Its outline is 128 points of that walk, each pushed outwards by the springs on top of it: one
 *   rounded swelling toward the pointer, and one narrow bump at the root of each strand, along it.
 *   Springs that are not critically damped wobble when let go.
 * - A strand is a verlet rope of up to 18 nodes, pinned at its root on the deformed outline and,
 *   while held, at the hand (each one at its own small offset that turns, so they twist round
 *   each other). Its rest length trails the distance to the hand at a rate set by the viscosity:
 *   a quick pull draws them taut, a slow one leaves them slack, and gravity sags them.
 * - A held strand snaps when the hand gets further than its own reach, or when it is yanked well
 *   past its rest length. It splits: the part on the pill whips back (its rest length shrinks to a
 *   stub), hangs as a drip or stands as a hair (a pull toward the edge's normal fights gravity),
 *   then sinks into the pill; the part in the hand shrinks and fades. Letting go does the same to
 *   every strand still held.
 * - Thin filaments join neighbouring strands and break when they part too far.
 * - Each strand is drawn as a filled outline: thin in its middle, thinner the more it is
 *   stretched, flared into a fillet at its root and a bead at a free end.
 */

export type Box = { x: number; y: number; w: number; h: number };

/** The colours the canvas uses, read from the stylesheet's tokens: [r, g, b, a]. */
export type Palette = Record<
  | 'tar'
  | 'tarTop'
  | 'tarBottom'
  | 'sheen'
  | 'rim'
  | 'edge'
  | 'shadow'
  | 'shadowNear'
  | 'strandShine'
  | 'strandShadow'
  | 'web',
  [number, number, number, number]
>;

type Node = { x: number; y: number; px: number; py: number };

type Strand = {
  nodes: Node[];
  /** Held by the hand; a root left on the pill; or a tail left in the hand. */
  kind: 'held' | 'root' | 'tail';
  /** Arc position of the root: an offset from the grab's centre while held, then absolute. */
  off: number;
  s: number;
  /** Each segment's rest length, and what it heads for once let go. */
  rest: number;
  stub: number;
  slack: number;
  base: number;
  reach: number;
  /** The hand end's offset from the hand: a radius, an angle and how fast that turns. */
  twistR: number;
  twist: number;
  spin: number;
  age: number;
  /** When it starts sinking into the pill, in seconds of age. */
  sinkAt: number;
  fade: number;
  /** The bump at its root: amplitude, speed, direction. */
  bump: number;
  bumpV: number;
  dx: number;
  dy: number;
  phase: number;
};

type Web = { a: Strand; b: Strand; ia: number; ib: number };

const NODES = 18;
const OUTLINE = 128;
const STEP = 1 / 120;

const rand = (a: number, b: number) => a + Math.random() * (b - a);
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));

const rgba = ([r, g, b, a]: Palette['tar'], k = 1) =>
  `rgba(${r},${g},${b},${Math.round(a * k * 1000) / 1000})`;

/** A damped spring toward `to`: stiffness k, damping ratio z. Returns [value, speed]. */
function spring(v: number, s: number, to: number, k: number, z: number, dt: number) {
  const a = (to - v) * k - s * 2 * z * Math.sqrt(k);
  const ns = s + a * dt;
  return [v + ns * dt, ns] as const;
}

export class Tar {
  private ctx: CanvasRenderingContext2D;
  private dpr = 1;
  private W = 0;
  private H = 0;
  private box: Box = { x: 0, y: 0, w: 0, h: 0 };
  private colors: Palette | null = null;

  count = 7;
  viscosity = 0.5;
  reduced = false;
  onSnap: (held: number) => void = () => {};

  private strands: Strand[] = [];
  private webs: Web[] = [];
  private hand: { x: number; y: number } | null = null;
  private hover: { x: number; y: number } | null = null;
  private grabbing = false;
  private spawned = false;
  /** The arc position the strands come out round. */
  private centre = 0;
  private time = 0;
  private spare = 0;

  private bx = 0;
  private by = 0;
  private bvx = 0;
  private bvy = 0;
  private squish = 0;
  private squishV = 0;
  private sheen = 0;
  private sheenV = 0;
  /** A tap's ripple, which wobbles the outline. */
  private poked = 0;
  private pokedV = 0;

  constructor(private canvas: HTMLCanvasElement) {
    this.ctx = canvas.getContext('2d')!;
  }

  /** The stage's size, the pill's box in it, and the screen's density. */
  layout(W: number, H: number, box: Box, dpr: number) {
    this.W = W;
    this.H = H;
    this.box = box;
    this.dpr = dpr;
    this.canvas.width = Math.round(W * dpr);
    this.canvas.height = Math.round(H * dpr);
  }

  setColors(colors: Palette) {
    this.colors = colors;
  }

  /** Whether anything still moves: when not, the frames can stop. */
  active() {
    const still = (v: number, s: number, e = 0.05) => Math.abs(v) < e && Math.abs(s) < e * 4;
    return (
      this.grabbing ||
      this.strands.length > 0 ||
      !still(this.bx, this.bvx) ||
      !still(this.by, this.bvy) ||
      !still(this.squish, this.squishV, 0.002) ||
      !still(this.poked, this.pokedV, 0.002) ||
      !still(this.sheen - this.sheenTarget(), this.sheenV, 0.1)
    );
  }

  /** A held pull, where strands are out. */
  get pulling() {
    return this.spawned;
  }

  hoverAt(point: { x: number; y: number } | null) {
    this.hover = point;
  }

  press(x: number, y: number) {
    this.grabbing = true;
    this.spawned = false;
    this.hand = { x, y };
  }

  move(x: number, y: number) {
    if (this.hand) {
      this.hand.x = x;
      this.hand.y = y;
    }
  }

  /** Lets go: every strand still held whips back to the pill. */
  release() {
    this.grabbing = false;
    this.spawned = false;
    for (const strand of this.strands) if (strand.kind === 'held') this.letGo(strand);
    this.webs = [];
    this.hand = null;
  }

  /** A click or a key: a quick squeeze that ripples out. */
  poke() {
    this.pokedV += this.reduced ? 0 : 9;
  }

  /** The label's transform, so the words ride on the surface. */
  labelTransform() {
    const { h } = this.box;
    const q = this.squish + this.poked;
    const tx = (this.bx * 0.07).toFixed(2);
    const ty = (this.by * 0.07).toFixed(2);
    return `translate(${tx}px, ${ty}px) scale(${(1 + q * 0.03).toFixed(4)}, ${(1 - q * 0.05).toFixed(4)}) skewX(${clamp((-this.bx / h) * 3, -6, 6).toFixed(2)}deg)`;
  }

  // ---- The pill's edge ---------------------------------------------------------------------

  private get perimeter() {
    const { w, h } = this.box;
    return 2 * (w - h) + Math.PI * h;
  }

  /** The undeformed edge at arc position s: point and outward normal. */
  private at(s: number) {
    const { x, y, w, h } = this.box;
    const r = h / 2;
    const L = w - h;
    const P = this.perimeter;
    s = ((s % P) + P) % P;
    if (s < L) return { x: x + r + s, y, nx: 0, ny: -1 };
    s -= L;
    if (s < Math.PI * r) {
      const a = -Math.PI / 2 + s / r;
      return {
        x: x + w - r + Math.cos(a) * r,
        y: y + r + Math.sin(a) * r,
        nx: Math.cos(a),
        ny: Math.sin(a),
      };
    }
    s -= Math.PI * r;
    if (s < L) return { x: x + w - r - s, y: y + h, nx: 0, ny: 1 };
    s -= L;
    const a = Math.PI / 2 + s / r;
    return {
      x: x + r + Math.cos(a) * r,
      y: y + r + Math.sin(a) * r,
      nx: Math.cos(a),
      ny: Math.sin(a),
    };
  }

  /** The arc position nearest a point, and how far outside the pill the point is. */
  private nearest(px: number, py: number) {
    const { x, y, w, h } = this.box;
    const r = h / 2;
    const L = w - h;
    const cx = clamp(px, x + r, x + w - r);
    const dx = px - cx;
    const dy = py - (y + r);
    const out = Math.hypot(dx, dy) - r;
    let s: number;
    if (px >= x + r && px <= x + w - r)
      s = dy < 0 ? px - (x + r) : L + Math.PI * r + (x + w - r - px);
    else if (px > x + w - r) s = L + (Math.atan2(dy, dx) + Math.PI / 2) * r;
    else {
      let a = Math.atan2(dy, dx);
      if (a < 0) a += Math.PI * 2;
      s = 2 * L + Math.PI * r + (a - Math.PI / 2) * r;
    }
    return { s, out };
  }

  /** The shortest way round the edge from a to b, signed. */
  private arc(a: number, b: number) {
    const P = this.perimeter;
    let d = (((b - a) % P) + P) % P;
    if (d > P / 2) d -= P;
    return d;
  }

  /** The deformed edge at s: the bulge, the strands' bumps and the squeeze on top. */
  private edge(s: number) {
    const { x, y, w, h } = this.box;
    const p = this.at(s);
    let ox = 0;
    let oy = 0;
    // One rounded swelling centred on the pull, easing back into the capsule on both sides. It
    // grows mostly along the edge's own normal (so the outline swells rather than shears), and a
    // little toward the pull.
    const sigma = w * 0.06 + h * 0.28;
    const g = Math.exp(-((this.arc(this.centre, s) / sigma) ** 2));
    if (g > 0.002) {
      const c = this.at(this.centre);
      const out = this.bx * c.nx + this.by * c.ny;
      ox += (p.nx * out * 0.8 + this.bx * 0.2) * g;
      oy += (p.ny * out * 0.8 + this.by * 0.2) * g;
    }
    const narrow = h * 0.13;
    for (const strand of this.strands) {
      if (strand.kind === 'tail' || Math.abs(strand.bump) < 0.05) continue;
      const d = this.arc(strand.s, s) / narrow;
      if (d > 3 || d < -3) continue;
      const k = strand.bump * Math.exp(-d * d);
      ox += strand.dx * k;
      oy += strand.dy * k;
    }
    // The squeeze of a press, and the ripple of a tap round the edge.
    const q = this.squish + this.poked;
    const cx = x + w / 2;
    const cy = y + h / 2;
    const ripple =
      this.poked * h * 0.04 * Math.sin((s / this.perimeter) * Math.PI * 8 + this.time * 9);
    return {
      x: cx + (p.x - cx) * (1 + q * 0.03) + ox + p.nx * ripple,
      y: cy + (p.y - cy) * (1 - q * 0.06) + oy + p.ny * ripple,
      nx: p.nx,
      ny: p.ny,
    };
  }

  // ---- Strands -----------------------------------------------------------------------------

  private spawn(s: number) {
    const { w, h } = this.box;
    const hand = this.hand!;
    this.centre = s;
    this.spawned = true;
    const n = Math.max(1, Math.round(this.count));
    const spread = Math.min(w * 0.21, n * h * 0.1);
    const made: Strand[] = [];
    for (let i = 0; i < n; i += 1) {
      const off = n === 1 ? 0 : ((i + 0.5) / n - 0.5) * 2 * spread + rand(-0.02, 0.02) * w;
      const root = this.edge(s + off);
      const strand: Strand = {
        nodes: [],
        kind: 'held',
        off,
        s: s + off,
        rest: 1,
        stub: 0,
        slack: rand(1.02, 1.3),
        base: h * rand(0.014, 0.034) * (i % 3 === 1 ? 1.4 : 1),
        reach: w * rand(0.42, 1.1) * (0.8 + this.viscosity * 0.4),
        twistR: rand(1, h * 0.08),
        twist: rand(0, Math.PI * 2),
        spin: rand(-2.4, 2.4),
        age: 0,
        sinkAt: 0,
        fade: 1,
        bump: 0,
        bumpV: 0,
        dx: root.nx,
        dy: root.ny,
        phase: rand(0, 10),
      };
      for (let j = 0; j < NODES; j += 1) {
        const t = j / (NODES - 1);
        const nx = root.x + (hand.x - root.x) * t;
        const ny = root.y + (hand.y - root.y) * t;
        strand.nodes.push({ x: nx, y: ny, px: nx, py: ny });
      }
      strand.rest = (Math.hypot(hand.x - root.x, hand.y - root.y) / (NODES - 1)) * strand.slack;
      made.push(strand);
    }
    // Filaments between neighbours, a couple per pair.
    for (let i = 0; i + 1 < made.length; i += 1) {
      for (let k = 0; k < 2; k += 1) {
        const ia = Math.floor(rand(2, NODES - 3));
        this.webs.push({
          a: made[i],
          b: made[i + 1],
          ia,
          ib: clamp(ia + Math.round(rand(-3, 3)), 1, NODES - 2),
        });
      }
    }
    this.strands.push(...made);
  }

  /** A held strand comes loose from the hand: it heads back to a stub on the pill. */
  private letGo(strand: Strand) {
    const { h } = this.box;
    strand.kind = 'root';
    strand.stub = h * rand(0.05, 0.2);
    strand.sinkAt = strand.age + rand(0.5, 1.1);
  }

  private snap(strand: Strand) {
    const { h } = this.box;
    const k = Math.floor((NODES - 1) * rand(0.45, 0.85));
    const tail: Strand = {
      ...strand,
      kind: 'tail',
      nodes: strand.nodes.slice(k).map((node) => ({ ...node })),
      bump: 0,
      bumpV: 0,
    };
    strand.nodes = strand.nodes.slice(0, k + 1);
    this.letGo(strand);
    strand.stub = h * rand(0.05, 0.2);
    // A hair or two springs up beside the root.
    for (let i = 0, n = Math.round(rand(0, 2)); i < n; i += 1)
      this.hair(strand.s + rand(-1, 1) * h * 0.2);
    this.strands.push(tail);
    this.webs = this.webs.filter((web) => web.a !== strand && web.b !== strand);
    this.onSnap(this.strands.filter((each) => each.kind === 'held').length);
  }

  private hair(s: number) {
    const { h } = this.box;
    const root = this.edge(s);
    const nodes: Node[] = [];
    for (let j = 0; j < 5; j += 1) {
      const x = root.x + root.nx * j;
      const y = root.y + root.ny * j;
      nodes.push({ x, y, px: x - root.nx * 1.5, py: y - root.ny * 1.5 });
    }
    this.strands.push({
      nodes,
      kind: 'root',
      off: 0,
      s,
      rest: 1,
      stub: h * rand(0.06, 0.13),
      slack: 1,
      base: h * rand(0.006, 0.01),
      reach: 0,
      twistR: 0,
      twist: 0,
      spin: 0,
      age: 0,
      sinkAt: rand(0.6, 1.3),
      fade: 1,
      bump: 0,
      bumpV: 0,
      dx: root.nx,
      dy: root.ny,
      phase: rand(0, 10),
    });
  }

  private sheenTarget() {
    const { x, w } = this.box;
    const hover = this.hover ? clamp((this.hover.x - (x + w / 2)) / w, -0.6, 0.6) * w * 0.12 : 0;
    return hover - this.bx * 0.9;
  }

  // ---- The clock ---------------------------------------------------------------------------

  step(dt: number) {
    const { w, h } = this.box;
    if (!w) return;
    this.time += dt;
    const z = this.reduced ? 1 : 0.24;
    const hand = this.hand;

    // The strands come out once the pointer leaves the pill; their roots then drift after it.
    if (this.grabbing && hand) {
      const near = this.nearest(hand.x, hand.y);
      if (!this.spawned && near.out > 2) this.spawn(near.s);
      else if (this.spawned)
        this.centre += this.arc(this.centre, near.s) * (1 - Math.exp(-dt * 0.45));
    }

    // The swelling, toward the hand while strands hold it.
    const held = this.strands.filter((each) => each.kind === 'held').length;
    let tx = 0;
    let ty = 0;
    if (held && hand) {
      const root = this.at(this.centre);
      const vx = hand.x - root.x;
      const vy = hand.y - root.y;
      const len = Math.hypot(vx, vy) || 1;
      const amount = Math.min(h * 0.16, len * 0.06) * Math.sqrt(held / Math.max(1, this.count));
      tx = (vx / len) * amount;
      ty = (vy / len) * amount;
    }
    [this.bx, this.bvx] = spring(this.bx, this.bvx, tx, 170, z, dt);
    [this.by, this.bvy] = spring(this.by, this.bvy, ty, 170, z, dt);
    const squeeze = this.grabbing && !this.spawned ? 1 : 0;
    [this.squish, this.squishV] = spring(
      this.squish,
      this.squishV,
      squeeze,
      320,
      this.reduced ? 1 : 0.3,
      dt,
    );
    [this.poked, this.pokedV] = spring(this.poked, this.pokedV, 0, 260, 0.18, dt);
    [this.sheen, this.sheenV] = spring(
      this.sheen,
      this.sheenV,
      this.sheenTarget(),
      60,
      this.reduced ? 1 : 0.45,
      dt,
    );

    // The ropes, in fixed steps.
    this.spare = Math.min(this.spare + dt, STEP * 4);
    while (this.spare >= STEP) {
      this.spare -= STEP;
      this.ropes(STEP);
    }

    this.strands = this.strands.filter((strand) => strand.fade > 0.02);
  }

  private ropes(dt: number) {
    const { w, h } = this.box;
    const hand = this.hand;
    const rate = 9 - this.viscosity * 6.5;
    const snapped: Strand[] = [];
    for (const strand of this.strands) {
      strand.age += dt;
      const nodes = strand.nodes;
      const n = nodes.length;
      if (strand.kind === 'held') strand.s = this.centre + strand.off * 0.85;
      const root = strand.kind === 'tail' ? null : this.edge(strand.s);
      let end: { x: number; y: number } | null = null;
      if (strand.kind !== 'root' && hand) {
        const turn = strand.twist + strand.spin * this.time;
        end = {
          x: hand.x + Math.cos(turn) * strand.twistR,
          y: hand.y + Math.sin(turn) * strand.twistR,
        };
      }

      // Rest length: trails the distance while held, shrinks to a stub once let go.
      if (strand.kind === 'held' && root && end) {
        const target = (Math.hypot(end.x - root.x, end.y - root.y) / (n - 1)) * strand.slack;
        strand.rest += (target - strand.rest) * (1 - Math.exp(-rate * dt));
      } else if (strand.kind === 'root') {
        if (strand.age > strand.sinkAt) {
          strand.stub *= Math.exp(-dt * 1.5);
          strand.fade = Math.min(strand.fade, clamp(strand.stub / (h * 0.05), 0, 1));
        }
        strand.rest += (strand.stub / (n - 1) - strand.rest) * (1 - Math.exp(-dt * 7));
      } else {
        strand.rest *= Math.exp(-dt * 6);
        strand.fade -= dt * 2.2;
      }

      // Move: verlet, with gravity; a root's nodes also lean along its edge's normal.
      const gravity =
        strand.kind === 'held' ? 1100 - this.viscosity * 500 : strand.kind === 'tail' ? 1400 : 320;
      const damping = strand.kind === 'held' ? 0.986 : 0.965;
      for (let i = 0; i < n; i += 1) {
        const node = nodes[i];
        const vx = (node.x - node.px) * damping;
        const vy = (node.y - node.py) * damping;
        node.px = node.x;
        node.py = node.y;
        node.x += vx;
        node.y += vy + gravity * dt * dt;
        if (strand.kind === 'root' && root) {
          const tx = root.x + root.nx * i * strand.rest;
          const ty = root.y + root.ny * i * strand.rest;
          node.x += (tx - node.x) * 0.06;
          node.y += (ty - node.y) * 0.06;
        }
      }

      // Hold the ends, keep the segments at their length.
      for (let k = 0; k < 10; k += 1) {
        if (root) {
          nodes[0].x = root.x;
          nodes[0].y = root.y;
        }
        if (end) {
          nodes[n - 1].x = end.x;
          nodes[n - 1].y = end.y;
        }
        for (let i = 0; i + 1 < n; i += 1) {
          const a = nodes[i];
          const b = nodes[i + 1];
          const dx = b.x - a.x;
          const dy = b.y - a.y;
          const d = Math.hypot(dx, dy) || 1e-6;
          const diff = (d - strand.rest) / d;
          const pinA = i === 0 && root !== null;
          const pinB = i + 1 === n - 1 && end !== null;
          if (pinA && pinB) continue;
          const ka = pinA ? 0 : pinB ? 1 : 0.5;
          const kb = 1 - ka;
          a.x += dx * diff * ka;
          a.y += dy * diff * ka;
          b.x -= dx * diff * kb;
          b.y -= dy * diff * kb;
        }
      }

      // The bump at the root follows the pull along the strand.
      if (root && n > 2) {
        const p = this.at(strand.s);
        const along = nodes[Math.min(2, n - 1)];
        let dx = along.x - p.x;
        let dy = along.y - p.y;
        const len = Math.hypot(dx, dy) || 1;
        dx = (dx / len) * 0.6 + p.nx * 0.4;
        dy = (dy / len) * 0.6 + p.ny * 0.4;
        const norm = Math.hypot(dx, dy) || 1;
        strand.dx = dx / norm;
        strand.dy = dy / norm;
        let target = 0;
        if (strand.kind === 'held' && end)
          target = Math.min(h * 0.035, Math.hypot(end.x - p.x, end.y - p.y) * 0.012);
        [strand.bump, strand.bumpV] = spring(
          strand.bump,
          strand.bumpV,
          target,
          240,
          this.reduced ? 1 : 0.22,
          dt,
        );
      }

      // Too far, or yanked too hard: it snaps.
      if (strand.kind === 'held' && root && end && strand.age > 0.12) {
        const span = Math.hypot(end.x - root.x, end.y - root.y);
        if (span > strand.reach || span > strand.rest * (n - 1) * 2.2 + w * 0.15)
          snapped.push(strand);
      }
    }
    for (const strand of snapped) this.snap(strand);
  }

  // ---- Drawing -----------------------------------------------------------------------------

  draw() {
    const { ctx, colors: c, dpr } = this;
    const { x, y, w, h } = this.box;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    if (!c || !w) return;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    // The deformed outline, smoothed through the midpoints.
    const P = this.perimeter;
    const pts: { x: number; y: number }[] = [];
    for (let i = 0; i < OUTLINE; i += 1) pts.push(this.edge((i / OUTLINE) * P));
    const pill = new Path2D();
    const mid = (a: { x: number; y: number }, b: { x: number; y: number }) =>
      [(a.x + b.x) / 2, (a.y + b.y) / 2] as const;
    pill.moveTo(...mid(pts[OUTLINE - 1], pts[0]));
    for (let i = 0; i < OUTLINE; i += 1) {
      const p = pts[i];
      pill.quadraticCurveTo(p.x, p.y, ...mid(p, pts[(i + 1) % OUTLINE]));
    }

    const shapes = this.strands.map((strand) => this.shape(strand));

    // Shadows: the pill's, soft and long, then a near one, then the strands'.
    ctx.save();
    ctx.shadowColor = rgba(c.shadow);
    ctx.shadowBlur = h * 0.55 * dpr;
    ctx.shadowOffsetX = h * 0.12 * dpr;
    ctx.shadowOffsetY = h * 0.22 * dpr;
    ctx.fillStyle = rgba(c.tar);
    ctx.fill(pill);
    ctx.shadowColor = rgba(c.shadowNear);
    ctx.shadowBlur = h * 0.08 * dpr;
    ctx.shadowOffsetX = h * 0.02 * dpr;
    ctx.shadowOffsetY = h * 0.05 * dpr;
    ctx.fill(pill);
    ctx.restore();
    ctx.save();
    ctx.translate(h * 0.04, h * 0.08);
    ctx.fillStyle = rgba(c.strandShadow);
    for (const shape of shapes) ctx.fill(shape.body);
    ctx.restore();

    // The strands' rim, under everything else: only its outer half shows.
    if (c.edge[3] > 0) {
      ctx.strokeStyle = rgba(c.edge);
      ctx.lineWidth = 1.6;
      ctx.lineJoin = 'round';
      for (const shape of shapes) ctx.stroke(shape.body);
    }

    // The body: dark, a little lighter at the top and the bottom.
    const body = ctx.createLinearGradient(0, y, 0, y + h);
    body.addColorStop(0, rgba(c.tarTop));
    body.addColorStop(0.32, rgba(c.tar));
    body.addColorStop(0.78, rgba(c.tar));
    body.addColorStop(1, rgba(c.tarBottom));
    ctx.fillStyle = body;
    ctx.fill(pill);

    ctx.save();
    ctx.clip(pill);
    const sx = this.sheen;
    const pull = clamp(Math.hypot(this.bx, this.by) / (h * 0.12), 0, 1);
    // A window's reflection at each end, and a long soft one under the top.
    this.glow(x + w * 0.085 + sx, y + h * 0.47, w * 0.055, h * 0.42, c.sheen, 1);
    this.glow(x + w * 0.082 + sx, y + h * 0.45, w * 0.018, h * 0.3, c.sheen, 0.7);
    this.glow(x + w * 0.935 + sx * 0.5, y + h * 0.5, w * 0.03, h * 0.3, c.sheen, 0.45);
    this.glow(
      x + w * 0.5 + sx * 1.5 + this.bx * 0.6,
      y + h * 0.12 + this.by * 0.3,
      w * 0.36,
      h * 0.08,
      c.sheen,
      0.45,
    );
    // A second, wider band of light along the top that slides with the surface and brightens a
    // little while it is pulled.
    this.glow(
      x + w * 0.42 + sx * 2 + this.bx * 1.2,
      y + h * 0.2 + this.by * 0.4,
      w * 0.3,
      h * 0.13,
      c.sheen,
      0.22 + pull * 0.18,
    );
    // The rims: a bright bevel along the bottom, a thin light along the top.
    const low = ctx.createLinearGradient(0, y, 0, y + h + this.by * 0.3);
    low.addColorStop(0, rgba(c.rim, 0));
    low.addColorStop(0.72, rgba(c.rim, 0));
    low.addColorStop(1, rgba(c.rim));
    ctx.strokeStyle = low;
    ctx.lineWidth = h * 0.09;
    ctx.stroke(pill);
    const high = ctx.createLinearGradient(0, y, 0, y + h);
    high.addColorStop(0, rgba(c.rim, 0.55));
    high.addColorStop(0.14, rgba(c.rim, 0));
    ctx.strokeStyle = high;
    ctx.lineWidth = h * 0.05;
    ctx.stroke(pill);
    ctx.restore();

    // At night, a hairline of light round the edge.
    if (c.edge[3] > 0) {
      ctx.strokeStyle = rgba(c.edge, 0.8);
      ctx.lineWidth = 1;
      ctx.stroke(pill);
    }

    // The strands over the edge, their roots covering it; a glint along the thick ones.
    ctx.fillStyle = rgba(c.tar);
    for (const shape of shapes) ctx.fill(shape.body);
    ctx.lineCap = 'round';
    ctx.lineWidth = 0.7;
    for (const shape of shapes) {
      if (!shape.shine) continue;
      ctx.strokeStyle = rgba(c.strandShine, shape.fade);
      ctx.stroke(shape.shine);
    }

    // The filaments between neighbours, sagging a little; they break when stretched.
    const limit = w * 0.16;
    ctx.strokeStyle = rgba(c.web);
    ctx.lineWidth = 0.6;
    ctx.beginPath();
    this.webs = this.webs.filter((web) => {
      if (web.a.kind !== 'held' || web.b.kind !== 'held') return false;
      const a = web.a.nodes[web.ia];
      const b = web.b.nodes[web.ib];
      if (!a || !b) return false;
      const d = Math.hypot(b.x - a.x, b.y - a.y);
      if (d > limit) return false;
      ctx.moveTo(a.x, a.y);
      ctx.quadraticCurveTo((a.x + b.x) / 2, (a.y + b.y) / 2 + d * 0.18, b.x, b.y);
      return true;
    });
    ctx.stroke();
  }

  /** A soft elliptic light. */
  private glow(cx: number, cy: number, rx: number, ry: number, color: Palette['tar'], k: number) {
    const { ctx } = this;
    ctx.save();
    ctx.translate(cx, cy);
    ctx.scale(1, ry / rx);
    const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
    g.addColorStop(0, rgba(color, k));
    g.addColorStop(0.45, rgba(color, k * 0.55));
    g.addColorStop(1, rgba(color, 0));
    ctx.fillStyle = g;
    ctx.fillRect(-rx, -rx, rx * 2, rx * 2);
    ctx.restore();
  }

  /** A strand's filled outline, and the glint along it. */
  private shape(strand: Strand) {
    const { w, h } = this.box;
    // A root starts a little inside the pill, so it covers the rim where it leaves the surface.
    const inset = strand.kind === 'tail' ? 0 : h * 0.05;
    const first = strand.nodes[0];
    const nodes: { x: number; y: number }[] = inset
      ? [{ x: first.x - strand.dx * inset, y: first.y - strand.dy * inset }, ...strand.nodes]
      : strand.nodes;
    const n = nodes.length;
    const along: number[] = [0];
    for (let i = 1; i < n; i += 1)
      along.push(
        along[i - 1] + Math.hypot(nodes[i].x - nodes[i - 1].x, nodes[i].y - nodes[i - 1].y),
      );
    const total = Math.max(1, along[n - 1] - inset);
    const thin = clamp(Math.sqrt((w * 0.3) / total), 0.45, 1.15);
    const fillet = h * 0.045;
    const left: number[] = [];
    const right: number[] = [];
    const shine = new Path2D();
    let shining = false;
    for (let i = 0; i < n; i += 1) {
      const a = nodes[Math.max(0, i - 1)];
      const b = nodes[Math.min(n - 1, i + 1)];
      let tx = b.x - a.x;
      let ty = b.y - a.y;
      const len = Math.hypot(tx, ty) || 1;
      tx /= len;
      ty /= len;
      const d = Math.max(0, along[i] - inset);
      const rest = Math.max(0, total - d);
      let width = strand.base * thin * (1 + 0.25 * Math.sin(d * 0.09 + strand.phase));
      if (strand.kind !== 'tail')
        width +=
          strand.base * (strand.kind === 'held' ? 5 : 2.4) * (1 / (1 + along[i] / fillet)) ** 2;
      if (strand.kind === 'held') width += strand.base * 0.7 * Math.exp(-rest / (h * 0.06));
      else width += strand.base * 0.8 * Math.exp(-rest / (h * 0.03));
      width *= strand.fade;
      const half = Math.max(0.25, width / 2);
      left.push(nodes[i].x - ty * half, nodes[i].y + tx * half);
      right.push(nodes[i].x + ty * half, nodes[i].y - tx * half);
      if (strand.base * thin > h * 0.024 && i > 1 && i < n - 1) {
        const gx = nodes[i].x - ty * half * 0.35;
        const gy = nodes[i].y + tx * half * 0.35;
        if (shining) shine.lineTo(gx, gy);
        else shine.moveTo(gx, gy);
        shining = true;
      }
    }
    const body = new Path2D();
    body.moveTo(left[0], left[1]);
    for (let i = 1; i < n - 1; i += 1) {
      body.quadraticCurveTo(
        left[i * 2],
        left[i * 2 + 1],
        (left[i * 2] + left[i * 2 + 2]) / 2,
        (left[i * 2 + 1] + left[i * 2 + 3]) / 2,
      );
    }
    body.lineTo(left[(n - 1) * 2], left[(n - 1) * 2 + 1]);
    // A round cap at the far end.
    const ex = nodes[n - 1].x;
    const ey = nodes[n - 1].y;
    const er = Math.hypot(left[(n - 1) * 2] - ex, left[(n - 1) * 2 + 1] - ey);
    body.arc(
      ex,
      ey,
      er,
      Math.atan2(left[(n - 1) * 2 + 1] - ey, left[(n - 1) * 2] - ex),
      Math.atan2(right[(n - 1) * 2 + 1] - ey, right[(n - 1) * 2] - ex),
      true,
    );
    for (let i = n - 2; i > 0; i -= 1) {
      body.quadraticCurveTo(
        right[i * 2],
        right[i * 2 + 1],
        (right[i * 2] + right[i * 2 - 2]) / 2,
        (right[i * 2 + 1] + right[i * 2 - 1]) / 2,
      );
    }
    body.lineTo(right[0], right[1]);
    body.closePath();
    return { body, shine: shining ? shine : null, fade: strand.fade };
  }
}
```

### tar-button/glove.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The cartoon glove: white with a black outline, open with the index finger up, or closed in a
 * fist that holds the tar. The same drawings make the stage's cursor (as SVG data URIs) and the
 * demo's pointer. Each is drawn back to front in a 48 × 48 box, every piece white over the ones
 * behind it, so the outlines that cross read as the creases between fingers.
 */

/** The cuff, the same for both hands. */
const CUFF =
  'M15 37.5h17.5a2.5 2.5 0 0 1 2.5 2.5v4a2.5 2.5 0 0 1-2.5 2.5H15a2.5 2.5 0 0 1-2.5-2.5v-4a2.5 2.5 0 0 1 2.5-2.5z';
const CUFF_FOLD = 'M14.6 41.6c6 1 12 1 18 0';

const OPEN = [
  CUFF,
  // The index finger, then the three curled ones, the back of the hand and the thumb.
  'M13.5 5.5a3.6 3.6 0 0 1 7.2 0V24h-7.2z',
  'M21 21a4 4 0 1 0 8 0a4 4 0 1 0-8 0z',
  'M26.8 22.4a3.8 3.8 0 1 0 7.6 0a3.8 3.8 0 1 0-7.6 0z',
  'M31.2 25.8a3.4 3.4 0 1 0 6.8 0a3.4 3.4 0 1 0-6.8 0z',
  'M12.6 22.5h14c6 0 10.4 3.4 10.4 9.2v.6c0 3.9-2.7 6.4-6.4 6.4H17.4c-3.4 0-5.6-2.5-5.6-5.6v-8.8c0-1 .3-1.8.8-1.8z',
  'M8.3 26.2c1.6-1.8 4.6-1.6 6.5.4l4.2 4.6c1.2 1.3 1 3.2-.4 4.2-1.3.9-3 .7-4-.5L8.4 29.6c-.9-1-.9-2.4-.1-3.4z',
];
const OPEN_LINES = [CUFF_FOLD, 'M22.5 29.5c2.4.6 5 .6 7.6-.2'];

const GRAB = [
  CUFF,
  // The palm, the four fingers curled over it, and the thumb across them.
  'M12 21c0-5 3-8 8-8h8c5.5 0 9 3.5 9 9v8.5c0 4.4-3 7.6-7.4 7.6H18.6c-4 0-6.6-2.8-6.6-6.6z',
  'M14.5 14.5a3 3 0 0 1 6 0v8.5a3 3 0 0 1-6 0z',
  'M20.5 13.6a3 3 0 0 1 6 0V23a3 3 0 0 1-6 0z',
  'M26.5 14a3 3 0 0 1 6 0v9a3 3 0 0 1-6 0z',
  'M32.5 16a2.8 2.8 0 0 1 5.6 0v7.4a2.8 2.8 0 0 1-5.6 0z',
  'M9.6 25.4c.6-2.2 3-3.2 5.2-2.4l9.4 3.2c1.8.6 2.6 2.4 2 4.1-.6 1.6-2.4 2.4-4.1 1.8l-9.6-3.1c-2.2-.7-3.4-2.6-2.9-3.6z',
];
const GRAB_LINES = [CUFF_FOLD];

/** Each hand's tilt, about the middle of its wrist. */
const OPEN_TILT = 'rotate(-14 24 30)';
const GRAB_TILT = 'rotate(-10 24 30)';

/**
 * The point each glove stands on, in its 48 × 48 box: the open hand's fingertip, the middle of
 * the fist. The demo's pointer draws the fist moved so the two fall on the same spot.
 */
export const OPEN_TIP = { x: 10.5, y: 4.5 };
export const GRAB_TIP = { x: 24, y: 24 };

type Hand = 'open' | 'grab';

const pieces = (hand: Hand) =>
  hand === 'open'
    ? { fills: OPEN, lines: OPEN_LINES, tilt: OPEN_TILT }
    : { fills: GRAB, lines: GRAB_LINES, tilt: GRAB_TILT };

/** The glove as SVG markup, for a cursor. */
function markup(hand: Hand, size: number) {
  const { fills, lines, tilt } = pieces(hand);
  const paths = [
    ...fills.map((d) => `<path d="${d}"/>`),
    ...lines.map((d) => `<path d="${d}" fill="none"/>`),
  ].join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 48 48" fill="#fff" stroke="#111" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"><g transform="${tilt}">${paths}</g></svg>`;
}

/** A CSS cursor of the glove, `size` px, standing on its tip; `fallback` where SVG cursors fail. */
export function gloveCursor(hand: Hand, size: number, fallback: string) {
  const tip = hand === 'open' ? OPEN_TIP : GRAB_TIP;
  const k = size / 48;
  const url = `data:image/svg+xml,${encodeURIComponent(markup(hand, size))}`;
  return `url("${url}") ${Math.round(tip.x * k)} ${Math.round(tip.y * k)}, ${fallback}`;
}

/** The glove as an SVG element; `shift` moves the drawing inside its box, in box units. */
export function Glove({
  hand,
  className,
  shift,
}: {
  hand: Hand;
  className?: string;
  shift?: { x: number; y: number };
}) {
  const { fills, lines, tilt } = pieces(hand);
  return (
    <svg
      className={className}
      viewBox="0 0 48 48"
      fill="var(--glove)"
      stroke="var(--glove-ink)"
      strokeWidth="2"
      strokeLinejoin="round"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <g transform={`${shift ? `translate(${shift.x} ${shift.y}) ` : ''}${tilt}`}>
        {fills.map((d) => (
          <path key={d} d={d} />
        ))}
        {lines.map((d) => (
          <path key={d} d={d} fill="none" />
        ))}
      </g>
    </svg>
  );
}
```

### tar-button/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/* The tar button's defaults. */

/** The button's words. */
export const LABEL = 'let’s collaborate';

/** How many strands a pull draws out of the tar. */
export const STRANDS = 9;

/**
 * How thick the tar is, from 0 to 1: thicker tar lags further behind the pointer, sags less and
 * stretches further before a strand snaps.
 */
export const VISCOSITY = 0.5;
```

### tar-button/demo-pointer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import type { CSSProperties, Ref } from 'react';
import { GRAB_TIP, Glove, OPEN_TIP } from './glove';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API. The tar follows the pointer
 * frame by frame, so `pointerAt` reads where the flight has it now. The pointer is the glove,
 * open or closed: two HTML boxes that swap by their visibility.
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`. */
  glide: (to: Point, ms: number) => Promise<void>;
  /** Shows the open glove, or the fist. */
  glyph: (name: 'open' | 'grab') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the open glove's fingertip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: OPEN_TIP.x / 48, y: OPEN_TIP.y / 48 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

const tipOf = (pointer: HTMLElement): Point => ({
  x: pointer.offsetWidth * TIP.x,
  y: pointer.offsetHeight * TIP.y,
});

/** Where the pointer's tip is now, in the stage's px, mid-flight or at rest. */
export function pointerAt(pointer: HTMLElement): Point {
  const tip = tipOf(pointer);
  const matrix = new DOMMatrixReadOnly(getComputedStyle(pointer).transform);
  return { x: matrix.e + tip.x, y: matrix.f + tip.y };
}

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const tip = tipOf(pointer);
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.92 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      { duration: ms, easing: 'cubic-bezier(0.45, 0, 0.3, 1)' },
    );
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'open';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The pointer's box: it moves, on a layer of its own. Its size comes from its class. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(3px 5px 3px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };

/** The fist, moved so its middle falls where the open hand's fingertip is. */
const GRAB_SHIFT = { x: OPEN_TIP.x - GRAB_TIP.x, y: OPEN_TIP.y - GRAB_TIP.y };

/**
 * The glove the demo moves around: open, or a fist while it holds the tar. It is decorative:
 * what it does shows on the button itself. The stylesheet shows one glyph at a time, from the
 * box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  openClass,
  grabClass,
  gloveClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  openClass?: string;
  grabClass?: string;
  gloveClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={openClass} style={GLYPH}>
        <Glove hand="open" className={gloveClass} />
      </span>
      <span className={grabClass} style={GLYPH}>
        <Glove hand="grab" className={gloveClass} shift={GRAB_SHIFT} />
      </span>
    </span>
  );
}
```

### tar-button/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```