# Liquid goo button: hand-off pack

A hero call-to-action that behaves like thick, glossy liquid. When you move near the edge it bulges toward the pointer. If you grab the edge and pull, viscous strands stretch out and get thinner the further they go. Let go and they snap back while the pill settles with an elastic bounce. The label sits on a separate rigid layer, so it never distorts.

> Credit: the idea comes from a goo-button demo @exteedesign posted on X
> (https://x.com/exteedesign/status/2113276327415758886). This is an independent implementation. No code or assets were copied.

Status: hand-off files only. **Nothing has been deployed.**

## Files

| File | What it is |
|---|---|
| `goo-button.html` | Self-contained demo with no dependencies. Open it directly. The preset switcher and the hand-drawn cursor toggle are in the bottom bar. URL params: `?preset=mokcove\|slate`, `?renderer=svg`, `?cursor=1`. |
| `goo-engine.js` | The engine. A framework-free ES module: `createGoo(host, opts)`, `DEFAULTS`, `PRESETS`, `GOO_CSS`. Its tunables live at the top of the file. |
| `goo-engine.d.ts` | Types for the engine. |
| `react/GooButton.tsx` | React wrapper (React 18+). `react/demo-entry.tsx` is the small demo used for testing. |
| `page.template.html` + `build.py` | Demo page source. `python3 build.py` inlines `goo-engine.js` into `goo-button.html`. Re-run it after editing either file. |
| `check/` | Playwright screenshots, `contact-sheet.png`, `pull.mp4`, `shoot.py` (screenshots, checks, video) and `contact.py` (contact sheet). |

## Usage

Plain HTML:
```html
<span class="goo" id="cta-goo"><button type="button" aria-label="Book a free site check">Book a free site check</button></span>
<script type="module">
  import { createGoo, GOO_CSS } from './goo-engine.js';
  document.head.append(Object.assign(document.createElement('style'), { textContent: GOO_CSS }));
  const goo = createGoo(document.getElementById('cta-goo'), { preset: 'slate' });
</script>
```

React:
```tsx
<GooButton preset="slate" onClick={openBooking}>Book a free site check</GooButton>
<GooButton options={{ color: '#0b0b0b', viscosity: 0.7, strands: 4 }}>…</GooButton>
```
The wrapper creates the engine once on mount and destroys it on unmount, and it is safe under StrictMode (tested). Changing `preset` or `options` calls `goo.set()` without remounting.

**Host requirement:** the canvas is bigger than the pill by `maxStretch + 40` px on every side, so strands have room to travel. Give the section that contains it `overflow-x: clip` (or `hidden`) so a strand near the viewport edge can't create sideways scroll. The demo page does this.

## How it works

- **Shape (WebGL2 fragment shader).** The whole thing is one 2D signed-distance field:
  - a rounded-rect SDF for the pill
  - smooth-unioned (polynomial `smin`) with tapered capsules for each strand segment, plus round blobs for the tip, the contact bulge and the hover bulge.
  - Up to 48 segments are passed in as uniforms.
  - Edges get a 1-device-pixel anti-aliasing ramp on the distance.
- **Gloss that bends.** Normals come from the SDF gradient, using central differences. A circular bevel profile then tilts them, so each edge behaves like a rounded tube. Lighting is:
  - a tight white specular (Blinn, pow 38)
  - a broad sheen
  - a fresnel rim
  - a faint bounce light on the lower lip.

  Because the normals follow the field, the top highlight bends around bulges and runs along the strands.
- **Shadow.** The same field, offset 9 px down and blurred with a smoothstep. It is drawn into the same premultiplied canvas.
- **Label.** The text belongs to the real `<button>`, which sits above the canvas. It never deforms.
- **Physics (JS).** Runs in fixed 240 Hz substeps inside `requestAnimationFrame`, so it behaves the same at 60 or 120 Hz.
  - *Rubber band.* Pull distance is capped smoothly: `maxStretch·(1−e^(−d/maxStretch))`.
  - *Strands.* Each one is a chain of `nodes` points. Every point has a Hooke spring to the straight line from the edge anchor to the tip, plus neighbour coupling and damping. The tip trails the pointer through its own spring, and `viscosity` controls the lag. Strands are thick at both ends and thinner in the middle, and they get thinner the further you pull.
  - *Body.* The pill has an underdamped spring (`bounceStiffness`, `bounceDamping`). It leans toward the pull. On release it gets a velocity kick, which is why it overshoots and wobbles back. That is the elastic bounce.
  - *Snap-back.* When you let go, strands retract along straight lines and fade out within about 80 ms, so they don't tangle.
- **Fallback.** If WebGL2 is unavailable, or the context is lost, the engine switches to an SVG goo filter (`feGaussianBlur` + `feColorMatrix` alpha threshold) over plain shapes, with a static gradient highlight strip. Pulling still works but the lighting is flatter. Force it with `renderer: 'svg'`.

## Tunables (top of `goo-engine.js`)

| Option | Default | Notes |
|---|---|---|
| `color` / `label` / `ring` | `#0b0b0b` / `#fff` / `#0b0b0b` | Pill, label and focus-ring colours |
| `width` / `height` / `radius` | 300 / 90 / 45 | CSS px |
| `viscosity` | 0.55 | 0 = snappy, 1 = honey |
| `maxStretch` | 150 | Furthest the goo travels from the edge, in px |
| `strands` / `nodes` | 3 / 8 | 1–5 strands, 3–12 nodes each |
| `stiffness` / `damping` | 260 / 16 | Strand springs |
| `bounceStiffness` / `bounceDamping` | 230 / 7 | Release bounce (lower damping = wobblier) |
| `gloss` | 1.0 | Specular strength, 0–1.5 |
| `shadow` | 0.2 | Shadow alpha |
| `nearRadius` | 70 | Hover-bulge range (mouse only) |
| `clickAfterPull` / `pullClickPx` | false / 28 | A drag more than 28 px past the edge is treated as play, not a click |
| `customCursor` | false | Hand-drawn cursor, off by default |
| `dprCap` | 2 | Device-pixel-ratio cap |
| `renderer` | `'auto'` | `'auto'`, `'webgl2'` or `'svg'` |

**Presets**
- `default`: black glossy pill on `#F5F5F5`.
- `mokcove` (dusk lilac): the reference hue `#b9a6d8` only gives the cream label (`#FFF7E8`) **2.07:1** contrast, so the preset deepens the pill to `#9682BF` (**3.16:1**, which passes WCAG for large bold text). Focus ring is `#5B4A86`. Use the paler hue only if you also darken the label.
- `slate` (JKCA): `#1c2430` pill, warm cream label `#F3E9D7` (12.98:1).

## Accessibility

- It's a real `<button>`: focusable, Enter and Space work natively, and it has an `aria-label`. The visible focus ring is a 3 px outline, offset 5 px, in the preset's ring colour. The canvas is `aria-hidden` and `pointer-events: none`.
- Keyboard presses get the same small press-scale spring as a tap. Keyboard clicks are never swallowed.
- Under `prefers-reduced-motion: reduce` there are no strands, bulges or bounce. Pressing gives a simple 0.97 scale. This is tested.
- Copy is calm, with one accent (the pill) and no prices. Pair it with a plain-text alternative route, such as a link in the footer.

## Touch (iPhone)

- `touch-action: none` is set **only on the button**. Dragging a finger on it pulls the goo, and the page scrolls normally everywhere else. Pointer capture keeps the drag alive when the finger leaves the button.
- A tap clicks straight away with no delay. Only a real pull, more than 28 px past the edge, cancels the click, so a playful tug doesn't open the booking form.
- The hover bulge needs `(hover: hover)` and a mouse, so emulated mouse events after a tap don't leave a bulge behind.
- The pill is 300 px wide. On viewports under about 340 px, set `width` smaller, and consider `maxStretch: 110` so strands stay on screen.

## Performance

- **Sleeps when settled.** No rAF runs while idle. Mouse moves only wake the loop near the edge (`nearRadius + 20`).
- **Pauses offscreen.** An `IntersectionObserver` stops the loop when the button scrolls out of view.
- **DPR cap 2.** The canvas is about (300+380)×(90+380) CSS px and is rendered at no more than 2× density.
- **Per-pixel cost.** About 5 SDF evaluations (for the normal and the shadow) across up to 48 segments. That is fine for one button on modern phones, but it's why this belongs on **one hero CTA per page, not every button**.
- **Context loss.** Falls back to SVG.

## Where it fits

Use it for the primary hero CTA, for example "Book a free site check" on a landing page. Ordinary buttons, forms and navigation should use standard, calm styles. The goo is a single moment of delight; repeating it everywhere makes it noise.

## Testing (`check/`)

`python3 check/shoot.py`, then `python3 check/contact.py`. This needs Playwright for Python with Chromium and WebKit.

- Desktop: Chromium at 1440×900. iPhone: WebKit with the iPhone 13 profile (390×844, touch). The iPhone pull uses synthetic touch PointerEvents.
- Screenshots are taken on a virtual rAF clock, advanced one exact 1/60 s frame at a time, so animation frames are deterministic even though headless software GL is slow. `pull.mp4` is rendered frame by frame on the same clock: 3.6 s at 30 fps.
- Results checked:
  - WebGL2 in both browsers
  - a pull doesn't click; a tap clicks once
  - Enter and Space click
  - focus ring present
  - canvas `aria-hidden`
  - `touch-action` is `none` on the button and `auto` on the page
  - no horizontal overflow; page scrolls
  - reduced motion: no strands, press class applied
  - SVG fallback renders
  - no console errors
- React wrapper: type-checked with `tsc --strict`, bundled with esbuild, and run in Chromium and WebKit under StrictMode. One canvas, pull/tap/click behaviour as above, and live preset swap. See `check/react-*.png`.
