New
Snap ToggleDraggable
A stepped slider with a real detent instead of a rounding function. The notches cost something to climb out of, so a slow drag settles into the nearest one and a flick clears three — out of one equation rather than a velocity threshold.
'use client';
import './detent-slider.css';
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import {
useCanvasScene,
useReducedMotion,
type SceneDrawContext,
type SceneSetupContext,
} from '@/hooks/use-canvas-scene';
/**
* A stepped slider whose steps are a mechanism rather than a rounding.
*
* The value is carried by a spring-preloaded ball riding a scalloped rail. The rail
* is y(x) = −A·cos(2πx/p). The ball is pressed into it by a preload N. The only
* force that moves the carriage along the track is the component of that load along
* whichever piece of surface the ball happens to be sitting on, which is the slope
* of the profile there — so the entire mechanism is two lines:
*
* s(x) = dy/dx = A·(2π/p)·sin(2πx/p)
* m·ẍ = F_hand − N·s(x)/√(1 + s²) − c·ẋ
*
* integrated at a fixed 1/240 s.
*
* This is not a `snapPoints` array with an ease onto the nearest entry, and it is not
* a spring pulled toward Math.round(value). Both of those are strongest at the
* boundary between two steps. This one is zero there — sin(π) = 0 — so the crest of a
* scallop is an equilibrium, an unstable one, and crossing it is an over-centre event
* rather than a threshold being passed. Three things follow that no array of stops
* can be made to do:
*
* · Let go just short of a crest and the rail pulls the handle back down into the
* notch it came from, ringing at that notch's own frequency, (2π/p)·√(N·A/m).
* A rounding has already committed to the far step by then.
* · A flick spends ½mv² on crests. Each one costs about 2·N·A — exactly, it is the
* tangential force integrated over half a pitch, a shade under 2NA because of the
* √(1 + s²) — and the drag takes its share too. So how many steps a flick crosses
* is a continuous function of how hard it was flicked, and the same flick always
* crosses the same number of them.
* · Hold the handle on a crest and it balances there, between two values, with the
* readout still on the one it came from because nothing has committed. Let go and
* it falls — slowly, then all at once, because a departure from an unstable
* equilibrium grows as e^(t/τ) with τ fixed and the head start not.
*
* The handle is a real role="slider", and the arrow keys hand the ball an impulse
* instead of assigning a value, so the keyboard crosses the same crest the pointer
* does and can be watched doing it.
*/
/** Seconds per solver step. The preload is stiff; at 1/120 a crossing loses a pixel. */
const STEP = 1 / 240;
/** Steps per frame, capped. Twelve is 50ms, the same clamp `elapsed` gets, so a slow
* frame is integrated in full rather than quietly losing time. */
const MAX_STEPS = 12;
/** Notches on the rail. Seven leaves room for a flick to cross three and still stop. */
const NOTCHES = 7;
/** The value at each notch. Consecutive, so aria-valuemin/max describe the range. */
const VALUES = [2, 3, 4, 5, 6, 7, 8];
/** The notch it starts in: mid-rail, so both directions are one flick away. */
const START = 3;
/** Scallop amplitude as a fraction of the pitch, so the steepest slope is 2π·0.1 =
* 0.63 on any card width and the floor-to-crest rise is a fifth of a pitch. */
const SCALLOP = 0.1;
/** Preload on the ball, px/s² at unit mass. Sets the toll per crest (≈2NA) and the notch
* frequency with it: (2π/p)·√(N·A/m) is 21.5 rad/s, 3.4 Hz, on the 308px rail this card
* gives it — about as slow as a notch can ring before it reads as a bounce. */
const PRELOAD = 6000;
/** The ball's mass. One, so a force is an acceleration and ½mv² is ½v². */
const MASS = 1;
/** Viscous drag. ζ ≈ 0.23 at the notch frequency: heavier and a flick's energy goes
* into drag instead of into crests, lighter and the ball rattles for two seconds. */
const DRAG = 10;
/** Hand spring, px/s² per px of lead. Soft enough that a crest visibly holds the
* handle three pixels behind a steady drag, stiff enough to beat the crest's own
* negative stiffness (N·A·(2π/p)² ≈ 460) so the handle can be balanced up there. */
const HAND = 1000;
/** Damping on the hand's speed relative to the ball's rather than on the ball's own,
* so the grip settles a grab without bleeding a fast drag — a flick still lets go at
* the speed the pointer had. */
const GRIP = 40;
/** How near the handle in x a press has to land to take hold of it, px. */
const REACH = 30;
/** Arrow-key impulse, as a multiple of √(2·barrier/m) — the speed that just clears one
* crest with no drag at all. 1.9 clears exactly one at every track length this card
* can have; 1.6 fails to cross on a wide one, 2.4 crosses two on a narrow one. */
const KICK = 1.9;
/** Home and End, as a multiple of the same escape speed. What bounds this is not the
* crests — six of them take under 5% of the energy — it is the drag: a shove of v₀ glides
* v₀/c before it dies, and 6·1.9·√(2B)/DRAG is 400px on the 308px rail this card can have
* at its widest. The end stop is inelastic, so the surplus is absorbed rather than
* bounced back over the last crest, which is why the ends need no special case. */
const SWEEP = KICK * 6;
/** Pitches past a crest before the readout commits to the next notch. Without it a
* handle balanced on a crest rewrites aria-valuenow every frame. */
const SEAT_HYST = 0.04;
/** Room under the track for the tick marks and their labels, px. */
const SKIRT = 40;
/** Two colours. The tint is the accent under a tenth of an alpha. */
const INK = '234, 243, 255';
const ACCENT = '158, 205, 255';
const TAU = Math.PI * 2;
interface DetentState {
/** Track geometry. Rebuilt on resize, never mutated after. */
readonly x0: number;
readonly railY: number;
readonly pitch: number;
readonly amp: number;
readonly span: number;
/** What one crest costs: the tangential force integrated over half a pitch. */
readonly barrier: number;
/** The ball along the rail, 0 at the first notch. This is where the value lives. */
x: number;
v: number;
/** The hand at the last integrated step, so the next frame can walk it forward. */
hand: number;
/** Ball minus pointer at the moment of the grab, so taking hold does not jump it. */
grab: number;
held: boolean;
/** The notch the readout has committed to. The only thing published to React. */
seat: number;
carry: number;
clock: number;
/** Seat the ball and skip the solver. Set under `prefers-reduced-motion`, where the
* loop never runs and a ball advancing one accumulator per repaint never arrives. */
snap: boolean;
/** Last handle position written to the DOM, so an unmoved frame writes nothing. */
postedX: number;
postedY: number;
}
/** The slope of that rail, and the only thing that moves the carriage. */
function slopeAt(x: number, amp: number, pitch: number): number {
return ((TAU * amp) / pitch) * Math.sin((TAU * x) / pitch);
}
/** The preload resolved along the surface: N·s/√(1 + s²), signed back toward a floor. */
function tangential(x: number, amp: number, pitch: number): number {
const s = slopeAt(x, amp, pitch);
return (-PRELOAD * s) / Math.sqrt(1 + s * s);
}
/**
* The energy toll of one crest, by Simpson over half a pitch. Twenty-four panels on
* half a cosine is exact to a part in a billion, and it is computed rather than
* assumed because the arrow keys have to buy a crossing with it.
*/
function barrierOf(amp: number, pitch: number): number {
const panels = 24;
const h = pitch / 2 / panels;
let sum = 0;
for (let i = 0; i <= panels; i += 1) {
const weight = i === 0 || i === panels ? 1 : i % 2 === 1 ? 4 : 2;
sum += weight * Math.abs(tangential(i * h, amp, pitch));
}
return (sum * h) / 3;
}
const clampSeat = (notch: number): number => Math.max(0, Math.min(NOTCHES - 1, notch));
/** One fixed step of m·ẍ = F_hand + F_tan − c·ẋ, semi-implicit Euler. */
function advance(state: DetentState, hand: number, handVel: number): void {
let force = tangential(state.x, state.amp, state.pitch) - DRAG * state.v;
if (state.held) force += HAND * (hand - state.x) + GRIP * (handVel - state.v);
state.v += (force / MASS) * STEP;
state.x += state.v * STEP;
/*
* Both end stops land on a notch floor, and they are inelastic. A carriage that has
* hit its stop does not come back off it, and a bounce here could re-cross the last
* crest and settle one short of the end the flick plainly asked for.
*/
if (state.x <= 0) {
state.x = 0;
if (state.v < 0) state.v = 0;
} else if (state.x >= state.span) {
state.x = state.span;
if (state.v > 0) state.v = 0;
}
}
/**
* Which notch the readout is in. The hysteresis is in the label only — the solver has
* never heard of it — and it is what keeps a handle balanced on a crest from flipping
* between two values at frame rate while it makes its mind up.
*/
function reseat(state: DetentState): void {
const pitches = state.x / state.pitch;
if (Math.abs(pitches - state.seat) > 0.5 + SEAT_HYST) {
state.seat = clampSeat(Math.round(pitches));
}
}
function build({ width, height }: SceneSetupContext, seat: number, snap: boolean): DetentState {
// The inset has to clear the handle's own half-width and the two end tick labels.
const inset = Math.min(30, width * 0.14);
const span = Math.max(NOTCHES - 1, width - inset * 2);
const pitch = span / (NOTCHES - 1);
const amp = SCALLOP * pitch;
return {
x0: inset,
// Half the handle plus its focus ring is all the clearance the track needs above it.
railY: Math.max(16, height - SKIRT),
pitch,
amp,
span,
barrier: barrierOf(amp, pitch),
// Setup re-runs on resize and the pitch changes with it, so the ball is placed by
// the notch it was in rather than by the pixel it was at.
x: seat * pitch,
v: 0,
hand: seat * pitch,
grab: 0,
held: false,
seat,
carry: 0,
clock: 0,
snap,
postedX: Number.NaN,
postedY: Number.NaN,
};
}
/** Advance the mechanism to now, then paint it. */
function paint({ context, width, height, state, pointer }: SceneDrawContext<DetentState>) {
const now = performance.now();
const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;
state.clock = now;
/*
* Take hold on a press that lands in the handle's column, and keep it until the
* press ends. The grab is not re-tested while held, so a drag that runs off the card
* — which is where a hard flick naturally ends up — keeps the handle.
*/
if (!pointer.down) state.held = false;
else if (!state.held && pointer.inside && Math.abs(pointer.x - (state.x0 + state.x)) < REACH) {
state.held = true;
state.hand = state.x;
state.grab = state.x - (pointer.x - state.x0);
}
const demand = Math.max(0, Math.min(state.span, pointer.x - state.x0 + state.grab));
if (state.snap) {
// The answer instead of the route to it: the ball is placed in a notch and the
// rail is never integrated, because with the loop stopped it would never arrive.
if (state.held) state.seat = clampSeat(Math.round(demand / state.pitch));
state.x = state.seat * state.pitch;
state.v = 0;
state.hand = state.x;
state.carry = 0;
} else {
state.carry += elapsed;
const count = Math.min(MAX_STEPS, Math.floor(state.carry / STEP));
/*
* The hand is walked across the substeps rather than teleported to this frame's
* pointer, and the velocity handed to the grip damper is the one that walk
* actually has. That is the number a flick leaves in the ball, so it has to be the
* pointer's real speed and not a per-frame difference divided by a nominal step.
*/
const from = state.hand;
const handVel = count > 0 ? (demand - from) / (count * STEP) : 0;
for (let i = 1; i <= count; i += 1) {
advance(state, from + ((demand - from) * i) / count, handVel);
}
if (count > 0) {
state.hand = demand;
state.carry -= count * STEP;
}
if (state.carry > STEP * MAX_STEPS) state.carry = 0;
reseat(state);
}
context.clearRect(0, 0, width, height);
context.lineCap = 'round';
context.lineJoin = 'round';
// The track, from the first notch to the last: the whole travel and nothing past it.
context.lineWidth = 4;
context.strokeStyle = `rgba(${INK}, 0.12)`;
context.beginPath();
context.moveTo(state.x0, state.railY);
context.lineTo(state.x0 + state.span, state.railY);
context.stroke();
// The travelled part of it, filled to wherever the value has got to.
context.strokeStyle = `rgba(${ACCENT}, 0.82)`;
context.shadowColor = `rgba(${ACCENT}, 0.32)`;
context.shadowBlur = 14;
context.beginPath();
context.moveTo(state.x0, state.railY);
context.lineTo(state.x0 + state.x, state.railY);
context.stroke();
context.shadowBlur = 0;
/*
* Ticks and their values, placed at the solver's own notch positions rather than at
* even fractions of the width — the two agree here, and they have to be drawn from the
* same number or a resize would put the labels somewhere the handle cannot stop.
*/
context.textAlign = 'center';
context.textBaseline = 'middle';
context.font = '500 11px ui-monospace, "SFMono-Regular", Menlo, monospace';
for (let i = 0; i < NOTCHES; i += 1) {
const tx = state.x0 + i * state.pitch;
const seated = i === state.seat;
context.fillStyle = seated ? `rgba(${ACCENT}, 0.85)` : `rgba(${INK}, 0.26)`;
context.fillRect(tx - 0.5, state.railY + 19, 1, seated ? 7 : 4);
context.fillStyle = seated ? `rgba(${ACCENT}, 0.95)` : `rgba(${INK}, 0.32)`;
context.fillText(String(VALUES[i]), tx, state.railY + 33);
}
}
/**
* Put the DOM handle where the solver says the value is. The transform is written from the
* same `x0 + x` the canvas fills the track to, so the two cannot drift; a CSS `left`
* matched by hand to a canvas constant drifts the moment either changes. Skipped under
* half a pixel of movement, because writing a transform costs a layer update whether or
* not the value differs.
*/
function place(node: HTMLElement | null, state: DetentState): void {
if (!node) return;
const x = state.x0 + state.x;
const y = state.railY;
if (Math.abs(x - state.postedX) < 0.4 && Math.abs(y - state.postedY) < 0.4) return;
// The handle has no position until the solver has been asked for one, so it starts
// transparent rather than in the corner. Opacity and not `visibility`, which would take
// the slider out of the accessibility tree for as long as it took to place it.
if (Number.isNaN(state.postedX)) node.style.opacity = '1';
state.postedX = x;
state.postedY = y;
node.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -50%)`;
}
/**
* A settings card whose one control is the mechanism above.
*
* The canvas layer takes every pointer event, so the readout and the handle sit over it in
* a sibling layer that is transparent to the pointer. The handle is still the real
* `role="slider"`: a press anywhere on the card hands it focus, so the keyboard reaches
* the same control the mouse is dragging.
*/
/** `compact` is the 298x240 catalogue card: the same rail and the same reading, with the
* explanatory paragraph and the hint dropped. The rail is laid out from the measured
* canvas box, so the detent solve is unchanged — see `detent-slider.css`. */
export type DetentSliderProps = { compact?: boolean };
export function DetentSlider({ compact = false }: DetentSliderProps) {
const reduced = useReducedMotion();
const [value, setValue] = useState(VALUES[START]);
/** The notch the solver last settled on, so `draw` only touches React when it changes. */
const seatRef = useRef(START);
const handleRef = useRef<HTMLDivElement>(null);
/**
* A keypress leaves its request here for the next step to take, rather than reaching
* into the solver from an event handler: the impulse has to be applied inside the fixed
* step or it is an impulse of some unknown fraction of one.
*/
const pendingRef = useRef<{ kick: number; seat: number } | null>(null);
const draw = (scene: SceneDrawContext<DetentState>) => {
const { state } = scene;
state.snap = reduced;
const pending = pendingRef.current;
if (pending) {
pendingRef.current = null;
// With the loop stopped an impulse would never be integrated, so under reduced
// motion the key moves the notch instead. Everywhere else it is a shove on the ball
// and the rail decides where that lands — which is why a key can be watched
// crossing a crest, and can fail to.
if (reduced) state.seat = clampSeat(pending.seat);
else state.v += pending.kick * Math.sqrt((2 * state.barrier) / MASS);
}
paint(scene);
place(handleRef.current, state);
if (state.seat !== seatRef.current) {
seatRef.current = state.seat;
setValue(VALUES[state.seat]);
}
};
const { stageRef, canvasRef, requestRender } = useCanvasScene<DetentState>({
setup: (scene) => build(scene, seatRef.current, reduced),
draw,
});
// The readout is React and the ball is not, so a change of value — or of the motion
// preference, which stops the loop outright — has to ask for the one repaint that keeps
// the canvas showing the same notch the label does.
useEffect(() => {
requestRender();
}, [value, reduced, requestRender]);
/**
* Arrow keys hand the ball an impulse; Home and End hand it a bigger one. Nothing here
* assigns a value, so a key can be watched crossing a crest — and the readout changes
* when the ball has actually arrived, not when the key was pressed.
*/
const handleKey = (event: KeyboardEvent<HTMLDivElement>) => {
const seat = seatRef.current;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
pendingRef.current = { kick: KICK, seat: seat + 1 };
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
pendingRef.current = { kick: -KICK, seat: seat - 1 };
} else if (event.key === 'End') {
pendingRef.current = { kick: SWEEP, seat: NOTCHES - 1 };
} else if (event.key === 'Home') {
pendingRef.current = { kick: -SWEEP, seat: 0 };
} else {
return;
}
event.preventDefault();
requestRender();
};
return (
// The handle is transparent to the pointer so the canvas keeps the press and the
// capture with it; without this the control could only ever be reached by Tab. In a
// card there is nothing to hand focus to — the whole frame is aria-hidden — so the
// press is left to the canvas alone.
<div
className="detent-slider-stage"
data-compact={compact ? 'true' : undefined}
onPointerDown={
compact ? undefined : () => handleRef.current?.focus({ preventScroll: true })
}
>
<div className="detent-slider-card">
<div ref={stageRef} className="detent-slider-well" aria-hidden="true">
<canvas ref={canvasRef} />
</div>
<div className="detent-slider-face">
<p className="detent-slider-label">Grid columns</p>
<p className="detent-slider-read">
{value}
<span className="detent-slider-unit">columns</span>
</p>
<p className="detent-slider-copy">
Sets how many columns the grid lays out. Cards resize to fill the row, so fewer
columns means larger cards.
</p>
</div>
<div
ref={handleRef}
className="detent-slider-handle"
role="slider"
tabIndex={compact ? -1 : 0}
aria-label="Grid columns"
aria-valuemin={VALUES[0]}
aria-valuemax={VALUES[NOTCHES - 1]}
aria-valuenow={value}
aria-valuetext={`${value} columns`}
onKeyDown={handleKey}
/>
</div>
<p className="detent-slider-hint">Drag the handle, or flick it</p>
</div>
);
}
export default DetentSlider;.detent-slider-stage {
position: relative;
display: grid;
place-content: center;
width: 100%;
min-height: 21.5rem;
padding: 2.25rem 1.5rem;
overflow: hidden;
border-radius: 0.75rem;
background: radial-gradient(120% 110% at 50% 0%, #0c1620 0%, #070b12 60%, #05070c 100%);
color: #eaf3ff;
}
.detent-slider-card {
position: relative;
width: min(23rem, 100%);
height: 17rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015));
isolation: isolate;
}
/* The track, behind the readout. This and the handle are both positioned against the
card's padding box, which is the whole reason the handle's transform can be written
straight from the pixel the canvas drew the track to. */
.detent-slider-well {
position: absolute;
inset: 0;
touch-action: none;
}
.detent-slider-well canvas {
display: block;
width: 100%;
height: 100%;
}
/* Transparent to the pointer, so a press over the copy still takes hold of the handle.
The bottom padding is the track's room: the handle sits on the track, 40px up from the
card's bottom edge, and nothing in the flow may reach it. */
.detent-slider-face {
position: relative;
display: flex;
height: 100%;
flex-direction: column;
padding: 1.25rem 1.375rem 4.5rem;
pointer-events: none;
}
.detent-slider-label {
margin: 0 0 0.5rem;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(234, 243, 255, 0.5);
}
.detent-slider-read {
margin: 0;
font-size: 2.75rem;
font-weight: 500;
line-height: 0.92;
letter-spacing: -0.035em;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 18px rgba(5, 12, 20, 0.55);
}
.detent-slider-unit {
margin-left: 0.35em;
font-size: 1rem;
font-weight: 500;
letter-spacing: -0.01em;
color: rgba(234, 243, 255, 0.6);
}
.detent-slider-copy {
margin: 0.625rem 0 0;
max-width: 19rem;
font-size: 0.8125rem;
line-height: 1.45;
color: rgba(234, 243, 255, 0.52);
text-shadow: 0 1px 14px rgba(5, 12, 20, 0.5);
}
/*
* The handle. Its `transform` is written by the solver every frame, so the only geometry
* CSS owns here is the second translate that centres it on the point it is handed — no
* `left` matched by hand to a canvas constant, which is a pair of numbers that drift.
* `pointer-events: none` is what keeps the press on the canvas, where the capture is;
* focus still lands here, so Tab and the arrow keys reach the real slider.
*/
.detent-slider-handle {
position: absolute;
top: 0;
left: 0;
width: 2.125rem;
height: 1.125rem;
border: 1px solid rgba(158, 205, 255, 0.45);
border-radius: 0.3125rem;
background: linear-gradient(180deg, rgba(158, 205, 255, 0.22), rgba(8, 14, 22, 0.74));
box-shadow:
0 2px 10px rgba(4, 9, 16, 0.6),
inset 0 1px 0 rgba(234, 243, 255, 0.18);
pointer-events: none;
opacity: 0;
will-change: transform;
transition: border-color 160ms ease;
}
/* Knurl, so the handle reads as something a hand holds rather than a rounded rectangle. */
.detent-slider-handle::after {
content: "";
position: absolute;
inset: 0.3125rem 0.75rem;
border-left: 1px solid rgba(234, 243, 255, 0.26);
border-right: 1px solid rgba(234, 243, 255, 0.26);
}
.detent-slider-handle:focus-visible {
border-color: rgba(158, 205, 255, 0.8);
outline: 2px solid rgba(158, 205, 255, 0.75);
outline-offset: 3px;
}
.detent-slider-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 value is placed on its notch instead of travelling to it, so
* the handle arrives in one step — what is gone is the crossing, which is the part that was
* asked to go. Dragging and the arrow keys both still change the value.
*/
@media (prefers-reduced-motion: reduce) {
.detent-slider-handle {
transition: none;
}
}
/*
* The card variant: the 298x240 catalogue frame, at that real size and never scaled.
* The rail is laid out from the live canvas box — `railY` is `height − SKIRT` — so the
* notches, the ball and the handle all follow the card down without being told. All this
* does is take the paragraph and the hint out and let the reading breathe.
*/
.detent-slider-stage[data-compact='true'] {
min-height: 0;
height: 100%;
/* `place-content: center` leaves the single row auto-sized, and a row sized from its
content is what a card's `height: 100%` would then resolve against. Stretched, the row
is the frame and the card is the row. */
place-content: stretch;
padding: 0.75rem;
/* The card frame rounds and clips already. */
border-radius: 0;
}
.detent-slider-stage[data-compact='true'] .detent-slider-card {
width: 100%;
height: 100%;
}
/* A full-bleed drag surface that claims every touch traps the page inside a scrolling
grid. `pan-y` hands the vertical gesture back to the document; the horizontal drag is
the one this mechanism is about, and it still arrives. */
.detent-slider-stage[data-compact='true'] .detent-slider-well {
touch-action: pan-y;
}
/* The rail keeps its skirt: the padding below is what the notch numbers sit in. */
.detent-slider-stage[data-compact='true'] .detent-slider-face {
padding: 0.8125rem 0.9375rem 4.5rem;
}
.detent-slider-stage[data-compact='true'] .detent-slider-read {
font-size: 2.25rem;
}
/* Three lines of prose and a hint, both of which the card's own title already covers. */
.detent-slider-stage[data-compact='true'] .detent-slider-copy,
.detent-slider-stage[data-compact='true'] .detent-slider-hint {
display: none;
}"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 }
}