Mikkel Manniche
Dansk English Deutsch

Widgets

Wave ribbon

Bilder auf einem langen Band, das sanft in der Tiefe wogt; die Nachbarn verschwinden im Nebel der Hintergrundfarbe, und schnelles Ziehen vertieft die Welle etwas. WebGL mit CSS-3D-Ersatz.

Vorschau

Verwendung

wave-ribbon-demo.tsx
import { useState } from 'react'
import { glPictures } from '@/components/gl-pictures'
import { WaveRibbon } from '@/components/wave-ribbon'

export default function WaveRibbonDemo() {
  const [index, setIndex] = useState(2)
  const picture = glPictures[index]
  return (
    <div className="flex w-full flex-col gap-5">
      <WaveRibbon images={glPictures} label="Landscapes" loop animate index={index} onIndexChange={setIndex} />
      <div className="mx-auto -mt-1 flex max-w-sm flex-col items-center gap-1.5 px-4 text-center">
        <p className="font-mono text-[10.5px] leading-none font-medium tracking-[0.08em] text-muted-foreground uppercase">Print {String(index + 1).padStart(2, '0')}</p>
        <p className="text-[15px] leading-tight font-medium tracking-[-0.01em] text-foreground">{picture.title}</p>
      </div>
    </div>
  )
}

Quellcode

wave-ribbon.tsx
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
  useCallback,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type HTMLAttributes,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
} from 'react'
import { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'
import { glAtlas, glProgram, useGlStage, type GlAtlas, type GlImage, type GlSceneContext, type Rgb } from '@/registry/manniche/hooks/use-gl-stage'
import { cn } from '@/lib/utils'

export type WaveRibbonProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
  /** The pictures, each with alt text. Pictures from another origin must send CORS headers to be drawn into WebGL. */
  images: GlImage[]
  /** Read by screen readers, e.g. “Landscapes”. */
  label: string
  /** Controlled: the picture at the front. */
  index?: number
  /** The picture at the front at first, when uncontrolled. */
  defaultIndex?: number
  /** Called when the ribbon heads for a new picture: on release, a key, a button or a click. */
  onIndexChange?: (index: number) => void
  /** Wrap round from the last picture to the first. Needs three pictures or more. */
  loop?: boolean
  /** Picture width over height. */
  aspect?: number
  /** How deep the wave runs; 0 lays the pictures out in a flat row. */
  amplitude?: number
  /** How much the pictures away from the front fade into the page, 0–1. */
  fog?: number
  /** A very slow drift in the wave while the ribbon is at rest. Off under reduced motion and off screen. */
  animate?: boolean
  /** Show the previous and next buttons and the position rule. */
  controls?: boolean
  /** The words on the buttons and for screen readers, to translate them, e.g. `{ picture: 'Bild', of: 'von' }`. */
  labels?: Partial<Record<'previous' | 'next' | 'picture' | 'of', string>>
}

const EN = { previous: 'Previous picture', next: 'Next picture', picture: 'Picture', of: 'of' }
const TYPING = 'input, textarea, select, [contenteditable=""], [contenteditable="true"]'

// The scene, in world units of one picture width.
const GAP = 0.09 // between pictures, along the ribbon
const FOV = (24 * Math.PI) / 180 // vertical field of view: long, so the wave reads calm rather than wide-angle
const DEPTH = 0.42 // how far the wave dips back, at amplitude 1
const LIFT = 0.06 // how far it dips down as it goes back
const WAVE = 3.9 // one wavelength, crest to crest
const RECEDE = 0.28 // how far the ribbon falls back towards its ends, per RECEDE_LEN of length
const RECEDE_LEN = 1.6
const RADIUS = 14 // px, the corner radius of the picture at the front
const SEG = 28 // columns per picture, so it bends smoothly
const PAD = 0.03 // world units of quad around each picture, room for its smoothed edge
const FOG_START = 1 // distance along the ribbon where the fog begins
const FOG_DEPTH = 0.35 // how fast the fog thickens with depth
const FLUTTER = 0.3 // how much deeper the wave runs at full speed

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const smooth = (a: number, b: number, v: number) => {
  const t = clamp((v - a) / (b - a), 0, 1)
  return t * t * (3 - 2 * t)
}

/** The wave: depth `a` with wavenumber `k`, a dip of `h` with wavenumber `ky`, a drifting phase `ph`, and a fall
 *  back of `r` per `rl` towards the ends. */
type Wave = { a: number; k: number; h: number; ky: number; ph: number; r: number; rl: number }

// The ribbon's centre line, as height and depth over x. The crest sits at x = 0, facing the camera.
const waveY = (w: Wave, x: number) => w.h * (Math.cos(w.ky * x + w.ph) - Math.cos(w.ph))
const waveZ = (w: Wave, x: number) => w.a * (Math.cos(w.k * x) - 1) - w.r * (Math.hypot(1, x / w.rl) - 1)
const slopeY = (w: Wave, x: number) => -w.h * w.ky * Math.sin(w.ky * x + w.ph)
const slopeZ = (w: Wave, x: number) => -w.a * w.k * Math.sin(w.k * x) - (w.r * x) / (w.rl * w.rl * Math.hypot(1, x / w.rl))
// How far x moves per unit of distance along the ribbon.
const dxds = (w: Wave, x: number) => 1 / Math.sqrt(1 + slopeY(w, x) ** 2 + slopeZ(w, x) ** 2)
// Walk `s` along the ribbon from x, in midpoint steps. The vertex shader takes the same steps across a picture.
function walk(w: Wave, x: number, s: number, steps: number) {
  const h = s / steps
  for (let i = 0; i < steps; i++) x += h * dxds(w, x + 0.5 * h * dxds(w, x))
  return x
}
const arcToX = (w: Wave, s: number) => walk(w, 0, s, Math.max(1, Math.ceil(Math.abs(s) / 0.2)))

/** The camera and sizes for a stage, in world units of one picture width. */
type Rig = {
  width: number
  height: number
  half: [number, number]
  spacing: number
  fx: number
  fy: number
  shift: number
  dist: number
  camY: number
  /** Pixels per world unit at the front. */
  ppu: number
  radius: number
  /** Distance along the ribbon where a picture's outer edge reaches the side of the frame. */
  end: number
  /** Pixels at each side of the frame over which the ribbon dissolves into the page. */
  band: number
  wave: Wave
}

function rigFor(width: number, height: number, aspect: number, amplitude: number, n: number, loop: boolean): Rig {
  const half: [number, number] = [0.5, 0.5 / aspect]
  const ratio = width / height
  const f = 1 / Math.tan(FOV / 2)
  // The front picture takes a set share of the height, and at most under a third of the width.
  const share = 0.72
  const dist = Math.max((half[1] * f) / share, (half[0] * f) / (ratio * 0.3))
  const camY = half[1] * 0.42
  const k = (2 * Math.PI) / WAVE
  const wave: Wave = { a: DEPTH * amplitude, k, h: LIFT * amplitude, ky: k * 0.5, ph: 0, r: RECEDE * amplitude, rl: RECEDE_LEN }
  const rig: Rig = {
    width,
    height,
    half,
    spacing: 1 + GAP,
    fx: f / ratio,
    fy: f,
    shift: (f * camY) / dist,
    dist,
    camY,
    ppu: (height / 2) * (f / dist),
    radius: 0,
    end: 0,
    band: clamp(width * 0.13, 48, 200),
    wave,
  }
  rig.radius = Math.min(RADIUS / rig.ppu, 0.16)
  // Walk out along the ribbon until a picture's outer edge reaches the side of the frame.
  let s = 0.5
  while (s < 9) {
    const x = arcToX(wave, s + 0.5)
    const [px] = project(rig, x, waveY(wave, x), waveZ(wave, x))
    if (px > width) break
    s += 0.05
  }
  rig.end = loop ? Math.min(s, (n / 2) * rig.spacing - 0.4) : s
  return rig
}

