New
Snap ToggleDraggable
A tab indicator that is fifteen masses on a spring chain, so it necks and catches up instead of easing. Drop-in for a real nav.
'use client';
import './liquid-nav.css';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import {
useCanvasScene,
useReducedMotion,
type SceneDrawContext,
type SceneSetupContext,
} from '@/hooks/use-canvas-scene';
/**
* A tab bar whose indicator is a drop of liquid rather than a rectangle on a
* transition.
*
* The pill is a chain of point masses strung along the bar with stiff springs and
* damping on the rate of stretch. Every mass is pulled toward its own place in the
* destination, but not equally: the pull ramps from weak at the trailing end to
* full at the leading one. That single asymmetry is the whole reason the middle
* arrives late, the shape leans into the travel, and it overshoots and settles
* rather than easing.
*
* The thinning is not a scale animation. Every element of the chain is treated as
* incompressible: stretched by λ along the bar it has to give up the same factor
* across it, so the local half-thickness goes as 1/λ. Pull the ends apart and the
* neck has nowhere to take the area from but its own width; let them come back
* together and it has to bulge.
*
* The tabs are real buttons in a real tablist, and their boxes are measured from
* the DOM rather than assumed, so the indicator lands on the actual text whatever
* the font does to it. The canvas sits behind them and takes no pointer events.
*/
/** Seconds per step. Links this stiff want a short one. */
const STEP = 1 / 480;
/** Masses in the chain. Enough that the neck is several nodes wide. */
const NODES = 15;
/** Link stiffness, per unit mass. */
const LINK_K = 9000;
/**
* Damping on the rate of stretch of a link, rather than on each node's velocity.
* This is what makes the chain a medium instead of fifteen separate springs: the
* pill can still translate freely, but the internal ringing that would otherwise
* wobble for seconds is gone inside a couple of tenths.
*/
const LINK_DAMP = 62;
/**
* How hard a node is pulled toward its own place in the destination. Every node is
* driven, not just the ends: a single driven end has to hand its whole force down
* the chain, and a spring drive proportional to a two-hundred-pixel error tears
* the first link open long before the last one has heard about it.
*/
const DRIVE_K = 340;
/**
* The drive is weakest at the trailing end and full strength at the leading one,
* and this is the weak end's share. It is the only asymmetry in the file, and it
* is what makes the pill lean: the front lets go first, the back is still being
* persuaded, and the difference between the two is carried as tension — which
* peaks in the middle, which is where the neck appears.
*/
const TAIL_WEIGHT = 0.3;
/** Viscous drag against the bar. The only thing that finally stops the pill. */
const DRAG = 16;
/**
* Bounds on λ before it sets the thickness. Measured, not guessed: a jump across
* two tabs takes the middle to λ ≈ 2.1 and the arrival swell to λ ≈ 0.66, so these
* clip the swell and leave the stretch alone. Uncapped in the other direction the
* neck closes to nothing and the pill separates — a real thing liquid does, and the
* wrong thing for a control that has to stay legible as one object.
*/
const MIN_STRETCH = 0.8;
const MAX_STRETCH = 2.4;
const TABS = [
{ label: 'Overview', body: 'Fifteen masses on springs. No keyframes and no easing curve anywhere in the file.' },
{ label: 'Physics', body: 'Damping on the stretch rate rather than on velocity, integrated at 480 Hz under the paint loop.' },
{ label: 'Install', body: 'One component and one hook. The indicator measures your buttons; it does not lay them out.' },
{ label: 'Changelog', body: 'Thickness now falls as 1/λ rather than 1/√λ — the area is conserved in the plane, not in a cylinder.' },
];
type TabBox = { left: number; top: number; width: number; height: number };
interface LiquidState {
readonly count: number;
/** Node positions along the bar, and their velocities. The whole simulation. */
readonly sx: Float64Array;
readonly vx: Float64Array;
readonly force: Float64Array;
/** Half-thickness per node, from the local stretch. */
readonly radius: Float64Array;
/** Live tab geometry, shared with the component and re-measured on resize. */
readonly boxes: { current: TabBox[] };
/** Half-thickness at rest and the bar's centre line, both taken from the DOM. */
base: number;
cy: number;
/** Rest spacing between nodes. Follows the aim tab's width, with a lag. */
rest: number;
carry: number;
clock: number;
/** Index of the tab the chain is being pulled toward. Set by the component. */
aim: number;
/**
* The aim the ramp was last built for, and which way it points. Latched on the
* change of aim rather than recomputed per step: deciding the direction from the
* live centre would flip the ramp the instant the pill crossed its target, which
* is a discontinuity in the middle of the travel and looks like a stumble.
*/
held: number;
lead: number;
/**
* Skip the solver and place the chain at rest on the aim. Set under
* `prefers-reduced-motion`, where there is no loop to integrate the travel and a
* pill that crept a twelfth of the way per repaint would be worse than none.
*/
snap: boolean;
}
/**
* The span the chain is heading for. The aim tab's box inset by the end radius,
* because the end nodes carry a disc that reaches `base` past them — inset by
* exactly that and the settled pill covers the tab instead of overhanging it.
*/
function span(state: LiquidState) {
const boxes = state.boxes.current;
const box = boxes[state.aim] ?? boxes[0];
if (!box) return null;
state.base = Math.max(6, box.height * 0.5);
state.cy = box.top + box.height * 0.5;
const inset = Math.min(state.base, box.width * 0.32);
return { left: box.left + inset, right: box.left + box.width - inset };
}
/** Place the chain at rest across the aim tab, evenly spaced and stationary. */
function settle(state: LiquidState) {
const aim = span(state);
const left = aim ? aim.left : 0;
const right = aim ? aim.right : state.base * 2;
state.rest = Math.max(0.5, (right - left) / (state.count - 1));
for (let i = 0; i < state.count; i++) {
state.sx[i] = left + state.rest * i;
state.vx[i] = 0;
}
// Already there, so there is no travel for the ramp to describe.
state.held = state.aim;
}
/**
* One step: link forces, the distributed drive, then a semi-implicit Euler update.
*/
function advance(state: LiquidState) {
const { count, sx, vx, force } = state;
const aim = span(state);
if (!aim) return;
/*
* Rest spacing follows the destination rather than snapping to it. Tabs are
* different widths, and jumping the rest length in one step would restate the
* stretch of every link at once — a flinch the springs then have to absorb.
*/
const spacing = (aim.right - aim.left) / (count - 1);
state.rest += (spacing - state.rest) * Math.min(1, STEP * 14);
// Which end leads, decided once per aim. See `held` on the state.
if (state.aim !== state.held) {
state.lead = (aim.left + aim.right) * 0.5 >= (sx[0] + sx[count - 1]) * 0.5 ? 1 : -1;
state.held = state.aim;
}
force.fill(0);
for (let i = 1; i < count; i++) {
const stretch = sx[i] - sx[i - 1] - state.rest;
const rate = vx[i] - vx[i - 1];
const pull = LINK_K * stretch + LINK_DAMP * rate;
force[i] -= pull;
force[i - 1] += pull;
}
/*
* Every node toward its own place in the destination, weighted along the chain.
* Tension is the running sum of the drive imbalance, so it vanishes at both free
* ends and peaks somewhere in the middle — which is the neck, arrived at rather
* than drawn. Driving only the leading end instead would put a force of
* DRIVE_K × 200px into one node, and the links can only answer that by opening
* some thirty pixels: the chain tears and its nodes cross over.
*/
for (let i = 0; i < count; i++) {
const along = i / (count - 1);
const weight = TAIL_WEIGHT + (1 - TAIL_WEIGHT) * (state.lead > 0 ? along : 1 - along);
force[i] += DRIVE_K * weight * (aim.left + spacing * i - sx[i]);
}
for (let i = 0; i < count; i++) {
vx[i] += (force[i] - DRAG * vx[i]) * STEP;
sx[i] += vx[i] * STEP;
}
}
/** Half-thickness per node from the local stretch, area preserved in the plane. */
function thicken(state: LiquidState) {
const { count, sx, radius, rest, base } = state;
for (let i = 0; i < count; i++) {
const before = i > 0 ? sx[i] - sx[i - 1] : sx[1] - sx[0];
const after = i < count - 1 ? sx[i + 1] - sx[i] : sx[count - 1] - sx[count - 2];
// Centred, so a node's thickness answers to the links on both sides of it
// rather than stepping between them.
const stretch = Math.min(MAX_STRETCH, Math.max(MIN_STRETCH, (before + after) * 0.5 / rest));
radius[i] = base / stretch;
}
}
function build(
{ height }: SceneSetupContext,
boxes: { current: TabBox[] },
aim: number,
): LiquidState {
const count = NODES;
const state: LiquidState = {
count,
sx: new Float64Array(count),
vx: new Float64Array(count),
force: new Float64Array(count),
radius: new Float64Array(count),
boxes,
// Fallbacks for the frame before the tabs have been measured. `span` replaces
// both the moment there is a real box to read.
base: Math.max(6, height * 0.32),
cy: height * 0.5,
rest: 1,
carry: 0,
clock: 0,
aim,
held: aim,
lead: 1,
snap: false,
};
// Start settled on the active tab. A pill that flies in from the origin on
// every resize is a component announcing its own implementation.
settle(state);
thicken(state);
return state;
}
function paint({ context, width, height, state }: SceneDrawContext<LiquidState>) {
const now = performance.now();
const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;
state.clock = now;
state.carry += elapsed;
let steps = 0;
// 480 Hz is eight sub-steps per frame at 60 fps, so the cap has to clear sixteen
// or a display running at 30 would silently integrate the pill at half speed.
while (state.carry >= STEP && steps < 16) {
advance(state);
state.carry -= STEP;
steps += 1;
}
if (state.carry > STEP * 16) state.carry = 0;
if (state.snap) settle(state);
thicken(state);
context.clearRect(0, 0, width, height);
if (!state.boxes.current.length) return;
const { count, sx, radius, cy } = state;
/*
* The pill is the union of the chain's discs: one path of fifteen circles and
* one fill. Overlaps in a single colour are invisible, so the union needs no
* isosurface pass, and with the nodes spaced well inside their own width the
* scallop left on the silhouette is a fraction of a pixel.
*/
const trace = (shrink: number) => {
context.beginPath();
for (let i = 0; i < count; i++) {
const r = Math.max(0.5, radius[i] - shrink);
// Enter each circle at its own start angle, or `arc` draws a chord in from
// wherever the last one finished and the union fills solid.
context.moveTo(sx[i] + r, cy);
context.arc(sx[i], cy, r, 0, Math.PI * 2);
}
};
// The rim, with its bloom, then the body laid back over it. Two fills of the
// same union a pixel and a half apart is the entire lit edge.
context.shadowColor = 'rgba(126,186,255,0.42)';
context.shadowBlur = 22;
trace(0);
context.fillStyle = 'rgba(152,205,255,0.7)';
context.fill();
context.shadowBlur = 0;
trace(1.5);
const body = context.createLinearGradient(0, cy - state.base, 0, cy + state.base);
body.addColorStop(0, 'rgba(33,56,92,0.96)');
body.addColorStop(1, 'rgba(13,22,40,0.96)');
context.fillStyle = body;
context.fill();
}
/** `compact` is the 298x240 catalogue card: the same bar and the same solver, handed
* the whole box, with the panel copy dropped. Presentation only — see `liquid-nav.css`. */
export type LiquidNavProps = { compact?: boolean };
export function LiquidNav({ compact = false }: LiquidNavProps) {
const [active, setActive] = useState(1);
/** The tab being pointed at, so the pill can lean at it before the click. */
const [hover, setHover] = useState(-1);
const list = useRef<HTMLDivElement | null>(null);
const boxes = useRef<TabBox[]>([]);
const reduced = useReducedMotion();
const { stageRef, canvasRef, requestRender } = useCanvasScene<LiquidState>({
setup: (scene) => build(scene, boxes, active),
draw: (scene) => {
scene.state.aim = hover >= 0 ? hover : active;
scene.state.snap = reduced;
paint(scene);
},
});
// Selection has to repaint on its own account: with the loop stopped under
// reduced motion nothing else would, and the indicator would stay behind.
useEffect(() => {
requestRender();
}, [active, hover, requestRender]);
/*
* The indicator is told where the tabs are; it does not decide. Measuring the
* real boxes is what lets this sit under any label set, font and padding — and
* it is why the observer watches the buttons and not just the bar, since a font
* swap resizes them without moving the bar at all.
*/
const measure = useCallback(() => {
const node = list.current;
if (!node) return;
const frame = node.getBoundingClientRect();
boxes.current = Array.from(node.children, (child) => {
const box = child.getBoundingClientRect();
return {
left: box.left - frame.left,
top: box.top - frame.top,
width: box.width,
height: box.height,
};
});
requestRender();
}, [requestRender]);
useEffect(() => {
const node = list.current;
if (!node) return;
measure();
const sizes = new ResizeObserver(measure);
sizes.observe(node);
for (const child of Array.from(node.children)) sizes.observe(child);
return () => sizes.disconnect();
}, [measure]);
// A tablist takes one tab stop and the arrows move within it, which is the part
// of a tab control that hand-rolled ones usually skip.
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const move = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (!move) return;
event.preventDefault();
const next = (active + move + TABS.length) % TABS.length;
setActive(next);
(list.current?.children[next] as HTMLElement | undefined)?.focus();
};
return (
<div className="liquid-nav-stage" data-compact={compact ? 'true' : undefined}>
<div className="liquid-nav-bar">
<div ref={stageRef} className="liquid-nav-field" aria-hidden="true">
<canvas ref={canvasRef} />
</div>
<div
ref={list}
className="liquid-nav-tabs"
role="tablist"
aria-label="Sections"
onKeyDown={onKeyDown}
onPointerLeave={() => setHover(-1)}
>
{/* The roving tab stop, except in a card: the frame there is aria-hidden,
and a focusable node inside one is a trap with no name. The buttons stay
clickable in both — only the tab order changes. */}
{TABS.map((tab, index) => (
<button
key={tab.label}
type="button"
role="tab"
id={`liquid-nav-tab-${index}`}
className="liquid-nav-tab"
aria-selected={index === active}
aria-controls="liquid-nav-panel"
tabIndex={compact ? -1 : index === active ? 0 : -1}
onClick={() => setActive(index)}
onPointerEnter={() => setHover(index)}
onFocus={() => setHover(index)}
onBlur={() => setHover(-1)}
>
{tab.label}
</button>
))}
</div>
</div>
<p
className="liquid-nav-panel"
id="liquid-nav-panel"
role="tabpanel"
aria-labelledby={`liquid-nav-tab-${active}`}
>
{TABS[active].body}
</p>
<p className="liquid-nav-hint">Hover to lean, click to travel</p>
</div>
);
}
export default LiquidNav;.liquid-nav-stage {
position: relative;
display: grid;
place-content: center;
gap: 1.75rem;
width: 100%;
min-height: 260px;
padding: 2.5rem 1.5rem;
overflow: hidden;
border-radius: 0.75rem;
background: radial-gradient(120% 110% at 50% 0%, #101828 0%, #080c14 58%, #05070c 100%);
color: #eaf3ff;
}
.liquid-nav-bar {
position: relative;
justify-self: center;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
}
/* The indicator, behind the labels and deaf to the pointer. Every interaction
here belongs to a real button; the canvas only ever draws. */
.liquid-nav-field {
position: absolute;
inset: 0;
pointer-events: none;
}
.liquid-nav-field canvas {
display: block;
width: 100%;
height: 100%;
}
/* Shares its origin with the field: the bar carries no padding of its own, so an
absolute `inset: 0` and this row start at the same pixel and the boxes measured
from here are already canvas coordinates. */
.liquid-nav-tabs {
position: relative;
display: flex;
gap: 0.25rem;
padding: 0.5rem;
}
.liquid-nav-tab {
appearance: none;
margin: 0;
padding: 0.5rem 1.125rem;
border: 0;
border-radius: 999px;
background: none;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.005em;
color: rgba(234, 243, 255, 0.52);
cursor: pointer;
transition: color 200ms ease;
}
.liquid-nav-tab:hover {
color: rgba(234, 243, 255, 0.82);
}
.liquid-nav-tab[aria-selected='true'] {
color: #f2f8ff;
}
.liquid-nav-tab:focus-visible {
outline: 2px solid rgba(152, 205, 255, 0.7);
outline-offset: 2px;
}
.liquid-nav-panel {
margin: 0;
justify-self: center;
max-width: 34rem;
font-size: 0.875rem;
line-height: 1.6;
text-align: center;
text-wrap: pretty;
color: rgba(234, 243, 255, 0.58);
}
.liquid-nav-hint {
position: absolute;
right: 0.875rem;
bottom: 0.75rem;
margin: 0;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(234, 243, 255, 0.28);
pointer-events: none;
}
/*
* With the loop stopped the chain never integrates, so the pill stays where
* `setup` placed it — on the active tab, at rest, correct. Selecting a tab still
* works and still moves the indicator, because a resize or a `requestRender` is
* enough to repaint one settled frame; what is gone is the travel between them,
* which is exactly what was asked for.
*/
@media (prefers-reduced-motion: reduce) {
.liquid-nav-tab {
transition: none;
}
}
/*
* The card variant: the 298x240 catalogue frame, at that real size and never scaled.
* The bar was a content-width pill floating in the middle of a 260px stage, which in a
* card is a great deal of gradient around a small control. Here it spans the frame and
* the four tabs divide it evenly, so the pill is 61px long, 36px thick and travels 195px
* end to end: the stretch and the neck are the subject rather than a detail.
*/
.liquid-nav-stage[data-compact='true'] {
min-height: 0;
height: 100%;
/* The card frame rounds and clips already. */
border-radius: 0;
/*
* One deterministic column rather than a track sized to the labels, and a bottom
* padding that reserves the strip the hint sits in, so the bar reads as centred in
* the space above it.
*/
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding: 0.75rem 0.75rem 2.125rem;
}
/*
* No `touch-action` line here, and none needed: the canvas layer is `pointer-events:
* none` and the only pointer targets are four buttons, so this stage never becomes a
* full-bleed drag surface that claims the vertical gesture. A touch on the card scrolls
* the page as it should, and declaring `pan-y` would only cost the card its pinch-zoom.
*/
/* Fills the column, so the travel is the card's width instead of the labels'. */
.liquid-nav-stage[data-compact='true'] .liquid-nav-bar {
justify-self: stretch;
}
/*
* The deeper inset is the bar's own chrome, not the tabs': it thickens the capsule
* without touching a tab box — and the pill is drawn from the tab boxes — while buying
* the pill's end caps a couple of pixels of clearance inside the bar's 999px ends.
*/
.liquid-nav-stage[data-compact='true'] .liquid-nav-tabs {
padding: 0.75rem 0.5rem;
}
/*
* Equal shares of the bar, and the size is set by the longest label rather than by taste.
* Four `flex: 1 1 0` tabs split the 274px bar into 61px each; "Changelog" sets 61px of its
* own at 12px, so at the 6px inline padding it has at full size it needed 73px and the bar's
* `overflow: hidden` ate the descender. 11px brings the word to 56px and 2px of padding
* leaves 3px of slack — measured after the webfont lands, not before, which is what made the
* first pass at this look like it fitted.
*
* `line-height: 1` makes the tab 35px tall. The number that matters is not 35 but that half
* of it stays under the 0.32 × width cap `span` insets the pill by — 17.5 against 19.5 — so
* the settled pill still covers its own tab to the pixel and never reaches under a
* neighbour's label. Nothing here is a `vw`: the card is 298px wide and the viewport is not.
*/
.liquid-nav-stage[data-compact='true'] .liquid-nav-tab {
flex: 1 1 0;
min-width: 0;
padding: 0.75rem 0.0625rem;
font-size: 0.6875rem;
line-height: 1;
text-align: center;
white-space: nowrap;
}
/* Four lines of prose about the solver, in a box this size, is the card title's job. */
.liquid-nav-stage[data-compact='true'] .liquid-nav-panel {
display: none;
}
/*
* The one line of text kept: this pill is still until it is hovered or clicked, so the
* instruction is the part worth the room. A strip along the bottom edge, clear of the bar
* by the stage padding above, at the fixed 0.6875rem it was already set in. The
* `pointer-events: none` on the rule above is left alone, so the strip never swallows a
* press meant for the bar; the tabs go on taking their own clicks.
*/
.liquid-nav-stage[data-compact='true'] .liquid-nav-hint {
inset: auto 0 0 0;
padding: 0 0.75rem 0.6875rem;
text-align: center;
color: rgba(234, 243, 255, 0.4);
}"use client"
import { useCallback, useEffect, useRef, useState } from "react"
/**
* The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing
* store, a rebuild on resize, a loop that stops when the stage scrolls out of
* view, pointer tracking with per-frame deltas, and teardown.
*
* A scene supplies two functions. `setup` builds whatever mutable state the
* animation owns and is re-run whenever the stage changes size, so the state can
* be sized to the stage without ever being resized in place. `draw` paints one
* frame from that state — it is called with the transform already scaled to
* device pixels, so every coordinate in it is a CSS pixel.
*/
export type ScenePointer = {
x: number
y: number
/** Position at the previous painted frame, so `x - lastX` is a frame delta. */
lastX: number
lastY: number
down: boolean
inside: boolean
}
export type SceneSetupContext = {
context: CanvasRenderingContext2D
width: number
height: number
dpr: number
}
export type SceneDrawContext<State> = SceneSetupContext & {
state: State
pointer: ScenePointer
/** Painted frames since the last rebuild. Useful for every-Nth-frame work. */
frame: number
}
export type CanvasSceneOptions<State> = {
setup: (context: SceneSetupContext) => State
draw: (context: SceneDrawContext<State>) => void
}
export type CanvasScene = {
/** The sizing element. Owns the pointer listeners and is what is observed. */
stageRef: (node: HTMLDivElement | null) => void
canvasRef: (node: HTMLCanvasElement | null) => void
/** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */
requestRender: () => void
}
/** Live `prefers-reduced-motion`. False during SSR and the first paint. */
export function useReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
setReduced(query.matches)
const onChange = () => setReduced(query.matches)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
}, [])
return reduced
}
export function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {
const reduced = useReducedMotion()
/*
* `draw` is usually an inline closure, so it is a new function on every
* render. Reading it through a ref keeps the loop from being torn down and
* the scene from being rebuilt each time the component re-renders.
*/
const optionsRef = useRef(options)
optionsRef.current = options
const stage = useRef<HTMLDivElement | null>(null)
const canvas = useRef<HTMLCanvasElement | null>(null)
/*
* Plain ref assignment, with no state behind it. React attaches refs during
* the commit phase, before passive effects run, so the effect below already
* sees both nodes on the first mount — which is why these used to bump a
* `mounted` counter for nothing: the two `setMounted` calls batched into one
* re-render, the counter went 0 → 2, and the effect's dependency on it tore
* the live scene down and rebuilt it. Every scene was constructed, measured
* and warmed twice on every mount, four times under StrictMode in dev.
*
* The requirement this trades for that: a consumer must render the stage and
* the canvas unconditionally, in the same commit as the component itself. All
* thirteen do. Gating the canvas behind a flag would leave the effect bailing
* on the null guard with nothing to re-run it.
*/
const stageRef = useCallback((node: HTMLDivElement | null) => {
stage.current = node
}, [])
const canvasRef = useCallback((node: HTMLCanvasElement | null) => {
canvas.current = node
}, [])
/** Set once the scene is live, so `requestRender` before that is a no-op. */
const render = useRef<(() => void) | null>(null)
const requestRender = useCallback(() => render.current?.(), [])
useEffect(() => {
const stageNode = stage.current
const canvasNode = canvas.current
if (!stageNode || !canvasNode) return
const context = canvasNode.getContext("2d")
if (!context) return
const pointer: ScenePointer = {
x: 0,
y: 0,
lastX: 0,
lastY: 0,
down: false,
inside: false,
}
let state: State | null = null
let width = 0
let height = 0
let dpr = 1
let frame = 0
let loop = 0
let pending = 0
let visible = true
/** Rebuild the backing store and the scene state for the current size. */
const measure = () => {
// `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is
// post-transform, so a scene sitting inside a scaled ancestor measured its
// own frame at the scaled size, sized the backing store to that, and then
// had CSS scale the result a second time — the scene ran at a fraction of
// the box it was drawn into. The catalogue's scaled-poster branch is the
// one place that happens, and it is reachable again the moment an
// animation is registered without a card composition. These two properties
// are the untransformed layout box; both are integers, which is what the
// rounding below already reduced the rect to.
const nextWidth = Math.max(1, stageNode.offsetWidth)
const nextHeight = Math.max(1, stageNode.offsetHeight)
const nextDpr = Math.min(2, window.devicePixelRatio || 1)
if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return
width = nextWidth
height = nextHeight
dpr = nextDpr
canvasNode.width = Math.round(width * dpr)
canvasNode.height = Math.round(height * dpr)
canvasNode.style.width = `${width}px`
canvasNode.style.height = `${height}px`
frame = 0
state = optionsRef.current.setup({ context, width, height, dpr })
}
const paint = () => {
if (!state) return
// Re-applied every frame: a scene is free to install its own transform
// for a cell or a sprite, and most do.
context.setTransform(dpr, 0, 0, dpr, 0, 0)
optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })
pointer.lastX = pointer.x
pointer.lastY = pointer.y
frame += 1
}
/** One frame on the next tick, coalescing however many were asked for. */
const paintOnce = () => {
if (pending) return
pending = requestAnimationFrame(() => {
pending = 0
measure()
paint()
})
}
render.current = paintOnce
const tick = () => {
loop = requestAnimationFrame(tick)
if (visible) paint()
}
const start = () => {
if (loop || reduced) return
loop = requestAnimationFrame(tick)
}
const stop = () => {
if (!loop) return
cancelAnimationFrame(loop)
loop = 0
}
const at = (event: PointerEvent) => {
const rect = stageNode.getBoundingClientRect()
// The rect is the right thing to subtract here — `clientX` is viewport
// space and so is the rect — but the difference comes back in *rendered*
// pixels, and a scene reads `pointer` in the scene pixels `measure()` set
// up from the untransformed box. Under a CSS scale those two disagree, so
// divide the transform back out. `rect.width / offsetWidth` is the scale
// actually in force, whatever produced it, and it is exactly 1 when there
// is none.
const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1
pointer.x = (event.clientX - rect.left) / (scale || 1)
pointer.y = (event.clientY - rect.top) / (scale || 1)
// A frozen loop still owes the user feedback for a drag.
if (reduced) paintOnce()
}
const onEnter = (event: PointerEvent) => {
pointer.inside = true
at(event)
pointer.lastX = pointer.x
pointer.lastY = pointer.y
}
const onMove = (event: PointerEvent) => {
pointer.inside = true
at(event)
}
const onDown = (event: PointerEvent) => {
pointer.down = true
at(event)
// Capture keeps a drag alive past the edge of the stage, which is where
// a hard throw naturally ends up.
stageNode.setPointerCapture(event.pointerId)
}
const onUp = (event: PointerEvent) => {
pointer.down = false
at(event)
if (stageNode.hasPointerCapture(event.pointerId)) {
stageNode.releasePointerCapture(event.pointerId)
}
}
const onLeave = () => {
pointer.inside = false
pointer.down = false
if (reduced) paintOnce()
}
stageNode.addEventListener("pointerenter", onEnter)
stageNode.addEventListener("pointermove", onMove)
stageNode.addEventListener("pointerdown", onDown)
stageNode.addEventListener("pointerup", onUp)
stageNode.addEventListener("pointercancel", onUp)
stageNode.addEventListener("pointerleave", onLeave)
const resizes = new ResizeObserver(() => paintOnce())
resizes.observe(stageNode)
/*
* An animation nobody can see is heat. The observer both pauses the loop
* and, on the way back in, repaints immediately rather than waiting a frame.
*/
const views = new IntersectionObserver(
(entries) => {
visible = entries.some((entry) => entry.isIntersecting)
if (visible) {
start()
paintOnce()
} else {
stop()
}
},
{ rootMargin: "120px" },
)
views.observe(stageNode)
measure()
paint()
start()
return () => {
render.current = null
stop()
if (pending) cancelAnimationFrame(pending)
resizes.disconnect()
views.disconnect()
stageNode.removeEventListener("pointerenter", onEnter)
stageNode.removeEventListener("pointermove", onMove)
stageNode.removeEventListener("pointerdown", onDown)
stageNode.removeEventListener("pointerup", onUp)
stageNode.removeEventListener("pointercancel", onUp)
stageNode.removeEventListener("pointerleave", onLeave)
}
}, [reduced])
return { stageRef, canvasRef, requestRender }
}