The Musée Éphémère is a single HTML file, two vendored GSAP scripts, and roughly six hundred lines of GLSL. There are no image files anywhere in the building — every canvas is a fragment shader evaluated in real time, and the gallery itself is a pinned horizontal scroll rig. This catalogue explains each system with the actual code, so you can build your own wing.
Museums are quiet machines: warm walls, dark frames, brass plaques, one pool of light per work. The palette stays inside that logic — gallery bone for the wall, ink for frames and text, terracotta as the single voice allowed to speak, brass for the furniture of information. As you walk the six salles, the wall hue drifts a few degrees cooler and back — the architectural equivalent of a key change.
Type does three jobs and never trades them: Marcellus is the engraving (wordmark, room numbers), Cormorant Garamond italic is the artwork's voice (titles, curator notes), and Inter in letter-spaced small caps is the plaque metadata. Body copy on this page is Cormorant — a catalogue should read like a catalogue.
The gallery is one flex row (#track) inside a 100vh section. GSAP's ScrollTrigger
pins the section and maps vertical scroll distance 1:1 onto a horizontal translation of the track.
Because the mapping is 1:1 (end: '+=' + distance), a wheel tick, a scrollbar drag and a pointer
drag all move the visitor the same honest distance — no magic ratios to tune.
// distance the wall extends past the viewport const getScrollDist = () => Math.max(1, track.scrollWidth - innerWidth); st = gsap.to(track, { x: () => -getScrollDist(), ease: 'none', // the scrub IS the easing scrollTrigger: { trigger: '#gallery', start: 'top top', end: () => '+=' + getScrollDist(), pin: true, scrub: 1.15, // ~1s of inertia between finger and wall invalidateOnRefresh: true, onUpdate(self){ // normalized signed velocity → shader uniform + skew velTarget = gsap.utils.clamp(-1.6, 1.6, self.getVelocity() / 2600); progressGlobal = self.progress; } } });
Dragging is the same system wearing gloves. A pointer drag simply writes
scrollTo(0, dragScroll - dx) — because the mapping is 1:1, dragging the wall 300px moves it
exactly 300px. On release, the last pointer velocity keeps feeding scrollBy with a 0.94 decay
per frame: museum momentum, not carnival physics. A dragMoved > 8px guard decides whether a
gesture was a walk or a click on a painting.
Each artwork owns its own small <canvas> and WebGL1 context rather than sharing one
fullscreen canvas with scissored viewports. The trade was deliberate: six contexts is far below browser
limits, each canvas is small (a framed painting, not a wall), pausing a work is trivial
(just skip its draw call), and the canvases ride the DOM transform of the track for free — no
per-frame rect synchronisation. The classic shared-canvas technique earns its complexity when you have
dozens of views; a museum hangs six.
Every painting is the same vertex shader (one fullscreen triangle) plus a fragment shader assembled from
three parts: a shared prelude (hash, value noise, five-octave fbm), the work's own
vec3 art(vec2 uv, float t), and a shared main wrapper that applies velocity distortion,
grain, and gallery-light vignette to all works equally — the house style.
function makePainting(canvas, fragBody){ const gl = canvas.getContext('webgl', { antialias: false, alpha: false, depth: false, powerPreference: 'low-power' // a museum hums, it doesn't roar }); const prog = makeProgram(gl, PRELUDE + fragBody + MAINWRAP); // one triangle covers clip space; no index buffer, no quads gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); const DPR = Math.min(devicePixelRatio || 1, 1.75); // resolution cap ... }
Each work keeps its own clock. Per frame: p.t += dt * (0.28 + p.awaken * 1.15).
Asleep, a painting moves at ~a quarter of real time — museum stillness. As it approaches the viewport
centre (or is hovered, or contemplated), awaken eases toward 1 and the painting breathes
faster and zooms in ten percent. Time never jumps; the same clock feeds the contemplation view, so the
enlarged work continues mid-thought.
Classic folded fbm: one noise field feeds the coordinates of a second, and a sine wave ridden through the result becomes marble veining. pow(abs(vein), 0.55) sharpens the veins without hard edges.
float f1 = fbm(p + vec2(t*0.05, -t*0.02)); float f2 = fbm(p*1.4 + f1*2.3 + vec2(-t*0.03, t*0.04)); // noise through noise float vein = pow(abs(sin(p.y*2.2 + p.x*0.7 + f2*7.0)), 0.55); vec3 col = mix(ink, bone, vein);
Inigo Quilez's double domain warp: two vector fields q and r, each built from fbm of the other's output. Ink pools where the warp collapses (smoothstep on a third fbm of p + r).
vec2 q = vec2(fbm(p + t*0.04), fbm(p + vec2(5.2,1.3) - t*0.03)); vec2 r = vec2(fbm(p + 3.0*q + vec2(1.7,9.2) + t*0.02), fbm(p + 3.0*q + vec2(8.3,2.8) - t*0.025)); float f = fbm(p + 3.2*r); // the painting
A 3×3 Voronoi search tracks the two nearest feature points; F2 − F1 is the distance to the cell border, which becomes the lead came. Each pane's colour is a hash of its cell id fed through a five-colour palette, breathing at its own phase.
float d = length(g + o - fp); if (d < f1){ f2 = f1; f1 = d; id = ip + g; } else if (d < f2){ f2 = d; } ... float lead = smoothstep(0.015, 0.11, f2 - f1); // border width vec3 col = mix(cameDark, pane(hash21(id)) * breath, lead);
Six inverse-square fields sum into one scalar. A smoothstep at 1.0 births the bodies; sin(field × 16 − t) masked to a band around the threshold produces the standing-wave halos that read as reaction–diffusion.
field += r*r / max(dot(p - c, p - c), 1e-4); // per ball float m = smoothstep(0.9, 1.05, field); // the body float rings = sin(field * 16.0 - t * 0.9); // the halos rings *= smoothstep(1.6, 0.9, field) * smoothstep(0.35, 0.8, field);
Three reds separated by two horizontal edges whose positions are fbm of (x, time) — so the boundary wanders along its length and drifts over minutes. Edge widths pulse at different tempos; the painting is always exhaling somewhere.
float e2 = 0.62 + (fbm(vec2(uv.x*3.1 + 7.0, t*0.075)) - 0.5) * 0.10; float w2 = 0.06 + 0.03 * cos(t * 0.33); // breathing width col = mix(col, top, smoothstep(e2 - w2, e2 + w2, uv.y));
A 40-step sphere-traced scene: one fbm-displaced sphere, one ground plane. Shading is a diffuse dot product, a Blinn highlight at power 42, and a cubed fresnel rim in brass. Small canvas + capped steps keeps it cheap enough to hang beside five siblings.
for (int i = 0; i < 40; i++){ float d = map(ro + rd * dist, t); if (d < 0.0025){ hit = 1.0; break; } dist += d * 0.9; // slight under-step: fbm bends the field if (dist > 6.0) break; }
While the visitor moves, every painting smears slightly along the direction of travel and its colour channels part — like looking at oil paint through old glass in motion. The effect lives in the shared main wrapper, so all six works obey the same physics. The trick is restraint: the offset maxes at 1.6% of the canvas, channels are re-averaged with a motion-blur mix, and everything settles the moment you stop, because the uniform decays at 0.9 per frame and is smoothed at 0.09 — glass and light, not glitch.
float v = clamp(u_vel, -1.6, 1.6); if (abs(v) > 0.004){ // uniform branch: free when still vec2 off = vec2(0.016, 0.0) * v; vec3 ca = art(uv - off, u_time); // red looks behind vec3 cb = art(uv, u_time); // green stays vec3 cc = art(uv + off, u_time); // blue looks ahead col = vec3(ca.r, cb.g, cc.b); col = mix(col, (ca + cb + cc) / 3.0, min(abs(v) * 0.5, 0.45)); }
The same smoothed velocity drives the DOM: frames skew up to 4.5° against the direction of travel and
lead the motion by 18px while plaques lag at 7px — three parallax depths (frame, plaque, wall text)
that make the wall read as a place rather than a strip. When the wall stops, everything returns to
plumb with the museum easing curve, cubic-bezier(0.16, 1, 0.3, 1).
Six live shaders could be a space heater. They aren't, because the building only renders what you can see:
rootMargin: '25%' marks each work visible or not; the
single rAF loop simply skips draw calls for sleeping works. Off-screen paintings cost zero GPU.powerPreference: 'low-power', no antialias, no depth buffer; geometry is
one triangle. The only per-frame allocation is nothing.prefers-reduced-motion rebuilds the museum vertically: no pin, no scrub, no skew; every
painting renders exactly one still frame and the visit becomes a quiet page of framed prints.const io = new IntersectionObserver(entries => { for (const e of entries){ const w = paintings.find(p => p.el === e.target); if (w) w.visible = e.isIntersecting; } }, { rootMargin: '25%', threshold: 0 }); // in the rAF loop: if (p.painter && p.visible) p.painter.draw(p.t, velSmooth, p.awaken);
100vh section containing a flex row of panels (entrance text, six salles, colophon). Add the floor as a ::before gradient and the baseboard as a 1px ::after running the full track.scrollWidth − innerWidth, scrub ≈ 1.15. Feed self.getVelocity() / 2600 into a smoothed uniform.art() + shared wrapper. Cap DPR, skip draws for off-screen works.art() functions — steal the six above, then betray them: different palettes, different tempos. Keep every time coefficient under 0.1; stillness is the luxury.prefers-reduced-motion gets the vertical museum; no WebGL gets the apology card; touch gets native drag. Then hang your name in the colophon and open the doors.Everything ships from one origin: two GSAP files (~115 KB) and this HTML. No build step, no bundler, no image requests. The whole museum weighs less than one photograph of a museum.