// A world point to stage pixels, and its distance from the camera.
function project(r: Rig, x: number, y: number, z: number): [number, number, number] {
  const w = r.dist - z
  const nx = (r.fx * x) / w
  const ny = (r.fy * (y - r.camY)) / w + r.shift
  return [((nx + 1) / 2) * r.width, ((1 - ny) / 2) * r.height, w]
}

// How much a point at distance `s` along the ribbon and depth `z` mixes into the page.
function fogAt(r: Rig, fog: number, s: number, z: number) {
  const fs = smooth(FOG_START, r.end + 0.5, Math.abs(s))
  const fz = 1 - Math.exp(z * FOG_DEPTH)
  return fog * (1 - (1 - fs) * (1 - fz))
}

// How visible the ribbon is at a horizontal point on the stage: it dissolves into the page at both sides.
const edgeAt = (r: Rig, px: number) => smooth(0, r.band, Math.min(px, r.width - px))

type Card = { i: number; s: number; x: number; w: number; alpha: number }

// The pictures in view for a position, farthest first.
function lay(r: Rig, wave: Wave, p: number, n: number, loop: boolean): Card[] {
  const out: Card[] = []
  for (let i = 0; i < n; i++) {
    let d = i - p
    if (loop) d = ((((d + n / 2) % n) + n) % n) - n / 2
    const s = d * r.spacing
    if (Math.abs(s) > r.end + 1.05) continue
    // Near the seam of a loop a picture is about to jump to the other end, so it is gone first.
    const seam = loop ? clamp((n / 2 - Math.abs(d)) / 0.75, 0, 1) : 1
    if (seam <= 0) continue
    const x = arcToX(wave, s)
    out.push({ i, s, x, w: r.dist - waveZ(wave, x), alpha: seam })
  }
  return out.sort((a, b) => b.w - a.w)
}

const VERTEX = `
attribute vec2 aP;
uniform vec2 uHalf;
uniform float uPad;
uniform float uXc;
uniform float uSc;
uniform vec4 uWave;
uniform float uPh;
uniform vec2 uRec;
uniform vec4 uCam;
uniform vec3 uCam2;
uniform float uViewH;
varying vec2 vLocal;
varying float vS;
varying float vZ;
varying vec3 vN;
varying float vAA;
vec2 slope(float x){float e=sqrt(1.+x*x/(uRec.y*uRec.y));return vec2(-uWave.z*uWave.w*sin(uWave.w*x+uPh),-uWave.x*uWave.y*sin(uWave.y*x)-uRec.x*x/(uRec.y*uRec.y*e));}
float dxds(float x){vec2 d=slope(x);return inversesqrt(1.+dot(d,d));}
void main(){
  vec2 p=aP*(uHalf+uPad);
  // Walk from the picture's centre along the ribbon, the same midpoint steps as on the CPU.
  float h=p.x*.5;
  float x=uXc;
  x+=h*dxds(x+.5*h*dxds(x));
  x+=h*dxds(x+.5*h*dxds(x));
  vec2 d=slope(x);
  float y=uWave.z*(cos(uWave.w*x+uPh)-cos(uPh))+p.y;
  float z=uWave.x*(cos(uWave.y*x)-1.)-uRec.x*(sqrt(1.+x*x/(uRec.y*uRec.y))-1.);
  vN=normalize(vec3(-d.y,0.,1.));
  vLocal=p;
  vS=uSc+p.x;
  vZ=z;
  vec3 v=vec3(x,y-uCam2.x,z-uCam.w);
  float w=-v.z;
  float n=uCam2.y;
  float f=uCam2.z;
  gl_Position=vec4(uCam.x*v.x,uCam.y*v.y+uCam.z*w,(f+n)/(f-n)*w-2.*f*n/(f-n),w);
  // World units per device pixel here, for the edge when derivatives are missing.
  vAA=w/(uCam.y*uViewH*.5);
}`

const FRAGMENT = `
precision mediump float;
uniform sampler2D uTex;
uniform vec4 uRect;
uniform vec2 uBox;
uniform float uRadius;
uniform float uHair;
uniform vec3 uBg;
uniform vec3 uFg;
uniform vec4 uFog;
uniform vec2 uEdge;
uniform float uAlpha;
uniform vec3 uLight;
varying vec2 vLocal;
varying float vS;
varying float vZ;
varying vec3 vN;
varying float vAA;
void main(){
  vec2 q=abs(vLocal)-uBox+uRadius;
  float d=length(max(q,0.))+min(max(q.x,q.y),0.)-uRadius;
#ifdef DERIV
  float aa=max(fwidth(d),1e-4);
#else
  float aa=vAA;
#endif
  float a=clamp(.5-d/aa,0.,1.)*uAlpha;
  // The ribbon dissolves into the page at both sides of the frame.
  a*=smoothstep(0.,uEdge.y,min(gl_FragCoord.x,uEdge.x-gl_FragCoord.x));
  if(a<.002)discard;
  vec2 uv=clamp(vLocal/(2.*uBox)+.5,0.,1.);
  vec3 c=texture2D(uTex,mix(uRect.xy,uRect.zw,vec2(uv.x,1.-uv.y))).rgb;
  // Lit softly from the upper left and front, so the bend reads.
  float lam=max(dot(normalize(vN),uLight),0.);
  c*=mix(.84,1.02,lam);
  // A hairline just inside the edge.
  float line=clamp((d+aa*uHair)/aa+.5,0.,1.);
  c=mix(c,uFg,line*.13);
  float fs=smoothstep(uFog.x,uFog.y,abs(vS));
  float fz=1.-exp(vZ*uFog.z);
  c=mix(c,uBg,uFog.w*(1.-(1.-fs)*(1.-fz)));
  gl_FragColor=vec4(c*a,a);
}`

/**
 * A gallery on a long ribbon in 3D. The ribbon runs from left to right and undulates gently in depth: the picture
 * at the front faces you on the crest, and its neighbours bend along the wave as it falls away, fading into the page
 * at both ends. Dragging travels along the ribbon: the pictures slide along the curve while the wave stays where it
 * is, and speed briefly deepens it, so the ribbon flutters and then settles. Drag or swipe with momentum, use a
 * trackpad, the arrow keys, the buttons, or click a picture to bring it to the front. The pictures are drawn with
 * WebGL as finely divided quads that the vertex shader bends onto the ribbon. Under reduced motion every move jumps
 * and the ribbon holds still. Without WebGL, or if the GPU drops the context, the same pictures stand flat on the
 * same wave in CSS 3D.
 */
