1<!-- Glass overlay — sits above the page -->
2<canvas id="shimmer-canvas"></canvas>
3
4<div class="page">
5 <div class="hero">
6 <h1>Shimmer Transition</h1>
7 <p>WebGL iridescent sweep — no libraries.</p>
8 </div>
9
10 <button id="play-btn">Play Transition</button>
11</div>
12
13<!-- ES module — imports shader.js, sweep.js, palettes.js -->
14<script type="module" src="src/main.js"></script>
1import { createShader } from './shader.js'
2import { playSweep } from './sweep.js'
3import { PALETTES } from './palettes.js'
4
5const canvas = document.getElementById('shimmer-canvas')
6const btn = document.getElementById('play-btn')
7const ctrl = createShader(canvas, { palette: 'prism' })
8
9const paletteNames = Object.keys(PALETTES)
10let paletteIndex = 0
11let activeSweep = null
12
13btn.addEventListener('click', () => {
14 if (!ctrl) return
15
16 // Cancel any in-progress sweep so a double-tap restarts
17 activeSweep?.cancel()
18
19 // Cycle through palettes on each tap
20 const name = paletteNames[paletteIndex++ % paletteNames.length]
21 ctrl.setPalette(name)
22 btn.textContent = `✦ ${name}`
23 btn.classList.add('active')
24
25 activeSweep = playSweep(ctrl, {
26 onMidpoint: () => { /* swap page content here */ },
27 onComplete: () => {
28 btn.textContent = 'Play Transition'
29 btn.classList.remove('active')
30 activeSweep = null
31 },
32 })
33})
1// Fragment shader — this runs on the GPU for every pixel
2precision mediump float;
3
4uniform vec2 uRes; // canvas size in pixels
5uniform float uTime; // elapsed seconds
6uniform float uProgress; // band travel 0 → 1
7uniform float uAlpha; // outro fade
8uniform vec3 uPalA, uPalB, uPalC, uPalD; // palette
9
10vec3 pal(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
11 return a + b * cos(2.0 * PI * (c * t + d)); // IQ palette
12}
13
14void main() {
15 vec2 uv = gl_FragCoord.xy / uRes;
16
17 // Band position (-0.2 → 1.2 across the screen)
18 float pos = -0.2 + uProgress * 1.4;
19
20 // Wavy edge — 3 sine harmonics superimposed
21 float wave = sin(uv.y * 6.0 + uTime * 1.30) * 0.020
22 + sin(uv.y * 13.0 - uTime * 0.90) * 0.012
23 + sin(uv.y * 21.0 + uTime * 1.70) * 0.006;
24
25 // Gaussian band — smooth bell curve falloff from centre
26 float d = (uv.x - pos) - wave;
27 float band = exp(-d * d * uBandTight);
28
29 // Surface normal — analytic slope of the Gaussian hill
30 float slope = -2.0 * d * uBandTight * band;
31 vec3 N = normalize(vec3(-slope * 0.18, 0.0, 1.0));
32
33 // Iridescence — hue driven by normal tilt + position + time
34 float t = N.x * 0.45 + uv.x * 1.40 + uTime * 0.04;
35 vec3 col = pal(t, uPalA, uPalB, uPalC, uPalD);
36
37 // Blinn-Phong — Fresnel rim + specular catch-light (additive)
38 float fresnel = pow(1.0 - dot(N, V), 3.0);
39 float spec = pow(dot(normalize(L + V), N), 80.0);
40
41 float a = band * uAlpha;
42 gl_FragColor = vec4(col * a + (col * fresnel * 0.55
43 + vec3(spec) * 1.1) * a,
44 min(a + (fresnel * 0.4 + spec * 0.9) * a, 1.0));
45}
1// Animation orchestrator — two phases
2// Phase 1: sweep band left→right (1100ms, easeOutQuart)
3// Phase 2: fade alpha 1→0 (700ms, easeInOutCubic)
4
5const easeOutQuart = p => 1 - Math.pow(1 - p, 4)
6const easeInOutCubic = p => p < 0.5
7 ? 4 * p * p * p
8 : 1 - Math.pow(-2 * p + 2, 3) / 2
9
10export function playSweep(ctrl, opts = {}) {
11 const sweepMs = opts.sweepMs ?? 1100
12 const outroMs = opts.outroMs ?? 700
13 const midpoint = opts.midpoint ?? 0.5
14 let cancelled = false
15
16 ;(async () => {
17 ctrl.setAlpha(1)
18 ctrl.setProgress(0)
19
20 // Phase 1 — sweep
21 let midFired = false
22 const t0 = performance.now()
23 await new Promise(resolve => {
24 const tick = () => {
25 if (cancelled) { resolve(); return }
26 const raw = Math.min(1, (performance.now() - t0) / sweepMs)
27 const progress = easeOutQuart(raw)
28 ctrl.setProgress(progress)
29 if (!midFired && progress >= midpoint) {
30 midFired = true
31 opts.onMidpoint?.() // ← swap page content here
32 }
33 if (raw < 1) requestAnimationFrame(tick)
34 else resolve()
35 }
36 requestAnimationFrame(tick)
37 })
38
39 // Phase 2 — fade out
40 const t1 = performance.now()
41 await new Promise(resolve => {
42 const tick = () => {
43 if (cancelled) { resolve(); return }
44 const raw = Math.min(1, (performance.now() - t1) / outroMs)
45 ctrl.setAlpha(1 - easeInOutCubic(raw))
46 if (raw < 1) requestAnimationFrame(tick)
47 else resolve()
48 }
49 requestAnimationFrame(tick)
50 })
51
52 opts.onComplete?.()
53 })()
54
55 return { cancel: () => { cancelled = true } }
56}
1// Cosine palette formula by Inigo Quilez — public domain
2// color(t) = a + b * cos( 2π * (c*t + d) )
3// Original 6 from glimm npm · New 8 derived from Figma swatches
4
5export const PALETTES = {
6 // ── Original 6 ──────────────────────────────────────────────────────────
7 prism: { a:[0.46,0.88,0.33], b:[0.60,0.58,0.74], c:[0.5,0.5,0.5], d:[0.54,0.22,0.84] },
8 berry: { a:[0.92,0.36,0.56], b:[0.10,0.14,0.37], c:[0.5,0.5,0.5], d:[0.84,0.11,0.50] },
9 lagoon: { a:[0.58,0.64,0.52], b:[0.64,0.30,0.50], c:[0.5,0.5,0.5], d:[0.37,0.66,0.89] },
10 citrus: { a:[0.55,0.61,0.61], b:[0.54,0.41,0.63], c:[0.5,0.5,0.5], d:[0.66,0.92,0.23] },
11 azure: { a:[0.13,0.61,1.00], b:[0.15,0.14,0.00], c:[0.5,0.5,0.5], d:[0.29,0.98,0.74] },
12 ember: { a:[0.83,0.61,0.60], b:[0.20,0.41,0.62], c:[0.5,0.5,0.5], d:[0.73,0.05,0.36] },
13 // ── From Figma swatches ──────────────────────────────────────────────────
14 crimson: { a:[0.50,0.05,0.05], b:[0.40,0.05,0.05], c:[0.5,0.5,0.5], d:[0.00,0.50,0.50] },
15 galaxy: { a:[0.50,0.10,0.50], b:[0.45,0.10,0.45], c:[0.5,0.5,0.5], d:[0.20,0.50,0.80] },
16 steel: { a:[0.60,0.64,0.70], b:[0.32,0.33,0.27], c:[0.5,0.5,0.5], d:[0.00,0.00,0.00] },
17 fuchsia: { a:[0.58,0.05,0.48], b:[0.35,0.05,0.35], c:[0.5,0.5,0.5], d:[0.00,0.50,0.50] },
18 teal: { a:[0.22,0.42,0.38], b:[0.15,0.35,0.30], c:[0.5,0.5,0.5], d:[0.50,0.00,0.20] },
19 cosmic: { a:[0.47,0.29,0.50], b:[0.47,0.27,0.41], c:[0.5,0.5,0.5], d:[0.50,0.00,0.30] },
20 sunrise: { a:[0.57,0.55,0.23], b:[0.41,0.30,0.22], c:[0.5,0.5,0.5], d:[0.00,0.00,0.50] },
21 inferno: { a:[0.50,0.41,0.31], b:[0.49,0.38,0.18], c:[0.5,0.5,0.5], d:[0.00,0.00,0.00] },
22}
23
14export function resolvePalette(p) {
15 if (!p) return PALETTES.prism
16 if (typeof p === 'string') return PALETTES[p] ?? PALETTES.prism
17 return p
18}