Swing
Letters suspended from a cafe rail on individual chains.
Knobs
Preset
#E8DBB8
Colour2
#111815
#8D8A74
Shape4
Interaction1
Still frame
What reduced motion shows: 0.24 s.
Install
Code
components/isomer/swing.tsx
'use client'; // Generated by isomer. Do not edit by hand; run `isomer add` again to update. (from packages/react/src/Swing.tsx) // generated by packages/react/scripts/gen-components.mjs, do not edit import { useEffect, useRef, type CSSProperties } from 'react'; import { usePrefersReducedMotion } from './useIsomerCanvas'; // @ts-ignore: the scene and its dependencies are copied unchanged from effects/swing/web/ import * as runtime from './swing.js'; type SpringStates = Record<string, { value: number; velocity: number }>; type SceneRender = ((t: number, knobs: SwingKnobs, initial?: SpringStates) => void) & { ready?: Promise<unknown>; sample?: (t: number, knobs: SwingKnobs, initial?: SpringStates) => SpringStates; dispose?: () => void; }; const { DEFAULTS, mount } = runtime as { DEFAULTS: SwingKnobs; mount: (root: HTMLElement) => SceneRender }; const POSTER = 0.24; const LOOP = 0.0; const INPUT: keyof SwingKnobs | undefined = 'kick'; export interface SwingKnobs { ink: string; weight: number; damping: number; kick: number; wall: string; metal: string; letterSize: number; spacing: number; chainLength: number; chainWidth: number; scrollInfluence: number; } export interface SwingProps extends Partial<SwingKnobs> { className?: string; style?: CSSProperties; /** Render this one frame (seconds) and run no clock or live inputs. */ time?: number; paused?: boolean; /** Draw the poster at t = 0.24 and stop when reduced motion is preferred. Default true. */ respectReducedMotion?: boolean; /** Accepted for compatibility with shader components; DOM frames are always readable. */ preserveDrawingBuffer?: boolean; /** * Default true. Without a kick prop, read the nearest scrolling ancestor, else the page. * kick is points per second, once per scroll burst. A changed prop carries the current position and adds * velocity, as on iOS; return to zero between identical kicks. Fixed time and reduced motion ignore impulses. */ live?: boolean; } export const swingDefaults: Readonly<SwingKnobs> = DEFAULTS; export function Swing({ className, style, time, paused = false, respectReducedMotion = true, preserveDrawingBuffer, live = true, ...props }: SwingProps) { const knobs = { ...DEFAULTS }; for (const k of Object.keys(DEFAULTS) as (keyof SwingKnobs)[]) { if (props[k] !== undefined) (knobs as unknown as Record<string, unknown>)[k] = props[k]; } const reduced = usePrefersReducedMotion() && respectReducedMotion; const fed = INPUT !== undefined && props[INPUT] !== undefined; const config = { knobs, time, paused, reduced, live, fed }; const latest = useRef(config); latest.current = config; const ref = useRef<HTMLDivElement>(null); const updateRef = useRef<(() => void) | null>(null); const knobsKey = JSON.stringify(knobs); useEffect(() => { const root = ref.current; if (!root) return; const render = mount(root); let cfg = latest.current, initial: SpringStates = {}, t = 0, base = performance.now(); let ready = false, disposed = false, running = false, raf = 0; let host = root.parentElement; while (host && host !== document.body && host !== document.documentElement) { if (/^(auto|scroll|overlay)$/.test(getComputedStyle(host).overflowY)) break; host = host.parentElement; } if (host === document.body || host === document.documentElement) host = null; const target = host ?? window; const offset = () => host ? host.scrollTop : (document.scrollingElement?.scrollTop ?? window.scrollY); let lastY = offset(), lastAt = performance.now(), scrolling = false, listening = false; const nowTime = () => running ? t + (performance.now() - base) / 1000 : t; const sceneTime = (seconds: number) => LOOP > 0 ? seconds % LOOP : seconds; const enabled = (c: typeof cfg) => c.live && c.time === undefined && !c.reduced && !c.paused && !document.hidden; const draw = () => render(cfg.time !== undefined ? cfg.time : cfg.reduced ? POSTER : sceneTime(nowTime()), cfg.knobs, initial); const frame = () => { if (disposed) return; draw(); raf = requestAnimationFrame(frame); }; const kick = (amount: number) => { if (!ready || !enabled(cfg) || INPUT === undefined || !render.sample || !Number.isFinite(amount)) return; const current = render.sample(sceneTime(nowTime()), cfg.knobs, initial); const impulse = render.sample(0, { ...cfg.knobs, [INPUT]: amount }); initial = Object.fromEntries(Object.entries(current).map(([name, state]) => [name, { value: state.value, velocity: state.velocity + impulse[name].velocity, }])); t = 0; base = performance.now(); }; const scroll = () => { const now = performance.now(), y = offset(), delta = y - lastY; const first = !scrolling || now - lastAt > 120; const dt = Math.min(0.1, Math.max(1 / 60, (now - lastAt) / 1000)); lastY = y; lastAt = now; if (delta !== 0) { scrolling = true; if (first) kick(Math.max(-2000, Math.min(2000, delta / dt))); } }; const update = () => { const next = latest.current; // Sample the old knobs before replacing them, so a new impulse never changes the carried position. if (INPUT !== undefined && next.fed && enabled(next) && next.knobs[INPUT] !== cfg.knobs[INPUT]) kick(Number(next.knobs[INPUT])); t = nowTime(); base = performance.now(); cfg = next; running = ready && cfg.time === undefined && !cfg.reduced && !cfg.paused && !document.hidden; if (cfg.time !== undefined || cfg.reduced || !cfg.live) initial = {}; if (cfg.time !== undefined) t = cfg.time; else if (cfg.reduced) t = POSTER; target.removeEventListener('scroll', scroll); if (ready && INPUT !== undefined && enabled(cfg) && !cfg.fed) { if (!listening) { scrolling = false; lastY = offset(); lastAt = performance.now(); } target.addEventListener('scroll', scroll, { passive: true }); listening = true; } else listening = false; cancelAnimationFrame(raf); if (ready) { draw(); if (running) raf = requestAnimationFrame(frame); } }; updateRef.current = update; document.addEventListener('visibilitychange', update); // The background is in the markup already. Do not expose fallback glyphs while the bundled font loads. Promise.resolve(render.ready).then(() => { if (disposed) return; ready = true; update(); }, error => { if (!disposed) console.error('[isomer] could not load scene:', error); }); return () => { disposed = true; cancelAnimationFrame(raf); target.removeEventListener('scroll', scroll); document.removeEventListener('visibilitychange', update); updateRef.current = null; render.dispose?.(); root.replaceChildren(); }; }, []); useEffect(() => { updateRef.current?.(); }, [knobsKey, time, paused, reduced, live, fed]); return <div ref={ref} className={className} style={{ position: 'relative', display: 'block', width: '100%', height: '100%', overflow: 'hidden', background: '#111815', ...style }} />; }
Parity
95.7Pass
iPhone 17 Pro simulator, iOS 26.3.1 against Chromium headless + SwiftShader; parity: geometry and colour only. Native glyph rasterisation differs; no frame-identical claim., 15 October 2026.