export function WaveRibbon({
  images,
  label,
  index,
  defaultIndex,
  onIndexChange,
  loop = false,
  aspect = 4 / 5,
  amplitude = 1,
  fog = 0.6,
  animate = false,
  controls = true,
  labels = {},
  className,
  onKeyDown,
  ...rest
}: WaveRibbonProps) {
  const t = { ...EN, ...labels }
  const n = images.length
  const wraps = loop && n > 2
  const id = useId()

  const stage = useRef<HTMLDivElement | null>(null)
  const cards = useRef<(HTMLDivElement | null)[]>([])
  const ticks = useRef<(HTMLSpanElement | null)[]>([])
  const [width, setWidth] = useState(0)
  const height = width ? Math.round(clamp(width * 0.3, 300, 480)) : 0
  const rig = width ? rigFor(width, height, aspect, amplitude, n, wraps) : null

  // What the frame loop and the scene read, so neither needs React to render.
  const live = useRef({ rig, n, wraps, fog, pos: 0, vel: 0, wave: rig?.wave ?? null, ready: false })
  useLayoutEffect(() => {
    Object.assign(live.current, { rig, n, wraps, fog })
  })
  const source = useRef({ images, aspect })
  useLayoutEffect(() => {
    source.current = { images, aspect }
  })

  const build = useCallback(({ gl, ready, redraw, fail, colors: initial, signal }: GlSceneContext) => {
    const deriv = !!gl.getExtension('OES_standard_derivatives')
    const prog = glProgram(gl, VERTEX, (deriv ? '#extension GL_OES_standard_derivatives : enable\n#define DERIV\n' : '') + FRAGMENT)
    if (!prog) return null
    const u = (name: string) => gl.getUniformLocation(prog, name)
    const U = {
      half: u('uHalf'),
      box: u('uBox'),
      pad: u('uPad'),
      xc: u('uXc'),
      sc: u('uSc'),
      wave: u('uWave'),
      ph: u('uPh'),
      rec: u('uRec'),
      cam: u('uCam'),
      cam2: u('uCam2'),
      viewH: u('uViewH'),
      tex: u('uTex'),
      rect: u('uRect'),
      radius: u('uRadius'),
      hair: u('uHair'),
      bg: u('uBg'),
      fg: u('uFg'),
      fog: u('uFog'),
      edge: u('uEdge'),
      alpha: u('uAlpha'),
      light: u('uLight'),
    }
    // One strip of columns from the left edge to the right, two rows: the ribbon only bends sideways.
    const verts = new Float32Array((SEG + 1) * 4)
    for (let i = 0; i <= SEG; i++) verts.set([-1 + (2 * i) / SEG, 1, -1 + (2 * i) / SEG, -1], i * 4)
    const buf = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, buf)
    gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)
    const aP = gl.getAttribLocation(prog, 'aP')

    let atlas: GlAtlas | null = null
    let colors: Rgb[] = initial
    let viewH = 1
    let dpr = 1
    // The flutter: how much deeper the wave runs now, eased towards the speed without overshoot.
    let flutter = 0
    let last = 0
    let settle = 0
    const L = Math.hypot(-0.5, 0.32, 1)

    const { images: pics, aspect: ratio } = source.current
    glAtlas(gl, pics, { aspect: ratio, cell: 768, empty: colors[2], signal }).then((a) => {
      if (signal.aborted) return
      if (!a) return fail()
      atlas = a
      ready()
    })

    const draw = (time: number) => {
      const st = live.current
      const r = st.rig
      gl.clearColor(0, 0, 0, 0)
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
      if (!r || !atlas) return

      const now = performance.now()
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016
      last = now
      const want = FLUTTER * Math.tanh(Math.abs(st.vel) / 7)
      // Quick to deepen, slow to relax: a first-order ease, so it never swings past.
      flutter += (want - flutter) * (1 - Math.exp(-dt / (want > flutter ? 0.09 : 0.42)))
      if (flutter < 0.0008 && !want) flutter = 0
      cancelAnimationFrame(settle)
      if (flutter > 0 && !want) settle = requestAnimationFrame(() => redraw())

      // At rest the phase is 0; with the ambient drift it sways slowly round it.
      const ph = 0.55 * Math.sin((time - 4) * 0.13)
      const wave: Wave = { ...r.wave, a: r.wave.a * (1 + flutter), h: r.wave.h * (1 + flutter * 0.6), ph }
      st.wave = wave
      const list = lay(r, wave, st.pos, st.n, st.wraps)

      gl.useProgram(prog)
      gl.enable(gl.DEPTH_TEST)
      gl.depthFunc(gl.LEQUAL)
      gl.enable(gl.BLEND)
      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
      gl.bindBuffer(gl.ARRAY_BUFFER, buf)
      gl.enableVertexAttribArray(aP)
      gl.vertexAttribPointer(aP, 2, gl.FLOAT, false, 0, 0)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, atlas.texture)
      gl.uniform1i(U.tex, 0)
      gl.uniform2f(U.half, r.half[0], r.half[1])
      gl.uniform2f(U.box, r.half[0], r.half[1])
      gl.uniform1f(U.pad, PAD)
      gl.uniform4f(U.wave, wave.a, wave.k, wave.h, wave.ky)
      gl.uniform1f(U.ph, wave.ph)
      gl.uniform2f(U.rec, wave.r, wave.rl)
      gl.uniform4f(U.cam, r.fx, r.fy, r.shift, r.dist)
      gl.uniform3f(U.cam2, r.camY, 0.3, r.dist + 12)
      gl.uniform1f(U.viewH, viewH)
      gl.uniform1f(U.radius, r.radius)
      gl.uniform1f(U.hair, dpr)
      gl.uniform3f(U.bg, ...colors[0])
      gl.uniform3f(U.fg, ...colors[1])
      gl.uniform4f(U.fog, FOG_START, r.end + 0.5, FOG_DEPTH, st.fog)
      gl.uniform2f(U.edge, gl.drawingBufferWidth, r.band * dpr)
      gl.uniform3f(U.light, -0.5 / L, 0.32 / L, 1 / L)
      for (const c of list) {
        const rect = atlas.rects[c.i]
        if (!rect) continue
        gl.uniform1f(U.xc, c.x)
        gl.uniform1f(U.sc, c.s)
        gl.uniform4f(U.rect, rect[0], rect[1], rect[2], rect[3])
        gl.uniform1f(U.alpha, c.alpha)
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, (SEG + 1) * 2)
      }
    }

    return {
      draw,
      resize: (_w: number, h: number, ratio: number) => {
        viewH = h
        dpr = ratio
      },
      theme: (c: Rgb[]) => {
        colors = c
      },
      dispose: () => {
        cancelAnimationFrame(settle)
        gl.deleteBuffer(buf)
        gl.deleteProgram(prog)
        if (atlas) gl.deleteTexture(atlas.texture)
      },
    }
  }, [])

  const key = `${images.map((im) => im.src).join('\n')}|${aspect}`
  const gl = useGlStage(stage, build, key, {
    animate,
    maxDpr: 2,
    colors: ['var(--background)', 'var(--foreground)', 'var(--muted)'],
    className: 'pointer-events-none absolute inset-0 size-full opacity-0 transition-opacity duration-300 ease-out-quint data-ready:opacity-100 motion-reduce:transition-none',
  })
  const ready = gl.status === 'ready'

  // The flat cards in CSS 3D, for when WebGL is not drawing: the same wave, the same camera.
  const paint = useCallback(() => {
    const st = live.current
    const r = st.rig
    if (!r || st.ready) return
    const p = st.pos
    const shown = new Set<number>()
    for (const c of lay(r, r.wave, p, st.n, st.wraps)) {
      const el = cards.current[c.i]
      if (!el) continue
      shown.add(c.i)
      const z = waveZ(r.wave, c.x)
      const mix = fogAt(r, st.fog, c.s, z)
      const turn = Math.atan(-slopeZ(r.wave, c.x))
      el.style.visibility = 'visible'
      el.style.transform =
        `translate3d(${(c.x * r.ppu).toFixed(2)}px, ${(-waveY(r.wave, c.x) * r.ppu).toFixed(2)}px, ${(z * r.ppu).toFixed(2)}px)` +
        ` rotateY(${turn.toFixed(4)}rad)`
      el.style.opacity = c.alpha.toFixed(3)
      el.style.zIndex = String(1000 - Math.round(c.w * 50))
      const veil = el.lastElementChild as HTMLElement
      veil.style.opacity = mix.toFixed(3)
    }
    cards.current.forEach((el, i) => {
      if (el && !shown.has(i)) el.style.visibility = 'hidden'
    })
  }, [])

  const rule = useCallback((p: number) => {
    const { n: count, wraps: w } = live.current
    const at = w ? (((p % count) + count) % count) / count : (Math.min(Math.max(p, 0), count - 1) / Math.max(count - 1, 1)) * (1 - 1 / count)
    ticks.current.forEach((el, k) => {
      if (el) el.style.transform = `translateX(${((at - k) * 100).toFixed(3)}cqw)`
    })
  }, [])

  const { redraw } = gl
  const onFrame = useCallback(
    (p: number, v: number) => {
      live.current.pos = p
      live.current.vel = v
      redraw()
      paint()
      rule(p)
    },
    [redraw, paint, rule],
  )

  const engine = useCarouselEngine({
    count: n,
    loop: wraps,
    index,
    defaultIndex,
    onIndexChange,
    step: rig ? rig.spacing * rig.ppu : 300,
    onFrame,
  })
  const current = engine.index

  // The stage only exists while there are pictures, so watch it again when the first ones arrive.
  const empty = !n
  useLayoutEffect(() => {
    const el = stage.current
    if (!el) return
    const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)))
    ro.observe(el)
    return () => ro.disconnect()
  }, [empty])

  // A new size, shape or set of pictures, or WebGL taking over or dropping out: draw the current frame again.
  const { draw } = engine
  useLayoutEffect(() => {
    live.current.ready = ready
    draw()
  }, [draw, ready, width, aspect, amplitude, fog, n, wraps])

  // The picture under a point on the stage, from the same maths the shader runs; the nearest wins.
  const hit = (e: MouseEvent<HTMLElement> | PointerEvent<HTMLElement>) => {
    const st = live.current
    const r = st.rig
    const wave = st.wave ?? r?.wave
    const el = stage.current
    if (!r || !wave || !el) return null
    const box = el.getBoundingClientRect()
    const px = e.clientX - box.left
    const py = e.clientY - box.top
    let best: number | null = null
    for (const c of lay(r, wave, st.pos, st.n, st.wraps)) {
      if (c.alpha * edgeAt(r, project(r, c.x, waveY(wave, c.x), waveZ(wave, c.x))[0]) < 0.3) continue
      const outline: [number, number][] = []
      for (const side of [1, -1]) {
        for (let j = 0; j <= 8; j++) {
          const ds = (side > 0 ? -0.5 + j / 8 : 0.5 - j / 8) * 2 * r.half[0]
          const x = walk(wave, c.x, ds, 2)
          const [sx, sy] = project(r, x, waveY(wave, x) + side * r.half[1], waveZ(wave, x))
          outline.push([sx, sy])
        }
      }
      let inside = false
      for (let a = 0, b = outline.length - 1; a < outline.length; b = a++) {
        const [xa, ya] = outline[a]
        const [xb, yb] = outline[b]
        if (ya > py !== yb > py && px < ((xb - xa) * (py - ya)) / (yb - ya) + xa) inside = !inside
      }
      // Drawn farthest first, so the last hit is the one in front.
      if (inside) best = c.i
    }
    return best
  }

  const onClick = (e: MouseEvent<HTMLDivElement>) => {
    if (!ready || e.defaultPrevented) return
    const i = hit(e)
    if (i !== null && i !== current) engine.go(i)
  }

  // A pointer over a picture away from the front shows that it can be clicked.
  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    engine.bind.onPointerMove(e)
    const el = e.currentTarget
    if (!ready || e.pointerType !== 'mouse' || 'dragging' in el.dataset) {
      el.style.cursor = ''
      return
    }
    const i = hit(e)
    el.style.cursor = i !== null && i !== current ? 'pointer' : ''
  }

  const keys = (e: KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e)
    if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return
    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') engine.move(e.key === 'ArrowRight' ? 1 : -1)
    else if (e.key === 'Home') engine.go(0)
    else if (e.key === 'End') engine.go(n - 1)
    else return
    e.preventDefault()
  }

  if (!n) return null

  const atStart = !wraps && current === 0
  const atEnd = !wraps && current === n - 1
  const button =
    'grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-muted text-foreground [-webkit-tap-highlight-color:transparent] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--muted))] ' +
    'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:active:scale-100 motion-reduce:transition-none ' +
    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'

  return (
    <div onKeyDown={keys} className={cn('w-full', className)} {...rest}>
      <div
        {...engine.bind}
        ref={(el) => {
          stage.current = el
          engine.bind.ref(el)
        }}
        onPointerMove={onPointerMove}
        onClick={onClick}
        id={id}
        role="group"
        aria-roledescription="carousel"
        aria-label={label}
        tabIndex={0}
        style={{ height: height || 300 }}
        className={cn(
          'relative isolate w-full touch-pan-y overflow-hidden rounded-[calc(var(--radius)*2+2px)] select-none [-webkit-tap-highlight-color:transparent]',
          'cursor-grab data-dragging:cursor-grabbing',
          'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring',
        )}
      >
        {/* The pictures for screen readers, and the picture itself until WebGL draws or when it cannot. */}
        <div
          style={
            rig
              ? {
                  perspective: rig.dist * rig.ppu,
                  perspectiveOrigin: `50% ${(((1 - rig.shift) / 2) * 100).toFixed(2)}%`,
                  maskImage: `linear-gradient(to right, transparent, #000 ${rig.band}px, #000 calc(100% - ${rig.band}px), transparent)`,
                }
              : undefined
          }
          className={cn(
            'absolute inset-0 transition-opacity duration-300 ease-out-quint motion-reduce:transition-none',
            ready && 'pointer-events-none opacity-0',
          )}
        >
          {rig &&
            images.map((im, i) => {
              const on = i === current
              return (
                <div
                  key={i}
                  ref={(el) => {
                    cards.current[i] = el
                  }}
                  role="group"
                  aria-roledescription="slide"
                  aria-label={`${t.picture} ${i + 1} ${t.of} ${n}`}
                  aria-hidden={!on}
                  onClick={on || ready ? undefined : () => engine.go(i)}
                  style={{
                    width: rig.half[0] * 2 * rig.ppu,
                    height: rig.half[1] * 2 * rig.ppu,
                    marginLeft: -rig.half[0] * rig.ppu,
                    marginTop: -rig.half[1] * rig.ppu,
                  }}
                  className={cn('absolute top-1/2 left-1/2 overflow-hidden rounded-[calc(var(--radius)+2px)] bg-muted', !on && 'invisible cursor-pointer')}
                >
                  <img
                    inert={!on}
                    src={im.src}
                    alt={im.alt}
                    draggable={false}
                    decoding="async"
                    className="size-full object-cover [-webkit-user-drag:none]"
                  />
                  <span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)]" />
                  <span aria-hidden className="pointer-events-none absolute inset-0 bg-background opacity-0" />
                </div>
              )
            })}
        </div>
      </div>

      {controls && n > 1 ? (
        <div className="mx-auto mt-4 flex max-w-sm items-center gap-3">
          {/* aria-disabled, not disabled: a button that has focus keeps it when it reaches the end. */}
          <button type="button" aria-label={t.previous} aria-controls={id} aria-disabled={atStart} onClick={() => atStart || engine.move(-1)} className={button}>
            <ChevronLeft className="size-[18px]" strokeWidth={2} aria-hidden />
          </button>
          <div className="flex min-w-0 flex-1 flex-col gap-2">
            {/* A hairline rule with a primary tick, one picture's share of it long, that follows the position. In a
                loop a second tick trails one rule-length behind, so the tick slides off one end and on at the other. */}
            <div aria-hidden className="@container relative h-1 overflow-hidden rounded-full bg-muted">
              {(wraps ? [0, 1] : [0]).map((k) => (
                <span
                  key={k}
                  ref={(el) => {
                    ticks.current[k] = el
                  }}
                  style={{ width: `${100 / n}%` }}
                  className="absolute inset-y-0 left-0 block rounded-full bg-primary"
                />
              ))}
            </div>
            <p className="flex justify-between font-mono text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground tabular-nums">
              <span aria-hidden>{String(current + 1).padStart(2, '0')}</span>
              <span aria-hidden>{String(n).padStart(2, '0')}</span>
            </p>
          </div>
          <button type="button" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={button}>
            <ChevronRight className="size-[18px]" strokeWidth={2} aria-hidden />
          </button>
        </div>
      ) : null}
      <p aria-live="polite" className="sr-only">
        {`${t.picture} ${current + 1} ${t.of} ${n}`}
      </p>
    </div>
  )
}
use-carousel-engine.ts
import { useEffect, useLayoutEffect, useState, type MouseEvent, type PointerEvent } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'

