Web Animations Hard
WebGL Scene Switcher
Switch between three tiny raw WebGL scenes while keeping one canvas and one render loop.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #08090c;
--panel: #111319;
--line: #22262f;
--text: #e6e9ef;
--muted: #8b93a3;
--accent: #61e2c2;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.demo { min-height: 100vh; display: grid; place-items: center; padding: 32px 20px; }
.switcher {
width: min(720px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 22px;
box-shadow: 0 24px 60px -30px #000;
}
.switcher__title { margin: 0; font-size: 1.15rem; letter-spacing: -0.01em; }
.switcher__sub { margin: 4px 0 18px; color: var(--muted); font-size: 0.85rem; }
.stage {
position: relative;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: #000;
aspect-ratio: 16 / 9;
}
.stage__canvas { display: block; width: 100%; height: 100%; }
.stage__fallback {
position: absolute; inset: 0; margin: 0;
display: grid; place-items: center; color: var(--muted);
}
.stage__hud {
position: absolute; left: 10px; bottom: 10px;
display: flex; gap: 8px;
font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--accent); text-transform: uppercase; letter-spacing: 0.08em;
}
.stage__hud span {
background: #000a; border: 1px solid var(--line);
border-radius: 999px; padding: 5px 9px;
}
.controls { display: flex; gap: 8px; margin: 16px 0 12px; flex-wrap: wrap; }
.chip {
flex: 1 1 auto; padding: 9px 14px;
border-radius: 10px; border: 1px solid var(--line);
background: #171a21; color: var(--muted);
font: inherit; font-weight: 600; cursor: pointer;
transition: background .18s ease, color .18s ease, border-color .18s ease;
}
.chip:hover { color: var(--text); }
.chip[aria-selected="true"] {
background: #1b2b2a; border-color: var(--accent); color: var(--accent);
}
.chip:focus-visible, .btn:focus-visible, input:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.field { display: flex; align-items: center; gap: 10px; flex: 1 1 220px; color: var(--muted); font-size: .85rem; }
.field input { flex: 1; accent-color: var(--accent); }
.btn {
padding: 9px 16px; border-radius: 10px; border: 1px solid var(--line);
background: #171a21; color: var(--text); font: inherit; font-weight: 600; cursor: pointer;
}
.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
.hint { margin: 14px 0 0; font-size: .78rem; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
}/* WebGL Scene Switcher — one canvas, one render loop, three raw GL scenes. */
(() => {
const canvas = document.getElementById("gl");
const fallback = document.getElementById("fallback");
const hudScene = document.getElementById("hud-scene");
const hudFps = document.getElementById("hud-fps");
const speedEl = document.getElementById("speed");
const toggleEl = document.getElementById("toggle");
const tabs = [...document.querySelectorAll(".chip")];
const gl =
canvas.getContext("webgl", { antialias: true, alpha: false }) ||
canvas.getContext("experimental-webgl");
if (!gl) {
canvas.hidden = true;
fallback.hidden = false;
tabs.forEach((t) => (t.disabled = true));
toggleEl.disabled = true;
speedEl.disabled = true;
return;
}
/* ---------- GL helpers ---------- */
function compile(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(s) || "shader compile failed");
}
return s;
}
function program(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(p) || "program link failed");
}
return p;
}
function buffer(data) {
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
return b;
}
function bindAttrib(prog, name, buf, size, stride, offset) {
const loc = gl.getAttribLocation(prog, name);
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride * 4, offset * 4);
}
/* ---------- Scene 1: rotating gradient triangle ---------- */
const triProg = program(
`attribute vec2 aPos; attribute vec3 aColor;
uniform float uTime; uniform float uAspect;
varying vec3 vColor;
void main() {
float a = uTime * 0.9;
mat2 rot = mat2(cos(a), -sin(a), sin(a), cos(a));
vec2 p = rot * aPos * (0.72 + 0.08 * sin(uTime * 2.0));
p.x /= uAspect;
vColor = aColor;
gl_Position = vec4(p, 0.0, 1.0);
}`,
`precision mediump float; varying vec3 vColor;
void main() { gl_FragColor = vec4(vColor, 1.0); }`
);
// interleaved: x, y, r, g, b
const triBuf = buffer([
0.0, 0.9, 0.38, 0.89, 0.76,
-0.85, -0.6, 0.25, 0.45, 0.95,
0.85, -0.6, 0.95, 0.38, 0.62,
]);
/* ---------- Scene 2: animated wireframe grid ---------- */
const gridProg = program(
`attribute vec2 aPos; uniform float uTime; uniform float uAspect;
varying float vDepth;
void main() {
float w = sin(aPos.x * 3.0 + uTime * 1.6) * 0.12
+ cos(aPos.y * 3.4 - uTime * 1.1) * 0.12;
vec2 p = aPos;
p.y += w;
p *= 0.85;
p.x /= uAspect;
vDepth = w;
gl_Position = vec4(p, 0.0, 1.0);
}`,
`precision mediump float; varying float vDepth;
void main() {
float t = clamp(vDepth * 3.0 + 0.5, 0.0, 1.0);
gl_FragColor = vec4(mix(vec3(0.13,0.28,0.45), vec3(0.38,0.89,0.76), t), 1.0);
}`
);
const gridVerts = [];
const N = 18;
for (let i = 0; i <= N; i++) {
const t = (i / N) * 2 - 1;
gridVerts.push(t, -1, t, 1); // vertical line
gridVerts.push(-1, t, 1, t); // horizontal line
}
const gridBuf = buffer(gridVerts);
const gridCount = gridVerts.length / 2;
/* ---------- Scene 3: fullscreen plasma ---------- */
const quadProg = program(
`attribute vec2 aPos; varying vec2 vUv;
void main() { vUv = aPos; gl_Position = vec4(aPos, 0.0, 1.0); }`,
`precision highp float; varying vec2 vUv;
uniform float uTime; uniform float uAspect;
void main() {
vec2 p = vec2(vUv.x * uAspect, vUv.y) * 2.2;
float v = sin(p.x + uTime)
+ sin(p.y * 1.3 - uTime * 0.7)
+ sin((p.x + p.y) * 0.9 + uTime * 0.5)
+ sin(length(p) * 2.0 - uTime * 1.4);
v *= 0.25;
vec3 col = 0.5 + 0.5 * cos(6.2831 * (vec3(0.0, 0.33, 0.66) + v));
col *= vec3(0.55, 1.0, 0.92);
gl_FragColor = vec4(col, 1.0);
}`
);
const quadBuf = buffer([-1, -1, 3, -1, -1, 3]);
const scenes = {
triangle(time, aspect) {
gl.clearColor(0.03, 0.035, 0.05, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(triProg);
gl.uniform1f(gl.getUniformLocation(triProg, "uTime"), time);
gl.uniform1f(gl.getUniformLocation(triProg, "uAspect"), aspect);
bindAttrib(triProg, "aPos", triBuf, 2, 5, 0);
bindAttrib(triProg, "aColor", triBuf, 3, 5, 2);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
grid(time, aspect) {
gl.clearColor(0.02, 0.03, 0.04, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(gridProg);
gl.uniform1f(gl.getUniformLocation(gridProg, "uTime"), time);
gl.uniform1f(gl.getUniformLocation(gridProg, "uAspect"), aspect);
bindAttrib(gridProg, "aPos", gridBuf, 2, 2, 0);
gl.drawArrays(gl.LINES, 0, gridCount);
},
plasma(time, aspect) {
gl.useProgram(quadProg);
gl.uniform1f(gl.getUniformLocation(quadProg, "uTime"), time);
gl.uniform1f(gl.getUniformLocation(quadProg, "uAspect"), aspect);
bindAttrib(quadProg, "aPos", quadBuf, 2, 2, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
};
/* ---------- State + single render loop ---------- */
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let current = "triangle";
let playing = !reduced;
let speed = Number(speedEl.value);
let clock = 0;
let last = 0;
let frames = 0;
let fpsAt = 0;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
}
function frame(now) {
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
if (playing) clock += dt * speed;
resize();
scenes[current](clock, canvas.width / canvas.height);
frames++;
if (now - fpsAt > 500) {
hudFps.textContent = Math.round((frames * 1000) / (now - fpsAt)) + " fps";
frames = 0;
fpsAt = now;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ---------- Controls ---------- */
function select(name, focus) {
current = name;
hudScene.textContent = name;
tabs.forEach((t) => {
const on = t.dataset.scene === name;
t.setAttribute("aria-selected", String(on));
t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
});
}
tabs.forEach((tab) => {
tab.addEventListener("click", () => select(tab.dataset.scene, false));
});
document.querySelector(".controls").addEventListener("keydown", (e) => {
const i = tabs.findIndex((t) => t.dataset.scene === current);
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const d = e.key === "ArrowRight" ? 1 : -1;
select(tabs[(i + d + tabs.length) % tabs.length].dataset.scene, true);
} else if (e.key === "Home") {
e.preventDefault();
select(tabs[0].dataset.scene, true);
} else if (e.key === "End") {
e.preventDefault();
select(tabs[tabs.length - 1].dataset.scene, true);
}
});
speedEl.addEventListener("input", () => {
speed = Number(speedEl.value);
});
function setPlaying(next) {
playing = next;
toggleEl.textContent = playing ? "Pause" : "Play";
toggleEl.setAttribute("aria-pressed", String(!playing));
}
toggleEl.addEventListener("click", () => setPlaying(!playing));
setPlaying(playing);
})();<link rel="stylesheet" href="style.css" />
<main class="demo" data-demo="WebGL Scene Switcher">
<section class="switcher">
<header>
<h1 class="switcher__title">WebGL Scene Switcher</h1>
<p class="switcher__sub">One canvas, one render loop, three raw GL scenes.</p>
</header>
<div class="stage">
<canvas id="gl" class="stage__canvas" width="640" height="360"
role="img" aria-label="Animated WebGL scene"></canvas>
<p id="fallback" class="stage__fallback" hidden>WebGL is unavailable in this browser.</p>
<div class="stage__hud">
<span id="hud-scene">triangle</span>
<span id="hud-fps">— fps</span>
</div>
</div>
<div class="controls" role="tablist" aria-label="Scene">
<button class="chip" role="tab" data-scene="triangle" aria-selected="true" tabindex="0">Triangle</button>
<button class="chip" role="tab" data-scene="grid" aria-selected="false" tabindex="-1">Grid</button>
<button class="chip" role="tab" data-scene="plasma" aria-selected="false" tabindex="-1">Plasma</button>
</div>
<div class="row">
<label class="field" for="speed"><span>Speed</span>
<input id="speed" type="range" min="0" max="2" step="0.05" value="1" />
</label>
<button id="toggle" class="btn" aria-pressed="false">Pause</button>
</div>
<p class="hint">Arrow keys switch scenes while the tablist is focused.</p>
</section>
</main>
<script src="script.js"></script>import { useEffect, useRef, useState } from "react";
export function ThreeSceneSwitcher() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [active, setActive] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
const gl = canvas?.getContext("webgl");
if (gl) {
gl.clearColor(0.06, 0.1, 0.22, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
}
}, []);
return (
<section className="demo">
<h2>WebGL Scene Switcher</h2>
<canvas
ref={canvasRef}
style={{ width: "100%", height: 260, background: "#080d18" }}
onPointerMove={() => setActive(true)}
/>
<button onClick={() => setActive((v) => !v)}>{active ? "Active" : "Activate"}</button>
</section>
);
}WebGL Scene Switcher
Switch between three tiny raw WebGL scenes while keeping one canvas and one render loop.
Support notes
The WebGL examples deliberately stay raw: a canvas, context, and render loop. Use this as a small foundation for a real shader or add a renderer only when scene complexity warrants it.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.