Picture Wave
Picture cards fly in as a wave between two words, hold, then burst into a floating field that leans away from the pointer, drawn in one call and idle when still. Raw WebGL 2, no dependencies.
No dependencies
Seen inBetween Rivers
BetweenRivers
0px
Customize
Lean1.0×
Install
Terminal
$
npx shadcn@latest add @mewo/picture-waveNot using the registry directory?
Point the CLI straight at the item URL:
npx shadcn@latest add https://www.mewo-ui.com/r/picture-wave.json
Props
| Prop | Type | Default | Description |
|---|---|---|---|
images | { src: string; aspect: number }[] | 72 picsum photos | The pictures on the cards, with their proportions (width ÷ height), served with CORS; each depth layer of the field cycles through them in its own order (up to 100). |
words | string[] | ["Between", "Rivers"] | The two words riding the wave's ends; an empty list leaves them out. |
state | "wave" | "field" | "field" | "wave": the cards fly in and hold one period of a sine between the words. "field": the wave then bursts into a field over the whole box. Back to "wave", it gathers again. |
orientation | "auto" | "horizontal" | "vertical" | "auto" | The wave's long axis. "auto" lays it across, and stands it up in a box narrower than 900px, as on a phone. |
lean | number | 1 | How far the field's cards lean away from the pointer, the nearer ones more: 0 holds them still. |
float | boolean | true | The field's slow idle drift. Off, nothing moves once the cards settle and the pointer rests. |
onPick | (index: number) => void | — | Called with a picture's index in images when its card in the field is clicked. |
controlsRef | Ref<{ replay() }> | — | replay() flies the wave in again. |
dpr | number | — | Overrides the device pixel ratio, which is capped at 2. |
className | string | — | Merged onto the root, which fills its parent: give the parent a size. |
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export type PictureWaveState = "wave" | "field"
export type PictureWaveOrientation = "auto" | "horizontal" | "vertical"
export interface PictureWaveImage {
/** The picture's URL, served with CORS. */
src: string
/** Its width ÷ height. A card takes these proportions between 0.6 and 1.7 and crops the picture beyond them. */
aspect: number
}
export interface PictureWaveControls {
/** Flies the wave in again from nothing. */
replay: () => void
}
export interface PictureWaveProps extends Omit<React.ComponentProps<"div">, "children"> {
/** The pictures on the cards; each depth layer of the field cycles through them in its own order (up to 100). */
images?: PictureWaveImage[]
/** The two words riding the wave's ends; an empty list leaves them out. */
words?: string[]
/** `"wave"`: the cards fly in and hold one period of a sine between the words. `"field"`: the wave then bursts
* into a field over the whole box that floats and leans away from the pointer. Back to `"wave"`, it gathers again. */
state?: PictureWaveState
/** The wave's long axis. `"auto"` lays it across, and stands it up in a box narrower than 900px, as on a phone. */
orientation?: PictureWaveOrientation
/** How far the field's cards lean away from the pointer, the nearer ones more: 0 holds them still. */
lean?: number
/** The field's slow idle drift. Off, nothing moves once the cards settle and the pointer rests. */
float?: boolean
/** Called with a picture's index in `images` when its card in the field is clicked. */
onPick?: (index: number) => void
/** `replay()`, to fly the wave in again. */
controlsRef?: React.Ref<PictureWaveControls>
/** Override the device pixel ratio, which is capped at 2. */
dpr?: number
}
type Settings = Required<Pick<PictureWaveProps, "images" | "state" | "orientation" | "lean" | "float">> & Pick<PictureWaveProps, "onPick" | "dpr">
type World = "horizontal" | "vertical"
type Keys = readonly (readonly [number, number])[]
// the default pictures' proportions: the field's layout is seeded from them
const ASPECTS = [
1, 1.286, 0.783, 0.75, 0.75, 0.75, 0.729, 0.639, 0.678, 1.27, 0.896, 1.13, 1.399, 0.834, 1.43, 0.531, 0.781, 0.803,
0.564, 0.766, 1.047, 0.672, 0.682, 0.787, 1.258, 0.51, 0.688, 1.182, 1.442, 0.656, 1.249, 2.306, 1.326, 1.249, 1.135,
0.75, 2.265, 1.306, 1.889, 0.971, 0.748, 0.49, 1.249, 0.748, 1.488, 0.678, 0.748, 0.75, 0.826, 0.814, 0.719, 1.384,
1.058, 2.317, 1.59, 1.501, 1.506, 1.501, 1.497, 1.506, 1.506, 1.369, 1.323, 1.506, 1.008, 1.556, 0.756, 0.805, 1.299,
1.358, 0.734, 1.333,
]
const PICTURES: PictureWaveImage[] = ASPECTS.map((aspect, i) => ({
src: `https://picsum.photos/seed/mewo-picture-wave-${i + 1}/${Math.round(300 * aspect)}/300`,
aspect,
}))
const WORDS = ["Between", "Rivers"]
// The sequence, in seconds from the first card: the wave grows in about its centre, holds (where "wave" rests),
// the words dim and go, the cards burst into the field and land at TIME.field.
const TIME = { wordsOn: 0.14, wave: 1.88, wordsDim: 1.96, wordsOff: 2.18, field: 3.83 }
// the wave's scale against time
const GROW: Keys = [[0, 0], [0.087, 0.05], [0.22, 0.124], [0.353, 0.273], [0.474, 0.471], [0.6, 0.703], [0.724, 0.843], [0.858, 0.942], [0.99, 0.983], [1.13, 1]]
// the field's drift and lean ease in over SETTLE_IN once it lands, and out over SETTLE_OUT before it gathers again
const SETTLE_IN = 1.5
const SETTLE_OUT = 0.5
// the pointer is followed with this time constant (s)
const LEAN_EASE = 0.35
const PIXEL_BUDGET = 4.5e6
const CELL = 256
const GROUND = [217, 212, 203] as const
// Every length is in heights of the box from its centre, y down. The wave: y = A sin(2π (x + span) / period + phase)
// along its long axis, from −span to span about its centre; gap runs from each end to the inner edge of its word.
type Ribbon = { axis: 0 | 1; centre: [number, number]; span: number; period: number; amp: number; phase: number; cardH: number; gap: [number, number] }
const RIBBON: Record<World, Ribbon> = {
horizontal: { axis: 0, centre: [0.1235, 0.002], span: 0.4035, period: 0.756, amp: -0.154, phase: -0.17, cardH: 0.105, gap: [0.053, 0.042] },
vertical: { axis: 1, centre: [-0.001, -0.0085], span: 0.2695, period: 0.506, amp: -0.076, phase: -0.18, cardH: 0.075, gap: [0.02, 0.026] },
}
// The burst: each card leaves its place in the wave for its place in the field, passing towards the viewer on the
// way (it grows and moves out from the centre by `push` at mid-flight, and rises by `drop`). `order`: which cards
// leave first (0 the start end, 1 the far end, 2 both ends, 3 the middle), over `spread` seconds; `ghost`: when the
// field's other cards fade in.
type Burst = { size: number; start: number; dur: number; spread: number; order: number; push: number; drop: number; power: number; ghost: [number, number] }
const BURST: Record<World, Burst> = {
horizontal: { size: 0.93, start: 2.018, dur: 1.347, spread: 0.452, order: 1, push: 1.096, drop: -0.3, power: 1.89, ghost: [2.85, 3.2] },
vertical: { size: 0.9, start: 1.951, dur: 1.12, spread: 0.402, order: 3, push: 1.724, drop: -0.255, power: 1.613, ghost: [2.73, 3.18] },
}
// The field: three depth layers (near pictures, mid pictures, pale far cards) spread evenly (Poisson-disc, no two in
// a layer overlapping) over `span` × `period`, which wraps vertically; the layers' parallax speeds shape the band the
// pictures keep clear of above the centre. Cards are drawn at `size` of their base size.
type Layer = { speed: number; h: [number, number]; gap: number; cls: 0 | 1; depth: number }
type Field = { span: number; period: number; size: number; layers: Layer[] }
const FIELD: Record<World, Field> = {
horizontal: {
span: 3.2, period: 3.0, size: 0.75,
layers: [
{ speed: 0.708, h: [0.19, 0.27], gap: 0.42, cls: 0, depth: 1 },
{ speed: 0.637, h: [0.15, 0.21], gap: 0.36, cls: 0, depth: 0.7 },
{ speed: 0.566, h: [0.13, 0.2], gap: 0.32, cls: 1, depth: 0.45 },
],
},
vertical: {
span: 1.5, period: 2.6, size: 0.75,
layers: [
{ speed: 0.986, h: [0.15, 0.21], gap: 0.36, cls: 0, depth: 1 },
{ speed: 0.867, h: [0.12, 0.18], gap: 0.31, cls: 0, depth: 0.7 },
{ speed: 0.789, h: [0.11, 0.16], gap: 0.28, cls: 1, depth: 0.45 },
],
},
}
// Across, pictures keep their inner edge `column` from the centre and out of a headline's box above the centre
// (half-width x, half-height h plus the card's, rising from y0 by `rise` per step of its layer's speed until
// `until`), so a title over the field stays clear. Pale cards may pass behind; standing up, the text fills the width.
const CLEAR = { column: 0.34, hero: { x: 0.52, h: 0.18, y0: -0.095, rise: 0.749, until: 0.95 } }
const clamp01 = (x: number) => Math.min(1, Math.max(0, x))
const easeInOut = (x: number, p: number) => (x < 0.5 ? (2 * x) ** p / 2 : 1 - (2 - 2 * x) ** p / 2)
const wrap = (y: number, p: number) => (((y % p) + p) % p) - p / 2
// the field's offsets from the centre, pulled in a little, and less as the headline's box rises
const pushIn = (v: number) => Math.exp(-0.16 * (1 - clamp01(v)) ** 2)
const PUSH = pushIn(0)
// cards in the middle of the wave sit further back than its ends (smaller, drawn behind)
const deckDepth = (u: number) => 0.65 + 0.35 * Math.abs(2 * u - 1)
const deckAspect = (a: number) => Math.min(1.5, Math.max(0.6, a))
const delayOf = (order: number, u: number) => [u, 1 - u, 1 - Math.abs(2 * u - 1), Math.abs(2 * u - 1)][order]!
const rng = (seed: number) => () => {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
/** A keyframed value at k, linear between keys and held past either end. */
export function valueAt(keys: Keys, k: number) {
if (k <= keys[0]![0]) return keys[0]![1]
for (let i = 1; i < keys.length; i++) {
const [b, vb] = keys[i]!
if (k <= b) {
const [a, va] = keys[i - 1]!
return va + ((vb - va) * (k - a)) / (b - a)
}
}
return keys[keys.length - 1]![1]
}
/** The wave's scale at time t: 0 before the first card, 1 once it has arrived. */
export const waveScale = (t: number) => valueAt(GROW, t)
/** The words' look at time t: hidden, shown, or dimmed just before the burst. */
export function wordsAt(t: number): "off" | "on" | "dim" {
if (t >= TIME.wordsOn && t < TIME.wordsDim) return "on"
return t >= TIME.wordsDim && t < TIME.wordsOff ? "dim" : "off"
}
/** A picture card's proportions: its picture's, between 0.6 and 1.7. */
export const pictureAspect = (a: number) => Math.min(1.7, Math.max(0.6, a))
export type FieldCard = {
/** 0 a picture, 1 a pale card */
cls: 0 | 1
aspect: number
/** the picture it shows (an index into the pictures), or −1 */
img: number
layer: number
x0: number
y0: number
w: number
h: number
phase: number
/** where it rests in the field: x, y, w, h */
at0: [number, number, number, number]
}
function clearOfText(world: World, ly: Layer, x0: number, y0: number, w: number, h: number) {
if (world !== "horizontal" || ly.cls !== 0) return true
const F = FIELD[world]
const H = CLEAR.hero
if (Math.abs(x0) * PUSH - w / 2 < CLEAR.column) return false
for (let v = 0; v <= H.until + 1e-6; v += 0.05) {
const g = pushIn(v)
if (Math.abs(x0) * g - w / 2 >= H.x) continue
const y = wrap(y0 - ly.speed * v, F.period) * g
if (Math.abs(y - (H.y0 - H.rise * v)) < h / 2 + H.h) return false
}
return true
}
/** The field's cards for a world, seeded, from the pictures' proportions. */
export function buildField(world: World, seed: number, pictures: number[]): FieldCard[] {
const F = FIELD[world]
const r = rng(seed)
const cards: FieldCard[] = []
for (const [li, ly] of F.layers.entries()) {
// darts on a torus in y (the layer wraps); each draws its size first, so the text test sees the card's edges
const pts: [number, number, number, number, number][] = []
const order = pictures.map((_, i) => i)
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(r() * (i + 1))
;[order[i], order[j]] = [order[j]!, order[i]!]
}
for (let tries = 0; tries < 6000; tries++) {
const x = (r() - 0.5) * F.span
const y = r() * F.period
const h = ly.h[0] + r() * (ly.h[1] - ly.h[0])
const img = ly.cls === 0 && order.length ? order[pts.length % order.length]! : -1
const aspect = img >= 0 ? pictureAspect(pictures[img]!) : 0.75 + r() * 0.5
if (!clearOfText(world, ly, x, y, h * aspect * F.size, h * F.size)) continue
let ok = true
for (const [px, py] of pts) {
const dy = Math.min(Math.abs(py - y), F.period - Math.abs(py - y))
if (Math.hypot(px - x, dy) < ly.gap) {
ok = false
break
}
}
if (ok) pts.push([x, y, h, aspect, img])
}
for (const [x0, y0, h, aspect, img] of pts) {
const w = h * aspect
const card: FieldCard = { cls: ly.cls, aspect, img, layer: li, x0, y0, w, h, phase: r() * Math.PI * 2, at0: [0, 0, 0, 0] }
const at = [0, 0, 0, 0]
fieldAt(world, card, 0, 0, 0, 0, at)
card.at0 = [at[0]!, at[1]!, at[2]!, at[3]!]
cards.push(card)
}
}
return cards
}
// a card in the field: [x, y, w, h] into out. float (0 to 1) scales its idle drift; lean is the pointer's pull
function fieldAt(world: World, c: FieldCard, time: number, float: number, leanX: number, leanY: number, out: number[]) {
const F = FIELD[world]
const depth = F.layers[c.layer]!.depth
const fx = Math.sin(time * 0.4 + c.phase) * 0.004 * float - leanX * 0.022 * depth
const fy = Math.cos(time * 0.33 + c.phase * 1.3) * 0.006 * float - leanY * 0.022 * depth
out[0] = c.x0 * PUSH + fx
out[1] = wrap(c.y0, F.period) * PUSH + fy
out[2] = c.w * F.size
out[3] = c.h * F.size
return out
}
/** The point u (0 to 1) along the wave at full size, from its centre: [x, y] into out. */
export function wavePoint(world: World, u: number, out: number[]) {
const R = RIBBON[world]
const a = -R.span + 2 * R.span * u
const across = R.amp * Math.sin((2 * Math.PI * (a + R.span)) / R.period + R.phase)
out[R.axis] = a
out[1 - R.axis] = across
return out
}
const point = [0, 0]
// a card of the wave at time t, u along it, the wave at scale s: [x, y, w, h] into out; false while hidden
function burstAt(world: World, t: number, u: number, s: number, c: FieldCard, out: number[]) {
if (t < 0 || s <= 0.001) return false
const P = BURST[world]
const R = RIBBON[world]
const h0 = R.cardH * P.size * deckDepth(u) * s
const w0 = h0 * deckAspect(c.aspect)
wavePoint(world, u, point)
const ax = R.centre[0] + point[0]! * s
const ay = R.centre[1] + point[1]! * s
const p = clamp01((t - P.start - P.spread * delayOf(P.order, u)) / P.dur)
if (p <= 0) {
out[0] = ax
out[1] = ay
out[2] = w0
out[3] = h0
return true
}
const e = easeInOut(p, P.power)
const arc = Math.sin(Math.PI * e)
const [fx, fy, fw, fh] = c.at0
const m = 1 + P.push * arc
out[0] = (ax + (fx - ax) * e) * m
out[1] = (ay + (fy - ay) * e) * m + P.drop * arc
out[2] = (w0 + (fw - w0) * e) * m
out[3] = (h0 + (fh - h0) * e) * m
return true
}
type Scene = { cards: FieldCard[]; slot: Int32Array; deck: number; order: number[] }
// the wave: the pictures resting near the middle band, in order along its long axis; draw order: far layers first,
// and in the wave its middle behind its ends
function buildScene(world: World, pictures: number[]): Scene {
const cards = buildField(world, world === "horizontal" ? 21 : 23, pictures)
const axis = RIBBON[world].axis
const deck = cards
.map((c, i) => [i, c.at0[axis]] as const)
.filter(([i]) => cards[i]!.cls === 0 && Math.abs(cards[i]!.at0[1]) < 1.3)
.sort((a, b) => a[1] - b[1])
.map(([i]) => i)
const slot = new Int32Array(cards.length).fill(-1)
deck.forEach((i, k) => (slot[i] = k))
const rank = (i: number) => (2 - cards[i]!.layer) * 10 + (slot[i]! < 0 ? 0 : Math.abs((2 * slot[i]!) / Math.max(1, deck.length - 1) - 1))
const order = cards.map((_, i) => i).sort((a, b) => rank(a) - rank(b))
return { cards, slot, deck: deck.length, order }
}
const VERTEX = `#version 300 es
in vec4 aBox;
in vec4 aUv;
in vec2 aStyle;
uniform vec2 uView;
out vec2 vUv;
out vec2 vStyle;
void main() {
vec2 q = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vUv = aUv.xy + q * aUv.zw;
vStyle = aStyle;
vec2 p = aBox.xy + (q - 0.5) * aBox.zw;
gl_Position = vec4(p.x / uView.x * 2.0 - 1.0, 1.0 - p.y / uView.y * 2.0, 0.0, 1.0);
}`
// kind 0 samples the atlas, kind 1 is a pale card in the theme's colour
const FRAGMENT = `#version 300 es
precision mediump float;
uniform sampler2D uMap;
uniform vec3 uPale;
in vec2 vUv;
in vec2 vStyle;
out vec4 outColor;
void main() {
vec4 tex = texture(uMap, vUv);
vec4 c = vStyle.x < 0.5 ? tex : vec4(uPale, 1.0);
float a = c.a * vStyle.y;
if (a < 0.003) discard;
outColor = vec4(c.rgb * a, a);
}`
const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader
gl.deleteShader(shader)
return null
}
function setRef<T>(ref: React.Ref<T> | undefined, value: T) {
if (typeof ref === "function") ref(value)
else if (ref) ref.current = value
}
const WORD =
"absolute top-0 left-0 whitespace-nowrap text-foreground/40 opacity-0 transition-[opacity,color] duration-450 ease-[ease] data-[state=dim]:text-foreground/30 data-[state=dim]:opacity-100 data-[state=dim]:duration-250 data-[state=on]:text-foreground data-[state=on]:opacity-100 motion-reduce:transition-none"
/**
* Picture cards fly in as one period of a sine between two words, hold, then burst into a field spread over the
* whole box: three depth layers that drift slowly and lean away from the pointer, the nearer ones more. One WebGL 2
* draw call from one atlas; it stops drawing once nothing moves, and never draws off screen. Fills its parent.
*/
export function PictureWave({
images = PICTURES,
words = WORDS,
state = "field",
orientation = "auto",
lean = 1,
float = true,
onPick,
controlsRef,
dpr,
className,
ref,
...props
}: PictureWaveProps) {
const rootRef = React.useRef<HTMLDivElement>(null)
const wordsRef = React.useRef<HTMLParagraphElement>(null)
const wordARef = React.useRef<HTMLSpanElement>(null)
const wordBRef = React.useRef<HTMLSpanElement>(null)
const settings: Settings = { images, state, orientation, lean, float, onPick, dpr }
const settingsRef = React.useRef(settings)
const wakeRef = React.useRef<(() => void) | null>(null)
const replayRef = React.useRef<(() => void) | null>(null)
React.useImperativeHandle(controlsRef, () => ({ replay: () => replayRef.current?.() }), [])
React.useEffect(() => {
const root = rootRef.current
if (!root) return
const canvas = document.createElement("canvas")
canvas.className = "absolute inset-0 block h-full w-full"
canvas.setAttribute("aria-hidden", "true")
const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: true, depth: false, stencil: false })
if (!gl) return
root.appendChild(canvas)
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)
const program = gl.createProgram()
if (!vertex || !fragment || !program) return
gl.attachShader(program, vertex)
gl.attachShader(program, fragment)
gl.linkProgram(program)
gl.deleteShader(vertex)
gl.deleteShader(fragment)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return
const uView = gl.getUniformLocation(program, "uView")
const uPale = gl.getUniformLocation(program, "uPale")
const uMap = gl.getUniformLocation(program, "uMap")
// one instance buffer: box (centre x, y and size in pixels), atlas rectangle, style (kind, alpha)
const STRIDE = 10
const vao = gl.createVertexArray()
const buffer = gl.createBuffer()
gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
const attrib = (name: string, size: number, offset: number) => {
const loc = gl.getAttribLocation(program, name)
if (loc < 0) return
gl.enableVertexAttribArray(loc)
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, STRIDE * 4, offset * 4)
gl.vertexAttribDivisor(loc, 1)
}
attrib("aBox", 4, 0)
attrib("aUv", 4, 4)
attrib("aStyle", 2, 8)
gl.bindVertexArray(null)
// The atlas: one square cell per picture, the card's box centred in it at the card's proportions. Each picture
// is cropped to fill that box, and the rest of its cell holds the same picture stretched, so the small mipmap
// levels don't blend a neighbour into the card's edges. A quiet ground shows until a picture arrives; each one
// goes up into its own cell, and the mipmaps are rebuilt at most once a frame.
const texture = gl.createTexture()
const anisotropy = gl.getExtension("EXT_texture_filter_anisotropic")
let grid = 1
let boxes: [number, number, number, number][] = []
let mipsDirty = true
let loads: HTMLImageElement[] = []
const buildAtlas = (pictures: PictureWaveImage[]) => {
for (const img of loads) img.onload = img.onerror = null
loads = []
grid = Math.max(1, Math.ceil(Math.sqrt(pictures.length)))
const size = grid * CELL
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
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)
if (anisotropy) gl.texParameterf(gl.TEXTURE_2D, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, 4)
const fbo = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
gl.clearColor(GROUND[0] / 255, GROUND[1] / 255, GROUND[2] / 255, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.deleteFramebuffer(fbo)
mipsDirty = true
boxes = pictures.map((p, i) => {
const a = pictureAspect(p.aspect)
const w = a >= 1 ? CELL - 2 : Math.round((CELL - 2) * a)
const h = a >= 1 ? Math.round((CELL - 2) / a) : CELL - 2
const col = i % grid
const row = Math.floor(i / grid)
const x = (CELL - w) / 2
const y = (CELL - h) / 2
const img = new Image()
img.crossOrigin = "anonymous"
img.decoding = "async"
img.onload = () => {
const iw = img.naturalWidth
const ih = img.naturalHeight
if (!iw || !ih) return
const cell = document.createElement("canvas")
cell.width = cell.height = CELL
const g = cell.getContext("2d")
if (!g) return
g.fillStyle = `rgb(${GROUND.join(",")})`
g.fillRect(0, 0, CELL, CELL)
g.drawImage(img, 0, 0, CELL, CELL)
const s = Math.max(w / iw, h / ih)
g.drawImage(img, (iw - w / s) / 2, (ih - h / s) / 2, w / s, h / s, x, y, w, h)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
try {
gl.texSubImage2D(gl.TEXTURE_2D, 0, col * CELL, row * CELL, gl.RGBA, gl.UNSIGNED_BYTE, cell)
} catch {
// a picture served without CORS can't go up: its card keeps the ground
return
}
mipsDirty = true
wake()
}
img.src = p.src
loads.push(img)
// UVs: v counts from the top (no flip)
return [(col * CELL + x) / size, (row * CELL + y) / size, w / size, h / size]
})
}
// the pale cards take a whisper of the ink over the page colour, read through a probe so they follow the theme
const probe = document.createElement("span")
probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"
root.appendChild(probe)
const swatch = document.createElement("canvas").getContext("2d", { willReadFrequently: true })
let pale: [number, number, number] = [246 / 255, 246 / 255, 245 / 255]
const readTheme = () => {
probe.style.color = "color-mix(in srgb, var(--foreground, #000) 4%, var(--background, #fff))"
const value = getComputedStyle(probe).color
if (!swatch || !value) return
swatch.clearRect(0, 0, 1, 1)
swatch.fillStyle = value
swatch.fillRect(0, 0, 1, 1)
const [r, g, b] = swatch.getImageData(0, 0, 1, 1).data
pale = [r! / 255, g! / 255, b! / 255]
}
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
const scenes: Partial<Record<World, Scene>> = {}
let shownImages: PictureWaveImage[] | null = null
let imagesKey = "\u0000"
let data = new Float32Array(0)
let hits = new Float32Array(0)
let hitCount = 0
let width = 1
let height = 1
let rtl = false
let shownWorld: World | null = null
let wordWidths: [number, number] | null = null
let shownWords = ""
let raf = 0
let last = 0
let visible = true
// the sequence's clock (s), how far the field has settled (0 to 1), the field's own clock for its drift
let t = 0
let settle = 0
let clock = 0
const mouse: [number, number] = [0, 0]
const leaned: [number, number] = [0, 0]
const out: [number, number, number, number] = [0, 0, 0, 0]
const sceneFor = (world: World, pictures: PictureWaveImage[]) => {
const scene = scenes[world] ?? buildScene(world, pictures.map((p) => p.aspect))
scenes[world] = scene
if (data.length < scene.cards.length * STRIDE) {
data = new Float32Array(scene.cards.length * STRIDE)
hits = new Float32Array(scene.cards.length * 5)
// the GPU buffer is sized once per layout; each frame writes only what it draws
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW)
}
return scene
}
// the words ride just outside the wave's two ends, a little air from the cards; across, when one is too long
// for its side, both are set a little smaller from their inner edges, never closer to the cards
const placeWords = (world: World) => {
const first = wordARef.current
const second = wordBRef.current
if (!first) return
const key = `${first.textContent}\u0001${second?.textContent ?? ""}`
if (!wordWidths || key !== shownWords) {
shownWords = key
wordWidths = [first.offsetWidth, second?.offsetWidth ?? 0]
}
const R = RIBBON[world]
const s = waveScale(t)
const k = R.axis
const start = R.centre[k] - R.span * s - R.gap[0]
const end = R.centre[k] + R.span * s + R.gap[1]
const cross = R.centre[1 - k]!
const gap = 0.045 * height
const state = wordsAt(t)
for (const el of [first, second]) if (el && el.dataset.state !== state) el.dataset.state = state
if (k === 0) {
// the first word opens the line: on the left, or on the right when the text runs right to left
const [left, right, lw, rw] = rtl ? [second, first, wordWidths[1], wordWidths[0]] : [first, second, wordWidths[0], wordWidths[1]]
const edge = 6.5 * 0.00625 * width
const l = width / 2 + start * height - gap
const r = width / 2 + end * height + gap
const y = height / 2 + cross * height
const fit = (room: number, w: number) => (w ? Math.min(1, Math.max(0.55, room / w)) : 1)
const sc = Math.min(fit(l - edge, lw), fit(width - edge - r, rw))
if (left) {
left.style.transformOrigin = "100% 50%"
left.style.transform = `translate(${l}px, ${y}px) translate(-100%, -50%) scale(${sc})`
}
if (right) {
right.style.transformOrigin = "0 50%"
right.style.transform = `translate(${r}px, ${y}px) translate(0, -50%) scale(${sc})`
}
} else {
const x = width / 2 + cross * height
first.style.transformOrigin = ""
first.style.transform = `translate(${x}px, ${height / 2 + start * height - gap}px) translate(-50%, -100%)`
if (second) {
second.style.transformOrigin = ""
second.style.transform = `translate(${x}px, ${height / 2 + end * height + gap}px) translate(-50%, 0)`
}
}
}
const draw = () => {
const s = settingsRef.current
const pictures = s.images.slice(0, 100)
if (s.images !== shownImages) {
// a new list with the same pictures (written inline by the consumer) keeps the atlas and the layout
shownImages = s.images
const key = pictures.map((p) => `${p.src}\u0002${p.aspect}`).join("\u0001")
if (key !== imagesKey) {
imagesKey = key
delete scenes.horizontal
delete scenes.vertical
buildAtlas(pictures)
}
}
const world: World = s.orientation === "auto" ? (width < 900 ? "vertical" : "horizontal") : s.orientation
const line = wordsRef.current
if (world !== shownWorld || (line && line.dataset.world !== world)) {
shownWorld = world
if (line) line.dataset.world = world
wordWidths = null
}
const scene = sceneFor(world, pictures)
if (mipsDirty) {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.generateMipmap(gl.TEXTURE_2D)
mipsDirty = false
}
if (t < TIME.field) placeWords(world)
else if (wordARef.current?.dataset.state !== "off") placeWords(world)
const P = BURST[world]
const ghost = clamp01((t - P.ghost[0]) / (P.ghost[1] - P.ghost[0]))
const scale = waveScale(t)
const inField = t >= TIME.field
const drift = s.float && !reduced ? settle : 0
const lx = leaned[0] * s.lean * settle
const ly = leaned[1] * s.lean * settle
const ox = width / 2
const oy = height / 2
let n = 0
hitCount = 0
for (const i of scene.order) {
const c = scene.cards[i]!
let alpha = 1
if (!inField) {
const k = scene.slot[i]!
if (k < 0) {
// the field's other cards fade in as the burst lands
alpha = ghost
if (alpha <= 0.002) continue
fieldAt(world, c, clock, 0, 0, 0, out)
} else if (!burstAt(world, t, scene.deck > 1 ? k / (scene.deck - 1) : 0.5, scale, c, out)) continue
} else fieldAt(world, c, clock, drift, lx, ly, out)
const picture = c.cls === 0 && c.img >= 0 && c.img < boxes.length
const box = picture ? boxes[c.img]! : null
const o = n * STRIDE
const w = out[2] * height
const h = out[3] * height
const x = ox + out[0] * height
const y = oy + out[1] * height
data[o] = x
data[o + 1] = y
data[o + 2] = w
data[o + 3] = h
data[o + 4] = box ? box[0] : 0
data[o + 5] = box ? box[1] : 0
data[o + 6] = box ? box[2] : 0
data[o + 7] = box ? box[3] : 0
data[o + 8] = box ? 0 : 1
data[o + 9] = alpha
n++
if (inField && picture) {
const q = hitCount * 5
hits[q] = c.img
hits[q + 1] = x - w / 2
hits[q + 2] = y - h / 2
hits[q + 3] = x + w / 2
hits[q + 4] = y + h / 2
hitCount++
}
}
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
if (!n) return
gl.useProgram(program)
gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, n * STRIDE)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.uniform1i(uMap, 0)
gl.uniform2f(uView, width, height)
gl.uniform3f(uPale, pale[0], pale[1], pale[2])
gl.enable(gl.BLEND)
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n)
gl.bindVertexArray(null)
}
// the loop runs while the sequence plays, the field settles or drifts, or the cards still follow the pointer
const frame = (now: number) => {
raf = 0
if (!visible || document.hidden) {
last = 0
return
}
const dt = last ? Math.min(0.05, Math.max(1 / 240, (now - last) / 1000)) : 1 / 60
const s = settingsRef.current
const target = s.state === "wave" ? TIME.wave : TIME.field
let moving = false
if (reduced) {
t = target
settle = 0
} else if (t < target) {
t = Math.min(target, t + dt)
moving = true
} else if (t > target) {
// leaving the field: its drift and lean ease out first, then the cards gather back into the wave
if (settle > 0) settle = Math.max(0, settle - dt / SETTLE_OUT)
else t = Math.max(target, t - dt)
moving = true
} else if (t >= TIME.field && settle < 1) {
settle = Math.min(1, settle + dt / SETTLE_IN)
moving = true
}
const k = reduced ? 0 : 1 - Math.exp(-dt / LEAN_EASE)
leaned[0] += (mouse[0] - leaned[0]) * k
leaned[1] += (mouse[1] - leaned[1]) * k
// within a few hundredths of a pixel of the pointer, the lean lands and the loop may stop
if (Math.abs(mouse[0] - leaned[0]) + Math.abs(mouse[1] - leaned[1]) < 2e-3) {
leaned[0] = mouse[0]
leaned[1] = mouse[1]
} else if (settle > 0 && s.lean !== 0) moving = true
if (settle > 0 && s.float && !reduced) {
clock += dt
moving = true
}
draw()
last = moving ? now : 0
if (moving) wake()
}
const wake = () => {
if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame)
}
const resize = () => {
const s = settingsRef.current
width = Math.max(1, root.clientWidth)
height = Math.max(1, root.clientHeight)
rtl = getComputedStyle(root).direction === "rtl"
wordWidths = null
const ratio = Math.min(s.dpr || window.devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / (width * height)))
canvas.width = Math.max(1, Math.round(width * ratio))
canvas.height = Math.max(1, Math.round(height * ratio))
wake()
}
const pick = (clientX: number, clientY: number) => {
const r = root.getBoundingClientRect()
const x = clientX - r.left
const y = clientY - r.top
for (let k = hitCount - 1; k >= 0; k--) {
const q = k * 5
if (x >= hits[q + 1]! && x <= hits[q + 3]! && y >= hits[q + 2]! && y <= hits[q + 4]!) return hits[q]!
}
return -1
}
// the pointer, −1 to 1 across the box (held at its edges outside it): the field's cards lean away from it
const onPointer = (e: PointerEvent) => {
const r = root.getBoundingClientRect()
mouse[0] = Math.min(1, Math.max(-1, ((e.clientX - r.left) / Math.max(1, r.width)) * 2 - 1))
mouse[1] = Math.min(1, Math.max(-1, ((e.clientY - r.top) / Math.max(1, r.height)) * 2 - 1))
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
const cursor = inside && settingsRef.current.onPick && pick(e.clientX, e.clientY) >= 0 ? "pointer" : ""
if (canvas.style.cursor !== cursor) canvas.style.cursor = cursor
if (settle > 0 && settingsRef.current.lean !== 0) wake()
}
const onClick = (e: MouseEvent) => {
const i = pick(e.clientX, e.clientY)
if (i >= 0) settingsRef.current.onPick?.(i)
}
const onVisibility = () => wake()
const resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(root)
const intersection = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting)
if (visible) wake()
})
intersection.observe(root)
const themeObserver = new MutationObserver(() => {
readTheme()
wake()
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] })
document.addEventListener("visibilitychange", onVisibility)
window.addEventListener("pointermove", onPointer, { passive: true })
canvas.addEventListener("click", onClick)
document.fonts?.ready.then(() => {
wordWidths = null
wake()
})
readTheme()
resize()
wakeRef.current = () => {
wordWidths = null
wake()
}
replayRef.current = () => {
t = 0
settle = 0
wake()
}
return () => {
cancelAnimationFrame(raf)
raf = -1
wakeRef.current = null
replayRef.current = null
for (const img of loads) img.onload = img.onerror = null
resizeObserver.disconnect()
intersection.disconnect()
themeObserver.disconnect()
document.removeEventListener("visibilitychange", onVisibility)
window.removeEventListener("pointermove", onPointer)
canvas.removeEventListener("click", onClick)
gl.deleteBuffer(buffer)
gl.deleteVertexArray(vao)
gl.deleteTexture(texture)
gl.deleteProgram(program)
gl.getExtension("WEBGL_lose_context")?.loseContext()
canvas.remove()
probe.remove()
}
}, [])
// props reach the loop through refs written here, never in render; a change only wakes it
React.useEffect(() => {
settingsRef.current = settings
wakeRef.current?.()
})
return (
<div
ref={(el: HTMLDivElement | null) => {
rootRef.current = el
setRef(ref, el)
}}
data-slot="picture-wave"
className={cn("relative h-full w-full overflow-hidden [container-type:size]", className)}
{...props}
>
{words.length > 0 && (
<p
ref={wordsRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[1] m-0 font-heading text-[length:5cqw] leading-none tracking-[-0.03em] data-[world=vertical]:text-[length:min(10.667cqw,48px)]"
>
<span ref={wordARef} className={WORD}>
{words[0]}
</span>
{words[1] && (
<span ref={wordBRef} className={WORD}>
{words[1]}
</span>
)}
</p>
)}
</div>
)
}