export type CarouselEngineOptions = {
  /** How many slides there are. */
  count: number
  /** Wrap around from the last slide to the first. */
  loop?: boolean
  /** Controlled: the current slide. The carousel shows it, and goes back to it if `onIndexChange` does not update it. */
  index?: number
  /** The slide shown first, when uncontrolled. */
  defaultIndex?: number
  /** Called when the carousel heads for a new slide: on release, a key or a button, not when the glide ends. */
  onIndexChange?: (index: number) => void
  /** The drag direction. `x` also takes sideways wheel and trackpad swipes. */
  axis?: 'x' | 'y'
  /** Pixels of drag that move the carousel one slide. Negative turns the drag round, e.g. pull down for the next slide. */
  step: number
  /** Come to rest on a whole slide. Off, a fling glides to a stop anywhere. */
  snap?: boolean
  /** Draws a frame: the position in slides (fractional while moving) and the speed in slides per second. */
  onFrame: (position: number, velocity: number) => void
}

export type CarouselEngine = {
  /** The slide the carousel is on or heading to. */
  index: number
  /** Go to a slide. In a loop it takes the short way round. */
  go: (index: number) => void
  /** Move by a number of slides from where the carousel is heading. */
  move: (by: number) => void
  /** Draw the current frame again, e.g. after a resize. */
  draw: () => void
  /** Spread on the element that takes the drag. */
  bind: {
    ref: (el: HTMLElement | null) => void
    onPointerDown: (e: PointerEvent<HTMLElement>) => void
    onPointerMove: (e: PointerEvent<HTMLElement>) => void
    onPointerUp: (e: PointerEvent<HTMLElement>) => void
    onPointerCancel: (e: PointerEvent<HTMLElement>) => void
    onClickCapture: (e: MouseEvent<HTMLElement>) => void
  }
}

