Wave ribbon
widgetsGratisMITBilleder på et langt bånd, der bølger blidt i dybden; naboerne forsvinder i tåge i temaets baggrund, og et hurtigt træk gør bølgen lidt dybere. WebGL med CSS 3D-reserve.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/wave-ribbon.jsonPreview
Live demo i det mørke tema.
Kode
Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/wave-ribbon.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/wave-ribbonBrug
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>
)
}Kilde
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>
)
}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
}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] }
}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,
)
}Props
WaveRibbonProps
| Prop | Type | Standard | Hvad den gør |
|---|---|---|---|
images påkrævet | GlImage[] | – | The pictures, each with alt text. Pictures from another origin must send CORS headers to be drawn into WebGL. |
label påkrævet | string | – | Read by screen readers, e.g. “Landscapes”. |
index | number | – | Controlled: the picture at the front. |
defaultIndex | number | – | The picture at the front at first, when uncontrolled. |
onIndexChange | (index: number) => void | – | Called when the ribbon heads for a new picture: on release, a key, a button or a click. |
loop | boolean | false | Wrap round from the last picture to the first. Needs three pictures or more. |
aspect | number | 4 / 5 | Picture width over height. |
amplitude | number | 1 | How deep the wave runs; 0 lays the pictures out in a flat row. |
fog | number | 0.6 | How much the pictures away from the front fade into the page, 0–1. |
animate | boolean | false | A very slow drift in the wave while the ribbon is at rest. Off under reduced motion and off screen. |
controls | boolean | true | Show the previous and next buttons and the position rule. |
labels | Partial<Record<'previous' | 'next' | 'picture' | 'of', string>> | {} | The words on the buttons and for screen readers, to translate them, e.g. { picture: 'Bild', of: 'von' }. |
Tager også alle props fra Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>.
A11y
Tastatur
The stage is one Tab stop. ArrowLeft and ArrowRight move between pictures, Home and End jump to the ends. loop needs at least three pictures, and controls shows the buttons.
Skærmlæser
The pictures are real images with alt text. Each slide is labelled "Picture i of n"; the others are aria-hidden and inert. A polite live region reads the position. The words for previous, next, picture and of can be translated through labels.
Mindre bevægelse
Under reduced motion every move jumps and the ribbon holds still. The animate prop adds a slow drift that is off under reduced motion and off screen. Without WebGL the pictures stand flat on the wave in CSS 3D.