Widgets
Sand edge
Ein Karussell, dessen seitliche Bilder zu feinem Sand in ihren eigenen Farben zerfallen: die Körner wehen mit dem Tempo des Streifens davon und setzen sich in Ruhe zu einer verwitterten Kante. WebGL mit DOM-Ersatz.
Vorschau
Verwendung
sand-edge-demo.tsx
import { SandEdge } from '@/components/sand-edge'
import { glPictures } from '@/components/gl-pictures'
export default function SandEdgeDemo() {
return (
// Wider than the preview's column on a large screen, so the sand has room to blow; centred on the page.
<div className="relative left-1/2 w-[min(calc(100vw-3rem),72rem)] -translate-x-1/2">
<SandEdge images={glPictures} label="Landscapes" defaultIndex={2} shimmer />
</div>
)
}Quellcode
sand-edge.tsx
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent } from 'react'
import { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'
import { glAtlas, glProgram, useGlStage, type GlAtlas, type GlImage, type GlScene } from '@/registry/manniche/hooks/use-gl-stage'
import { cn } from '@/lib/utils'
/** A picture for the carousel. The title, if given, is shown under the pictures. */
export type SandEdgeImage = GlImage & { title?: string }
export type SandEdgeProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
/** The pictures. A picture from another origin must send CORS headers (Access-Control-Allow-Origin) to be drawn in WebGL. */
images: SandEdgeImage[]
/** Read by screen readers, e.g. “Landscapes”. */
label: string
/** Controlled: the picture in the middle. */
index?: number
/** The picture in the middle at first, when uncontrolled. */
defaultIndex?: number
/** Called when the carousel 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. */
loop?: boolean
/** Picture width over height. */
aspect?: number
/** Size of one grain of sand in CSS pixels, the same on every screen. */
grain?: number
/** How far the wind carries the sand when the strip moves; 0 keeps the grains near their edge. */
wind?: number
/** Loose grains at the edges shimmer slightly while on screen. Off under reduced motion. */
shimmer?: boolean
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"]'
// How the erosion reads, in parts of the stage's half width and of the card width.
const FADE = 0.32 // how far past its threshold a grain is still seen, at rest
const GUST = 1.1 // how much longer the trail of sand gets in a full wind
const REACH = 0.36 // card widths a grain drifts per unit of erosion, at rest
const RADIUS = 14 // css px, the corners of a card
const MAX_GRAINS = 90_000 // per card; past this the grains get coarser
const PIXELS = 4_200_000 // device pixels the canvas may use; the scene draws only cards, so this can be above the stage's own cap
type Layout = { width: number; cw: number; ch: number; step: number; pad: number; height: number; z0: number }
// Where everything sits for a stage `width` CSS pixels wide.
function measure(width: number, aspect: number): Layout {
let cw = Math.min(460, Math.max(width * 0.3, Math.min(width * 0.56, 320)))
cw = Math.round(Math.min(cw, 560 * aspect))
const ch = Math.round(cw / aspect)
const step = cw + Math.round(Math.max(12, cw * 0.05))
const pad = Math.round(Math.max(24, ch * 0.09))
// Erosion starts a little outside the middle card's edge, and never nearer the middle than 40 % of the half width.
const z0 = Math.max(0.4, cw / width + 0.05)
return { width, cw, ch, step, pad, height: ch + pad * 2, z0 }
}
// Shared by both programs: how eroded the stage is at a device x, in units where 0 is the start of the zone and
// 1 + FADE is the stage's edge.
const ERODE = `uniform vec4 uZ;
float erode(float x){return (abs(x-uZ.x)*uZ.y-uZ.z)*uZ.w;}`
// The solid body of a card: the picture at full resolution, with every grain cell that has come loose cut out.
const CARD_VS = `attribute vec2 aQ;
uniform vec2 uRes;uniform vec2 uOrg;uniform vec2 uSize;
varying vec2 vL;
void main(){
vL=aQ*uSize;
vec2 p=uOrg+vL;
gl_Position=vec4(p.x/uRes.x*2.-1.,1.-p.y/uRes.y*2.,0.,1.);
}`
const HIGHP = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
`
const CARD_FS = `${HIGHP}
uniform sampler2D uTex;uniform sampler2D uThr;
uniform vec2 uOrg;uniform vec2 uSize;uniform vec2 uGrid;uniform float uPitch;uniform vec4 uChan;uniform vec4 uRect;
uniform float uRadius;uniform float uLine;uniform vec4 uBorder;
varying vec2 vL;
${ERODE}
void main(){
vec2 cell=floor(vL/uPitch);
float t=dot(texture2D(uThr,(cell+.5)/uGrid),uChan);
// A hair of overlap with the grains, so the seam between the two never shows a gap.
if(erode(uOrg.x+(cell.x+.5)*uPitch)-t>.004)discard;
vec2 q=abs(vL-uSize*.5)-(uSize*.5-uRadius);
float sd=length(max(q,0.))+min(max(q.x,q.y),0.)-uRadius;
float a=clamp(.5-sd,0.,1.);
vec4 c=texture2D(uTex,mix(uRect.xy,uRect.zw,vL/uSize));
// The family's hairline ring, in the theme's border colour.
float ring=clamp(uLine-abs(sd+uLine*.5)+.5,0.,1.)*uBorder.a;
c.rgb=mix(c.rgb,uBorder.rgb*c.a,ring);
gl_FragColor=c*a;
}`
// One point per grain. Past its threshold a grain leaves home: out towards the nearer edge of the stage, a little up
// or down, farther the more eroded it is and the stronger the wind, and it fades as it goes.
const SAND_VS = `attribute vec2 aCell;attribute vec4 aT;attribute vec4 aR;
uniform vec2 uRes;uniform vec2 uOrg;uniform vec2 uSize;uniform float uPitch;uniform vec4 uChan;uniform vec4 uRect;
uniform float uRadius;
uniform vec4 uAir;
uniform vec3 uFlow;
uniform float uTime;
varying vec2 vUv;varying float vA;varying float vShade;
${ERODE}
void main(){
vec2 local=(aCell+.5)*uPitch;
vec2 home=uOrg+local;
float d=erode(home.x)-dot(aT,uChan);
float life=d/uAir.x;
vec2 q=abs(local-uSize*.5)-(uSize*.5-uRadius);
float sd=length(max(q,0.))+min(max(q.x,q.y),0.)-uRadius;
if(d<-.004||life>=1.||sd>0.){gl_Position=vec4(2.,2.,2.,1.);gl_PointSize=0.;return;}
d=max(d,0.);life=max(life,0.);
float side=home.x<uZ.x?-1.:1.;
float loose=smoothstep(0.,.03,d);
float w=uAir.w;
// The downwind side carries farther than the side the strip comes in from.
float gust=1.+.5*w*max(0.,side*uFlow.x);
float spread=.5+aR.x;
vec2 off;
off.x=side*(uAir.y*d+uAir.z*w*d*d*gust)*spread;
off.y=((aR.y-.5)*2.-uFlow.y*(.35+w))*pow(d,1.3)*uAir.y*.3;
off+=(aR.zw-.5)*uPitch*1.3*loose;
off+=vec2(sin(uTime*(1.3+aR.z)+aR.w*6.283),cos(uTime*(1.1+aR.w)+aR.z*6.283))*uPitch*.55*loose*uFlow.z;
vec2 p=home+off;
float edge=smoothstep(0.,uPitch*8.,min(p.x,uRes.x-p.x))*smoothstep(0.,uPitch*6.,min(p.y,uRes.y-p.y));
vA=(1.-smoothstep(.25,1.,life))*edge;
vShade=mix(1.,.9+.2*aR.w,loose);
vUv=mix(uRect.xy,uRect.zw,local/uSize);
gl_PointSize=max(1.,floor(uPitch*mix(1.,.55+.45*aR.x,life)+.5));
gl_Position=vec4(p.x/uRes.x*2.-1.,1.-p.y/uRes.y*2.,0.,1.);
}`
const SAND_FS = `precision mediump float;
uniform sampler2D uTex;uniform float uBias;
varying vec2 vUv;varying float vA;varying float vShade;
void main(){
// The point's uv is the same in all its pixels, so the bias alone picks the mip level: one grain, one average colour.
vec4 c=texture2D(uTex,vUv,uBias);
gl_FragColor=vec4(c.rgb*vShade,c.a)*vA;
}`
// A small seeded hash and value noise, for the grains' thresholds.
function hash(x: number, y: number, s: number) {
let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(s, 2147483647)
h = Math.imul(h ^ (h >>> 13), 1274126177)
return ((h ^ (h >>> 16)) >>> 0) / 4294967296
}
function noise(x: number, y: number, s: number) {
const ix = Math.floor(x)
const iy = Math.floor(y)
const fx = x - ix
const fy = y - iy
const u = fx * fx * (3 - 2 * fx)
const v = fy * fy * (3 - 2 * fy)
const a = hash(ix, iy, s)
const b = hash(ix + 1, iy, s)
const c = hash(ix, iy + 1, s)
const d = hash(ix + 1, iy + 1, s)
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
}
/**
* Each grain's threshold, four variants per grain (one per channel), so neighbouring cards do not erode alike.
* Broad noise, stretched sideways, sets bays and headlands; strata, thin bands that run across the card, let some
* layers recede further like weathered sandstone; fine noise and a little white noise fray the front. The top and
* bottom give way a little first, so the front rounds off at the corners. The values sit around 0.5 and change
* slowly across the card, so the front is one ragged line, not holes all over.
*/
function thresholds(cols: number, rows: number) {
const n = cols * rows
const out = new Uint8Array(n * 4)
const k = rows / cols
for (let s = 0; s < 4; s++) {
for (let j = 0; j < rows; j++) {
const y = (j + 0.5) / rows
const rim = 1 - Math.min(1, Math.min(y, 1 - y) / 0.1)
for (let i = 0; i < cols; i++) {
const x = (i + 0.5) / cols
const big = noise(x * 1.6, y * k * 3.2, s * 7 + 1) * 0.7 + noise(x * 3.5, y * k * 7, s * 7 + 2) * 0.3
const strata = noise(x * 2.2, y * k * 26, s * 7 + 3)
const fine = noise(x * 34, y * k * 34, s * 7 + 4)
const value = 0.5 + (big - 0.5) * 0.5 + (strata - 0.5) * 0.3 + (fine - 0.5) * 0.09 + (hash(i, j, s + 11) - 0.5) * 0.035 - rim * rim * 0.2
out[(j * cols + i) * 4 + s] = Math.round(Math.min(1, Math.max(0, value)) * 255)
}
}
}
return out
}
type Motion = { p: number; v: number; wind: number; dir: number; at: number; raf: number }
/**
* A row of pictures whose edges blow away like sand. The picture in the middle is solid; towards the stage's left
* and right edges each card breaks up into fine square grains in the picture's own colours, and they drift off on a
* wind and fade. The faster the strip moves, the stronger the wind and the farther the sand flies; a card coming in
* gathers from grains, one going out comes apart. Each card is drawn in WebGL as its full-resolution picture with the
* loose grain cells cut out, plus one point per loose grain, with the erosion front set per grain from noise so it
* reads as weathered stone, not a wipe. Drag or swipe with momentum, use a trackpad, the arrow keys, the buttons, or
* click a side picture. Under reduced motion each move jumps and the eroded edges stand still. Without WebGL, or if
* the GPU drops the context, the same carousel is drawn with plain images that fade towards the edges.
*/
export function SandEdge({
images,
label,
index,
defaultIndex,
onIndexChange,
loop = false,
aspect = 4 / 5,
grain = 2,
wind = 1,
shimmer = false,
labels = {},
className,
onKeyDown,
...rest
}: SandEdgeProps) {
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 tick = useRef<HTMLSpanElement | null>(null)
const [width, setWidth] = useState(0)
const L = width ? measure(width, aspect) : null
const layout = useRef<Layout | null>(null)
const motion = useRef<Motion>({ p: 0, v: 0, wind: 0, dir: 0, at: 0, raf: 0 })
// The latest effect settings, for the scene, which outlives a render.
const tune = useRef({ grain, wind, shimmer, n, wraps })
useLayoutEffect(() => {
tune.current = { grain, wind, shimmer, n, wraps }
})
const srcs = images.map((im) => im.src).join('\n')
const gl = useGlStage(
stage,
({ gl, canvas, ready, redraw, fail, signal }) => {
const card = glProgram(gl, CARD_VS, CARD_FS)
const sand = glProgram(gl, SAND_VS, SAND_FS)
if (!card || !sand) return null
const list = srcs.split('\n').map((src) => ({ src, alt: '' }))
const loc = (p: WebGLProgram, names: string[]) => Object.fromEntries(names.map((k) => [k, gl.getUniformLocation(p, k)]))
const common = ['uRes', 'uOrg', 'uSize', 'uPitch', 'uChan', 'uRect', 'uRadius', 'uZ', 'uTex']
const uc = loc(card, [...common, 'uThr', 'uGrid', 'uLine', 'uBorder'])
const us = loc(sand, [...common, 'uAir', 'uFlow', 'uTime', 'uBias'])
const aQ = gl.getAttribLocation(card, 'aQ')
const aCell = gl.getAttribLocation(sand, 'aCell')
const aT = gl.getAttribLocation(sand, 'aT')
const aR = gl.getAttribLocation(sand, 'aR')
const quad = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quad)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW)
const grains = gl.createBuffer()
const thr = gl.createTexture()
let grid = '' // cols × rows the grain buffer was built for
let count = 0
let atlas: GlAtlas | null = null
let border: [number, number, number] = [0.5, 0.5, 0.5]
let dark = false
let W = 1
let H = 1
// The atlas cells match the cards' size on screen, so the middle picture is sharp.
const host = canvas.parentElement
const cssH = host ? measure(host.clientWidth || 600, aspect).ch : 400
const cell = cssH * Math.min(2, window.devicePixelRatio || 1) > 560 ? 1024 : 512
glAtlas(gl, list, { aspect, cell, limit: 4096, signal }).then((a) => {
if (signal.aborted) return
if (!a) return fail()
atlas = a
ready()
redraw()
})
// The grain grid for a card of cols × rows grains: one vertex each, with its four thresholds and four randoms.
const build = (cols: number, rows: number) => {
const key = `${cols}x${rows}`
if (key === grid) return
grid = key
count = cols * rows
const t4 = thresholds(cols, rows)
const data = new ArrayBuffer(count * 16)
const f = new Float32Array(data)
const b = new Uint8Array(data)
for (let j = 0, g = 0; j < rows; j++)
for (let i = 0; i < cols; i++, g++) {
f[g * 4] = i
f[g * 4 + 1] = j
for (let c = 0; c < 4; c++) {
b[g * 16 + 8 + c] = t4[g * 4 + c]
b[g * 16 + 12 + c] = Math.floor(hash(i, j, 101 + c) * 256)
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, grains)
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, thr)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cols, rows, 0, gl.RGBA, gl.UNSIGNED_BYTE, t4)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
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 scene: GlScene = {
resize: (w, h) => {
W = w
H = h
},
theme: ([bd, bg]) => {
border = bd
dark = bg[0] * 0.3 + bg[1] * 0.6 + bg[2] * 0.1 < 0.5
},
draw: (time) => {
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
const lay = layout.current
if (!atlas || !lay) return
const o = tune.current
const m = motion.current
const dpr = W / Math.max(1, lay.width)
// Grain pitch in device pixels, whole, so grains tile with no gaps; coarser if a card would need too many.
let pitch = Math.max(1, Math.round(o.grain * dpr))
while ((lay.cw * dpr * lay.ch * dpr) / (pitch * pitch) > MAX_GRAINS) pitch++
const cols = Math.max(2, Math.round((lay.cw * dpr) / pitch))
const rows = Math.max(2, Math.round((lay.ch * dpr) / pitch))
build(cols, rows)
const sw = cols * pitch
const sh = rows * pitch
const cx = W / 2
const half = W / 2
const w = m.wind
// In a wind the erosion reaches a little further in, and the trail of sand gets longer.
const z0 = lay.z0 - 0.05 * w
const fade = FADE * (1 + GUST * w * Math.min(1, o.wind))
const z1 = z0 + (1 - z0) / (1 + FADE)
const zone = [cx, 1 / half, z0, 1 / (z1 - z0)] as const
const erode = (x: number) => (Math.abs(x - cx) / half - z0) / (z1 - z0)
const top = Math.round(lay.pad * dpr + (lay.ch * dpr - sh) / 2)
// The cards near the visible range, farthest first. `inner` and `outer` are the erosion at a card's
// nearest and farthest point from the middle.
const shown: { i: number; x: number; inner: number; outer: number }[] = []
for (let i = 0; i < o.n; i++) {
let d = i - m.p
if (o.wraps) d = ((((d + o.n / 2) % o.n) + o.n) % o.n) - o.n / 2
const x = cx + d * lay.step * dpr - sw / 2
const near = Math.max(0, x - cx, cx - x - sw)
const far = Math.max(Math.abs(x - cx), Math.abs(x + sw - cx))
const inner = erode(cx + near)
if (near > half || inner > 1 + fade) continue
shown.push({ i, x, inner, outer: erode(cx + far) })
}
shown.sort((a, b) => b.inner - a.inner)
gl.enable(gl.BLEND)
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, atlas.texture)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, thr)
const chan = (i: number) => [i % 4 === 0 ? 1 : 0, i % 4 === 1 ? 1 : 0, i % 4 === 2 ? 1 : 0, i % 4 === 3 ? 1 : 0] as const
const shared = (u: Record<string, WebGLUniformLocation | null>, c: (typeof shown)[number]) => {
gl.uniform2f(u.uRes, W, H)
gl.uniform2f(u.uOrg, c.x, top)
gl.uniform2f(u.uSize, sw, sh)
gl.uniform1f(u.uPitch, pitch)
gl.uniform4f(u.uChan, ...chan(c.i))
gl.uniform4f(u.uRect, ...atlas!.rects[c.i % atlas!.rects.length])
gl.uniform1f(u.uRadius, RADIUS * dpr)
gl.uniform4f(u.uZ, ...zone)
gl.uniform1i(u.uTex, 0)
}
gl.useProgram(card)
gl.bindBuffer(gl.ARRAY_BUFFER, quad)
gl.enableVertexAttribArray(aQ)
gl.vertexAttribPointer(aQ, 2, gl.FLOAT, false, 0, 0)
gl.uniform1i(uc.uThr, 1)
gl.uniform2f(uc.uGrid, cols, rows)
gl.uniform1f(uc.uLine, Math.max(1, dpr))
gl.uniform4f(uc.uBorder, ...border, dark ? 0.55 : 0.8)
for (const c of shown) {
if (c.inner > 1.004) continue
shared(uc, c)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
}
gl.disableVertexAttribArray(aQ)
gl.useProgram(sand)
gl.bindBuffer(gl.ARRAY_BUFFER, grains)
gl.enableVertexAttribArray(aCell)
gl.enableVertexAttribArray(aT)
gl.enableVertexAttribArray(aR)
gl.vertexAttribPointer(aCell, 2, gl.FLOAT, false, 16, 0)
gl.vertexAttribPointer(aT, 4, gl.UNSIGNED_BYTE, true, 16, 8)
gl.vertexAttribPointer(aR, 4, gl.UNSIGNED_BYTE, true, 16, 12)
const reach = REACH * sw
gl.uniform4f(us.uAir, fade, reach, reach * 2 * o.wind, w)
gl.uniform3f(us.uFlow, m.dir, 0.35, o.shimmer ? 1 : 0)
gl.uniform1f(us.uTime, time)
// Texels per grain, as a mip bias, so each grain is its patch's average colour.
gl.uniform1f(us.uBias, Math.log2(Math.max(1, (pitch * atlas.cell[1]) / sh)))
for (const c of shown) {
// A card wholly inside the solid middle has no loose grains.
if (c.outer < -0.004) continue
shared(us, c)
gl.drawArrays(gl.POINTS, 0, count)
}
gl.disableVertexAttribArray(aCell)
gl.disableVertexAttribArray(aT)
gl.disableVertexAttribArray(aR)
},
dispose: () => {
gl.deleteBuffer(quad)
gl.deleteBuffer(grains)
gl.deleteTexture(thr)
if (atlas) gl.deleteTexture(atlas.texture)
gl.deleteProgram(card)
gl.deleteProgram(sand)
},
}
return scene
},
`${srcs}|${aspect}`,
{
maxDpr: 2,
maxPixels: PIXELS,
animate: shimmer,
colors: ['var(--border)', 'var(--background)'],
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 glReady = gl.status === 'ready'
const redraw = gl.redraw
// The DOM layer: the visual until WebGL is ready or if it fails, and always the layer people click and read.
const paint = useCallback(
(p: number) => {
const lay = layout.current
if (!lay) return
for (let i = 0; i < n; i++) {
const el = cards.current[i]
if (!el) continue
let d = i - p
if (wraps) d = ((((d + n / 2) % n) + n) % n) - n / 2
const x = d * lay.step
if (Math.abs(x) - lay.cw / 2 > lay.width / 2) {
el.style.visibility = 'hidden'
continue
}
el.style.visibility = 'visible'
el.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`
}
const at = wraps ? (((p % n) + n) % n) / n : (Math.min(Math.max(p, 0), n - 1) / Math.max(n - 1, 1)) * (1 - 1 / n)
if (tick.current) tick.current.style.transform = `translateX(${(at * 100 * n).toFixed(3)}%)`
},
[n, wraps],
)
// The wind follows the strip's speed at once and dies down over about a third of a second once it stops.
const breathe = useCallback(() => {
const m = motion.current
cancelAnimationFrame(m.raf)
m.raf = 0
const step = (now: number) => {
const dt = Math.min(0.05, (now - m.at) / 1000)
m.at = now
m.wind *= Math.exp(-dt / 0.32)
if (m.wind < 0.01) m.wind = 0
redraw()
m.raf = m.wind ? requestAnimationFrame(step) : 0
}
m.at = performance.now()
m.raf = requestAnimationFrame(step)
}, [redraw])
const onFrame = useCallback(
(p: number, v: number) => {
const m = motion.current
const now = performance.now()
const dt = Math.min(0.05, (now - (m.at || now)) / 1000)
m.at = now
m.p = p
m.v = v
if (Math.abs(v) > 0.01) {
cancelAnimationFrame(m.raf)
m.raf = 0
m.dir = v > 0 ? -1 : 1
const target = 1 - Math.exp(-Math.abs(v) / 5)
m.wind = target > m.wind ? m.wind + (target - m.wind) * (1 - Math.exp(-dt / 0.05)) : m.wind + (target - m.wind) * (1 - Math.exp(-dt / 0.32))
} else if (m.wind > 0 && !m.raf) breathe()
paint(p)
redraw()
},
[paint, redraw, breathe],
)
useEffect(() => () => cancelAnimationFrame(motion.current.raf), [])
const engine = useCarouselEngine({ count: n, loop: wraps, index, defaultIndex, onIndexChange, step: L?.step ?? 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 or set of pictures: draw the current frame again.
const { draw } = engine
useLayoutEffect(() => {
layout.current = width ? measure(width, aspect) : null
draw()
}, [draw, width, aspect, grain, wind, glReady, n, wraps])
// If focus sat in a picture that just left the middle, keep it in the carousel.
useEffect(() => {
const el = stage.current
const active = document.activeElement
if (!el || !active || active === el || !el.contains(active)) return
if (!cards.current[current]?.contains(active)) el.focus({ preventScroll: true })
}, [current])
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 title = images[current]?.title
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'
// The fallback fades its cards out towards the stage's edges, where the sand would be.
const z = L ? Math.round((1 - L.z0) * 50) : 25
const mask = `linear-gradient(to right, transparent, #000 ${z}%, #000 ${100 - z}%, transparent)`
return (
<div onKeyDown={keys} className={cn('w-full', className)} {...rest}>
<div
{...engine.bind}
ref={(el) => {
stage.current = el
engine.bind.ref(el)
}}
id={id}
role="group"
aria-roledescription="carousel"
aria-label={label}
tabIndex={0}
style={{ height: L?.height ?? 480 }}
className={cn(
'relative 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-2 focus-visible:outline-ring',
)}
>
<div
style={{ maskImage: mask, WebkitMaskImage: mask }}
className={cn('absolute inset-0 transition-opacity duration-300 ease-out-quint motion-reduce:transition-none', glReady && 'opacity-0')}
>
{L &&
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 ? undefined : () => engine.go(i)}
style={{ width: L.cw, height: L.ch, top: L.pad, marginLeft: -L.cw / 2 }}
className={cn(
'absolute left-1/2 overflow-hidden rounded-[calc(var(--radius)+2px)] bg-muted will-change-transform',
!on && 'invisible cursor-pointer',
)}
>
<img
inert={!on}
src={im.src}
alt={im.alt}
draggable={false}
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)]" />
</div>
)
})}
</div>
</div>
{n > 1 ? (
<div className="mx-auto mt-3 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">
<p className="flex items-baseline justify-between gap-3 text-[11px] leading-none">
<span aria-hidden className="truncate text-[13px] font-medium tracking-[-0.005em] text-foreground">
{title ?? String(current + 1).padStart(2, '0')}
</span>
<span aria-hidden className="font-mono font-medium tracking-[0.04em] text-muted-foreground tabular-nums">
{String(current + 1).padStart(2, '0')}/{String(n).padStart(2, '0')}
</span>
</p>
{/* A hairline rule with a primary tick, one picture's share of it long, that follows the position. */}
<div aria-hidden className="relative h-1 overflow-hidden rounded-full bg-muted">
<span ref={tick} style={{ width: `${100 / n}%` }} className="absolute inset-y-0 left-0 block rounded-full bg-primary" />
</div>
</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/sand-edge.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/sand-edge