type Settings = Required<Pick<CarouselEngineOptions, 'loop' | 'axis' | 'snap' | 'step' | 'onFrame'>> &
  Pick<CarouselEngineOptions, 'onIndexChange'> & { n: number; reduce: boolean }

const SLOP = 6 // px before a press becomes a drag
const THROW = 0.32 // s: how far a fling carries, as speed × this
const SAMPLE = 90 // ms of pointer history used for the release speed
const mod = (v: number, n: number) => ((v % n) + n) % n
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
// Pixels per slide, kept away from zero but with its sign.
const per = (step: number) => (step < 0 ? -1 : 1) * Math.max(Math.abs(step), 1)
// Past an end the carousel gives less and less, and never more than one slide.
const rubber = (over: number) => 1 - 1 / (over * 0.55 + 1)

/**
 * The motion behind the carousels: one position, in slides, that drag, fling, wheel, keys and buttons all move.
 * It runs a frame loop only while something moves and hands each frame to `onFrame`, which writes transforms
 * straight to the DOM, so React renders only when the slide changes. Glides use ease-out-quint: 300 ms for a
 * step, and after a fling a duration that starts at the finger's speed. Under reduced motion every move jumps.
 */
export function useCarouselEngine(options: CarouselEngineOptions): CarouselEngine {
  const { count, loop = false, index, defaultIndex = 0, axis = 'x', snap = true, step, onFrame, onIndexChange } = options
  const n = Math.max(count, 1)
  const reduce = useReducedMotion()
  const [current, setCurrent] = useState(() => clamp(index ?? defaultIndex, 0, n - 1))
  const [m] = useState(() => machine(current, setCurrent))
  const [surface, setSurface] = useState<HTMLElement | null>(null)

  // The latest options, for the handlers and the frame loop, which outlive a render.
  useLayoutEffect(() => {
    m.configure({ n, loop, axis, snap, step, onFrame, onIndexChange, reduce })
  })

  // A new count, or a loop turned on or off, puts the carousel back on a slide it has.
  useEffect(() => m.reseat(), [n, loop, m])
  // Controlled: follow the index from outside, and go back to it when a move was not taken up. Neither calls
  // onIndexChange, as the parent already knows.
  const controlled = index !== undefined
  const target = clamp(index ?? 0, 0, n - 1)
  useEffect(() => {
    if (controlled && target !== m.committed) m.go(target, false)
  })

  useEffect(() => (surface && axis === 'x' ? m.listenWheel(surface) : undefined), [surface, axis, m])
  useEffect(() => m.stop, [m])

  return { index: controlled ? target : current, go: m.go, move: m.move, draw: m.draw, bind: { ref: setSurface, ...m.handlers } }
}

type Tween = { from: number; to: number; start: number; dur: number }
type Drag = { id: number; x: number; y: number; p0: number; active: boolean; samples: [number, number][]; resume: number | null }

