Swing

Letters suspended from a cafe rail on individual chains.

isomer.sh/effects/swing

Knobs

Continue on iPhone
Preset
#E8DBB8
1.00
0.12
220
Colour2
#111815
#8D8A74
Shape4
48.0
1.00
54.0
1.40
Interaction1
0.12
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

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.