// The state and behaviour, made once per carousel and kept for its lifetime.
function machine(start: number, setCurrent: (i: number) => void) {
  let want = start // where a drag or the wheel puts the carousel
  let drawn = start // the position on screen
  let velocity = 0
  let tween: Tween | null = null
  let drag: Drag | null = null
  let raf = 0
  let last = 0
  let swallowClick = false
  let wheelTimer = 0

  const m = {
    set: null as Settings | null,
    committed: start,
    configure: (settings: Settings) => {
      m.set = settings
    },
    go,
    move,
    reseat,
    draw,
    stop,
    listenWheel,
    handlers: {
      onPointerDown,
      onPointerMove,
      onPointerUp: (e: PointerEvent<HTMLElement>) => release(e, false),
      onPointerCancel: (e: PointerEvent<HTMLElement>) => release(e, true),
      onClickCapture,
    },
  }

  function draw() {
    m.set?.onFrame(drawn, velocity)
  }

  function tick(now: number) {
    raf = 0
    const o = m.set
    if (!o) return
    let p = want
    if (tween) {
      const u = clamp((now - tween.start) / tween.dur, 0, 1)
      p = tween.from + (tween.to - tween.from) * (1 - (1 - u) ** 5)
      if (u >= 1) tween = null
    }
    const dt = Math.max(now - last, 1) / 1000
    last = now
    // Smoothed over about 60 ms, so a layout can lean into the speed without jitter.
    velocity = o.reduce ? 0 : velocity + ((p - drawn) / dt - velocity) * (1 - Math.exp(-dt / 0.06))
    drawn = p
    const moving = tween || drag?.active || Math.abs(velocity) > 0.01
    if (!moving) velocity = 0
    o.onFrame(p, velocity)
    if (moving) raf = requestAnimationFrame(tick)
  }

  function run() {
    if (raf) return
    last = performance.now()
    raf = requestAnimationFrame(tick)
  }

  function commit(to: number, notify = true) {
    const o = m.set
    if (!o) return
    const i = mod(Math.round(to), o.n)
    if (i === m.committed) return
    m.committed = i
    setCurrent(i)
    if (notify) o.onIndexChange?.(i)
  }

  // Glide to a position. With a release speed (slides/s) in the same direction, the glide starts at that speed.
  function glide(to: number, speed = 0) {
    want = to
    const dist = to - drawn
    if (m.set?.reduce || Math.abs(dist) < 1e-4) tween = null
    else {
      const dur = speed && Math.sign(speed) === Math.sign(dist) ? clamp(((5 * Math.abs(dist)) / Math.abs(speed)) * 1000, 220, 900) : 300
      tween = { from: drawn, to, start: performance.now(), dur }
    }
    run()
  }

  function go(i: number, notify = true) {
    const o = m.set
    if (!o || mod(i, o.n) === m.committed) return
    let to: number
    if (o.loop) {
      const at = Math.round(tween?.to ?? want)
      to = at + mod(i - at + o.n / 2, o.n) - o.n / 2
      // An even count puts the far slide exactly half way round: go forwards.
      if (to < at && mod(i - at, o.n) === o.n / 2) to = at + o.n / 2
    } else to = clamp(i, 0, o.n - 1)
    glide(to)
    commit(to, notify)
  }

  // After the count or the loop changes: jump to the current slide, or the last one if it is gone. A position
  // wound past the ends of a loop means nothing in the new count, so it is unwound too.
  function reseat() {
    const o = m.set
    if (!o) return
    const i = clamp(m.committed, 0, o.n - 1)
    if (want === i && drawn === i && !tween) return
    tween = null
    velocity = 0
    want = drawn = i
    commit(i)
    draw()
  }

  function move(by: number) {
    const o = m.set
    if (!o) return
    let to = Math.round(tween?.to ?? want) + by
    if (!o.loop) to = clamp(to, 0, o.n - 1)
    glide(to)
    commit(to)
  }

  function stop() {
    cancelAnimationFrame(raf)
    clearTimeout(wheelTimer)
    raf = 0
    tween = null
    drag = null
    velocity = 0
    // Land where it was heading, so a remount starts settled.
    drawn = want
  }

  // Where a position lands once the ends of a carousel that does not loop push back.
  function give(raw: number) {
    const o = m.set!
    if (o.loop) return raw
    if (raw < 0) return -rubber(-raw)
    if (raw > o.n - 1) return o.n - 1 + rubber(raw - (o.n - 1))
    return raw
  }

  function settle(speed: number, from: number) {
    const o = m.set!
    let to = drawn + speed * THROW
    if (o.snap) {
      to = Math.round(to)
      // A quick flick always moves at least one slide.
      if (Math.abs(speed) > 0.8 && to === Math.round(from)) to += Math.sign(speed)
    }
    if (!o.loop) to = clamp(to, 0, o.n - 1)
    glide(to, speed)
    commit(to)
  }

  function onPointerDown(e: PointerEvent<HTMLElement>) {
    swallowClick = false
    // A press that left the carousel before it became a drag never got its pointerup.
    if (drag && !drag.active) drop(drag)
    if (!e.isPrimary || e.button !== 0 || drag || !m.set || m.set.n < 2) return
    const resume = tween ? tween.to : null
    // Catch a moving carousel where it is.
    if (tween) {
      tween = null
      want = drawn
    }
    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, p0: drawn, active: false, samples: [[e.timeStamp, drawn]], resume }
  }

  function onPointerMove(e: PointerEvent<HTMLElement>) {
    const d = drag
    const o = m.set
    if (!d || !o || e.pointerId !== d.id) return
    // The mouse came back with its button up, so it was let go outside the carousel.
    if (!d.active && !(e.buttons & 1)) return drop(d)
    const dx = e.clientX - d.x
    const dy = e.clientY - d.y
    const along = o.axis === 'x' ? dx : dy
    const across = o.axis === 'x' ? dy : dx
    if (!d.active) {
      if (Math.abs(along) < SLOP && Math.abs(across) < SLOP) return
      // Moving across the carousel: leave it to the page.
      if (Math.abs(across) >= Math.abs(along)) return drop(d)
      d.active = true
      // Count from here, so the slop does not make the slides jump.
      if (o.axis === 'x') d.x += Math.sign(dx) * SLOP
      else d.y += Math.sign(dy) * SLOP
      e.currentTarget.setPointerCapture(e.pointerId)
      e.currentTarget.dataset.dragging = ''
    }
    const p = give(d.p0 - (o.axis === 'x' ? e.clientX - d.x : e.clientY - d.y) / per(o.step))
    want = p
    d.samples.push([e.timeStamp, p])
    while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > SAMPLE) d.samples.shift()
    run()
  }

  function release(e: PointerEvent<HTMLElement>, cancelled: boolean) {
    const d = drag
    if (!d || e.pointerId !== d.id) return
    delete e.currentTarget.dataset.dragging
    if (!d.active) return drop(d)
    drag = null
    swallowClick = true
    let speed = 0
    const [t0, p0] = d.samples[0]
    const [t1, p1] = d.samples[d.samples.length - 1]
    // A finger that stopped before letting go throws nothing.
    if (!cancelled && t1 > t0 && e.timeStamp - t1 < 60) speed = ((p1 - p0) / (t1 - t0)) * 1000
    settle(speed, d.p0)
  }

  // Let go of a press that never became a drag, and finish any glide it caught.
  function drop(d: Drag) {
    drag = null
    if (d.resume !== null) glide(d.resume)
  }

  // A drag ends with a click on whatever was under the pointer; that click is not meant.
  function onClickCapture(e: MouseEvent<HTMLElement>) {
    if (!swallowClick) return
    swallowClick = false
    e.preventDefault()
    e.stopPropagation()
  }

  // Sideways wheel and trackpad swipes. A vertical wheel is left to the page.
  function listenWheel(el: HTMLElement) {
    let base: number | null = null
    const onWheel = (e: WheelEvent) => {
      const o = m.set
      const dx = e.deltaX || (e.shiftKey ? e.deltaY : 0)
      if (!o || o.n < 2 || Math.abs(dx) <= Math.abs(e.shiftKey ? 0 : e.deltaY)) return
      e.preventDefault()
      if (drag?.active) return
      tween = null
      base = (base ?? drawn) + (dx * (e.deltaMode === 1 ? 16 : 1)) / per(o.step)
      if (!o.loop) base = clamp(base, -1.5, o.n + 0.5)
      want = give(base)
      run()
      clearTimeout(wheelTimer)
      wheelTimer = window.setTimeout(() => {
        base = null
        settle(0, drawn)
      }, 140)
    }
    el.addEventListener('wheel', onWheel, { passive: false })
    return () => {
      el.removeEventListener('wheel', onWheel)
      clearTimeout(wheelTimer)
    }
  }

  return m
}
use-gl-stage.ts
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'

/** A picture for a WebGL carousel. The alt text is what screen readers read; the canvas itself is hidden from them. */
export type GlImage = { src: string; alt: string }

export type Rgb = [number, number, number]

export type GlSceneContext = {
  gl: WebGLRenderingContext
  canvas: HTMLCanvasElement
  /** Call once the scene has what it needs for its first frame, e.g. after its pictures load. The canvas fades in. */
  ready: () => void
  /** Draw again, e.g. once a texture has loaded. */
  redraw: () => void
  /** Call when the scene cannot run after all, e.g. its pictures could not be uploaded. The stage reports `failed`. */
  fail: () => void
  /** The theme's colours when the scene is built, in the order of `colors`, e.g. for a texture's empty cells. */
  colors: Rgb[]
  /** Set when the stage is torn down, so work that finishes later can stop. */
  signal: AbortSignal
}

export type GlScene = {
  /** Draws a frame. `time` is in seconds; it only moves while the stage animates on its own and stands still under reduced motion. */
  draw: (time: number) => void
  /** The canvas has a new size in device pixels. The viewport is already set. */
  resize?: (width: number, height: number, dpr: number) => void
  /** The theme's colours, in the order of `colors`, as 0–1 RGB. Called before the first frame and on every theme change. */
  theme?: (colors: Rgb[]) => void
  /** Frees what the scene made. The context is released right after. */
  dispose?: () => void
}

export type GlStageOptions = {
  /** Upper bound on device pixels per CSS pixel. */
  maxDpr?: number
  /** Upper bound on the canvas's device pixels; past it the canvas renders smaller and the browser scales it up. */
  maxPixels?: number
  /** A frame loop of its own while on screen, for motion that does not come from the carousel. Off under reduced motion. */
  animate?: boolean
  /** CSS colours the scene needs, e.g. `var(--background)`. Read from the host, so they follow the theme. */
  colors?: string[]
  /** Classes for the canvas, which goes first in the host. */
  className?: string
  /** Context attributes on top of the defaults (no antialias, low power). */
  attributes?: WebGLContextAttributes
}

export type GlStage = {
  /** `pending` until the scene is ready, `ready` once it has drawn, `failed` without WebGL or after the GPU drops the context. */
  status: 'pending' | 'ready' | 'failed'
  /** Draw a frame now, e.g. from a carousel's `onFrame`. Does nothing before the scene exists. */
  redraw: () => void
}

// Seconds of animation that a still frame shows under reduced motion, so it is not the plain first frame.
const STILL = 4

/**
 * Runs a WebGL scene in a canvas inside `host`: a fresh canvas each run, sized to the host with a cap on pixels,
 * the theme's colours read live, its own frame loop only while on screen, and the context released on teardown or
 * failure, so a page never holds more than it uses. `build` makes the scene; it runs again when `key` changes, so put
 * everything the scene is built from in it, e.g. the pictures' addresses. Return null from `build` when the scene cannot
 * run, and the stage reports `failed`.
 */
export function useGlStage(
  host: RefObject<HTMLElement | null>,
  build: (ctx: GlSceneContext) => GlScene | null,
  key: string,
  { maxDpr = 2, maxPixels = 2_400_000, animate = false, colors = [], className = '', attributes }: GlStageOptions = {},
): GlStage {
  const reduce = useReducedMotion()
  const [status, setStatus] = useState<GlStage['status']>('pending')
  // The live scene's draw, for redraw(); null between runs.
  const live = useRef<(() => void) | null>(null)
  const palette = colors.join('|')
  const attrs = JSON.stringify(attributes ?? {})
  // The latest build, so a new function each render does not rebuild the scene; `key` does.
  const make = useRef(build)
  useLayoutEffect(() => {
    make.current = build
  })

  useEffect(() => {
    const wrap = host.current
    if (!wrap) return
    const abort = new AbortController()
    // Reported after the effect, and only while this run is the live one.
    const report = (s: GlStage['status']) => queueMicrotask(() => abort.signal.aborted || setStatus(s))
    report('pending')
    // A fresh canvas per run: a canvas whose context was lost cannot hand out a new one.
    const el = document.createElement('canvas')
    el.setAttribute('aria-hidden', 'true')
    el.className = className
    wrap.prepend(el)

    const options: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: 'low-power', ...JSON.parse(attrs) }
    const gl = el.getContext('webgl', options)
    if (!gl) {
      el.remove()
      report('failed')
      return () => abort.abort()
    }

    let scene: GlScene | null = null
    let dead = false
    let shown = false
    let clock = STILL
    let raf = 0
    let last = 0
    let visible = false
    const looping = animate && !reduce

    const frame = () => {
      if (!dead && scene) scene.draw(clock)
    }
    let released = false
    const release = () => {
      if (released) return
      released = true
      dead = true
      live.current = null
      cancelAnimationFrame(raf)
      abort.abort()
      try {
        scene?.dispose?.()
      } catch {
        // The context may already be gone; there is nothing left to free.
      }
      gl.getExtension('WEBGL_lose_context')?.loseContext()
      el.remove()
    }

    // The theme's colours, read through a 1 px canvas so any CSS colour (oklch, color-mix, var) comes out as RGB.
    const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true })
    const wanted = palette ? palette.split('|') : []
    const read = () => {
      const style = getComputedStyle(wrap)
      return wanted.map((c): Rgb => {
        const value = c.startsWith('var(') ? style.getPropertyValue(c.slice(4, -1).split(',')[0].trim()).trim() || '#808080' : c
        if (!probe) return [0.5, 0.5, 0.5]
        probe.clearRect(0, 0, 1, 1)
        probe.fillStyle = '#808080'
        probe.fillStyle = value
        probe.fillRect(0, 0, 1, 1)
        const [r, g, b] = probe.getImageData(0, 0, 1, 1).data
        return [r / 255, g / 255, b / 255]
      })
    }

    const ctx: GlSceneContext = {
      gl,
      canvas: el,
      signal: abort.signal,
      colors: read(),
      fail: () => {
        if (dead) return
        release()
        queueMicrotask(() => setStatus('failed'))
      },
      redraw: () => {
        // A running loop draws on its next frame anyway.
        if (!(looping && visible)) frame()
      },
      ready: () => {
        if (dead || shown) return
        shown = true
        frame()
        // Wait a frame so the canvas fades in instead of popping.
        requestAnimationFrame(() => {
          if (dead) return
          el.dataset.ready = ''
          report('ready')
        })
      },
    }

    try {
      scene = make.current(ctx)
    } catch {
      scene = null
    }
    // Free the context straight away, so a scene that cannot run does not hold one of the page's few.
    if (!scene) {
      release()
      queueMicrotask(() => setStatus('failed'))
      return
    }
    live.current = ctx.redraw

    const theme = () => {
      if (dead || !scene?.theme || !wanted.length) return
      scene.theme(read())
      ctx.redraw()
    }
    theme()
    // The theme changes through a class or inline variables on <html>, or the system's colour scheme.
    const mo = new MutationObserver(theme)
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })
    const scheme = window.matchMedia('(prefers-color-scheme: dark)')
    scheme.addEventListener('change', theme)

    const resize = () => {
      if (dead) return
      const w = el.clientWidth
      const h = el.clientHeight
      let dpr = Math.min(maxDpr, window.devicePixelRatio || 1)
      if (w * h * dpr * dpr > maxPixels) dpr = Math.sqrt(maxPixels / Math.max(1, w * h))
      el.width = Math.max(1, Math.round(w * dpr))
      el.height = Math.max(1, Math.round(h * dpr))
      gl.viewport(0, 0, el.width, el.height)
      scene?.resize?.(el.width, el.height, dpr)
      frame()
    }
    const ro = new ResizeObserver(resize)
    ro.observe(el)
    resize()

    const loop = (now: number) => {
      // Cap the step so a stalled tab does not jump the motion ahead.
      clock += Math.min(50, now - (last || now)) / 1000
      last = now
      frame()
      raf = requestAnimationFrame(loop)
    }
    // Only animate while on screen.
    const io = new IntersectionObserver(([entry]) => {
      if (dead || !looping || entry.isIntersecting === visible) return
      visible = entry.isIntersecting
      last = 0
      if (visible) raf = requestAnimationFrame(loop)
      else cancelAnimationFrame(raf)
    })
    io.observe(el)

    // If the GPU drops the context, the component shows its fallback again.
    const lost = (e: Event) => {
      e.preventDefault()
      dead = true
      live.current = null
      cancelAnimationFrame(raf)
      delete el.dataset.ready
      report('failed')
    }
    el.addEventListener('webglcontextlost', lost)

    return () => {
      ro.disconnect()
      io.disconnect()
      mo.disconnect()
      scheme.removeEventListener('change', theme)
      el.removeEventListener('webglcontextlost', lost)
      release()
    }
  }, [host, key, reduce, animate, maxDpr, maxPixels, palette, className, attrs])

  const redraw = useCallback(() => live.current?.(), [])
  return { status, redraw }
}

/**
 * Compiles and links a program, or returns null so the caller can fall back; the reason goes to the console unless the
 * context was lost. A uniform used in both shaders must have the same precision in each, or linking fails.
 */
export function glProgram(gl: WebGLRenderingContext, vertex: string, fragment: string) {
  const program = gl.createProgram()
  if (!program) return null
  const log: string[] = []
  for (const [type, src] of [
    [gl.VERTEX_SHADER, vertex],
    [gl.FRAGMENT_SHADER, fragment],
  ] as const) {
    const s = gl.createShader(type)
    if (!s) {
      gl.deleteProgram(program)
      return null
    }
    gl.shaderSource(s, src)
    gl.compileShader(s)
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) log.push(gl.getShaderInfoLog(s) ?? '')
    gl.attachShader(program, s)
    // Flagged for deletion; it lives until the program goes.
    gl.deleteShader(s)
  }
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    if (!gl.isContextLost()) console.warn('WebGL program failed:', log.join('\n') || gl.getProgramInfoLog(program))
    gl.deleteProgram(program)
    return null
  }
  return program
}

export type GlAtlas = {
  /** The texture, power-of-two sized, with mipmaps. */
  texture: WebGLTexture
  /** Each picture's corner in the atlas as [u0, v0, u1, v1], with v growing downwards (the texture is not flipped). */
  rects: [number, number, number, number][]
  /** False for a picture that did not load; its cell shows `empty` instead. */
  loaded: boolean[]
  /** A cell's width and height in texels, without its gutter. */
  cell: [number, number]
  /** The texture's width and height in texels. */
  size: [number, number]
}

const pow2 = (v: number) => 2 ** Math.ceil(Math.log2(Math.max(1, v)))

function load(src: string, signal: AbortSignal) {
  return new Promise<HTMLImageElement | null>((resolve) => {
    const img = new Image()
    // A picture from another origin must send CORS headers to be drawn into WebGL; without them it shows as empty.
    if (!/^(data|blob):/.test(src)) img.crossOrigin = 'anonymous'
    img.decoding = 'async'
    img.onload = () => resolve(img)
    img.onerror = () => resolve(null)
    signal.addEventListener('abort', () => resolve(null), { once: true })
    img.src = src
  })
}

export type GlAtlasOptions = {
  /** The pictures' width over height; each is cropped from the middle to fill a cell of this shape. */
  aspect: number
  /** The longer side of a cell in texels, at most. Cells shrink to keep the texture within `limit`. */
  cell?: number
  /** The texture's largest side in texels. A 2048 atlas with mipmaps takes about 22 MB of GPU memory, a 4096 one four times that. */
  limit?: number
  /** The colour of a cell whose picture did not load. */
  empty?: Rgb
  signal: AbortSignal
}

/**
 * Loads pictures and paints them, cropped to fill a cell of `aspect` (width over height), into one texture. Each cell
 * has a gutter of its own edge pixels, so mipmaps and filtering never bleed one picture into the next. Resolves to
 * null if the stage was torn down meanwhile, or if the pictures could not be uploaded.
 */
export async function glAtlas(
  gl: WebGLRenderingContext,
  images: GlImage[],
  { aspect, cell = 1024, limit = 2048, empty = [0.5, 0.5, 0.5], signal }: GlAtlasOptions,
): Promise<GlAtlas | null> {
  const n = Math.max(images.length, 1)
  const cols = Math.ceil(Math.sqrt(n * aspect) / aspect) || 1
  const rows = Math.ceil(n / cols)
  const gutter = 4
  // The largest cell in the picture's shape that fits the grid within the limit; the limit is a power of two, so the
  // texture never rounds up past it.
  const max = Math.min(pow2(limit), gl.getParameter(gl.MAX_TEXTURE_SIZE) as number)
  const w = aspect >= 1 ? 1 : aspect
  const h = aspect >= 1 ? 1 / aspect : 1
  const size = Math.max(16, Math.floor(Math.min(cell, (max / cols - gutter * 2) / w, (max / rows - gutter * 2) / h)))
  const cw = Math.max(1, Math.floor(size * w))
  const ch = Math.max(1, Math.floor(size * h))
  const W = pow2((cw + gutter * 2) * cols)
  const H = pow2((ch + gutter * 2) * rows)

  const pictures = await Promise.all(images.map((im) => load(im.src, signal)))
  if (signal.aborted) return null

  const canvas = document.createElement('canvas')
  canvas.width = W
  canvas.height = H
  const c = canvas.getContext('2d')
  if (!c) return null
  c.imageSmoothingQuality = 'high'
  const fill = `rgb(${empty.map((v) => Math.round(v * 255)).join(' ')})`
  const rects: GlAtlas['rects'] = []
  const loaded: boolean[] = []

  pictures.forEach((img, i) => {
    const x = (i % cols) * (cw + gutter * 2) + gutter
    const y = Math.floor(i / cols) * (ch + gutter * 2) + gutter
    rects.push([x / W, y / H, (x + cw) / W, (y + ch) / H])
    const ok = !!img && img.naturalWidth > 0
    loaded.push(ok)
    if (!ok) {
      c.fillStyle = fill
      c.fillRect(x - gutter, y - gutter, cw + gutter * 2, ch + gutter * 2)
      return
    }
    // Crop to fill the cell, from the middle.
    const k = Math.max(cw / img.naturalWidth, ch / img.naturalHeight)
    const sw = cw / k
    const sh = ch / k
    const sx = (img.naturalWidth - sw) / 2
    const sy = (img.naturalHeight - sh) / 2
    c.drawImage(img, sx, sy, sw, sh, x, y, cw, ch)
    // The gutter repeats the cell's outermost pixels.
    c.drawImage(canvas, x, y, cw, 1, x, y - gutter, cw, gutter)
    c.drawImage(canvas, x, y + ch - 1, cw, 1, x, y + ch, cw, gutter)
    c.drawImage(canvas, x, y - gutter, 1, ch + gutter * 2, x - gutter, y - gutter, gutter, ch + gutter * 2)
    c.drawImage(canvas, x + cw - 1, y - gutter, 1, ch + gutter * 2, x + cw, y - gutter, gutter, ch + gutter * 2)
  })

  const texture = gl.createTexture()
  if (!texture || gl.isContextLost()) return null
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
  try {
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas)
  } catch {
    // A picture tainted the canvas; nothing can be uploaded.
    gl.deleteTexture(texture)
    return null
  }
  gl.generateMipmap(gl.TEXTURE_2D)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
  if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
  return { texture, rects, loaded, cell: [cw, ch], size: [W, H] }
}
use-reduced-motion.ts
import { useSyncExternalStore } from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/wave-ribbon.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/wave-ribbon

Abhängigkeiten

Dateien

  • wave-ribbon.tsx
  • use-carousel-engine.ts
  • use-gl-stage.ts
  • use-reduced-motion.ts

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.