Shader Gradient Background
A full-viewport animated gradient painted by a hand-written WebGL fragment shader — flowing fractal noise blends four palette colors in real time, with live controls for scheme, speed, grain and warp, plus a graceful CSS fallback when WebGL is unavailable.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
min-height: 100dvh;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85em;
background: rgba(255, 255, 255, 0.06);
padding: 0.1em 0.38em;
border-radius: 6px;
color: var(--accent-2);
}
/* ---------- Canvas + fallback ---------- */
.gradient-canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
display: block;
z-index: 0;
}
.gradient-canvas.css-fallback {
background: linear-gradient(120deg, #8b5cf6, #22d3ee, #34d399, #fbbf24);
background-size: 300% 300%;
animation: driftFallback 18s ease infinite;
}
@keyframes driftFallback {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ---------- Layout ---------- */
.stage {
position: relative;
z-index: 1;
min-height: 100dvh;
padding: clamp(20px, 4vw, 48px);
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 24px;
}
.hero {
max-width: 40ch;
align-self: end;
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 6px 12px;
border-radius: 999px;
color: var(--ink);
background: rgba(12, 13, 16, 0.55);
border: 1px solid var(--line-2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.hero h1 {
margin: 16px 0 10px;
font-size: clamp(28px, 5vw, 46px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.05;
text-shadow: 0 2px 30px rgba(0, 0, 0, 0.35);
}
.hero p {
margin: 0;
color: rgba(233, 234, 240, 0.92);
font-size: 15px;
max-width: 44ch;
padding: 12px 14px;
border-radius: var(--r-md);
background: rgba(12, 13, 16, 0.4);
border: 1px solid var(--line);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* ---------- Panel ---------- */
.panel {
width: 300px;
max-width: min(88vw, 340px);
background: rgba(21, 22, 27, 0.72);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.65);
overflow: hidden;
align-self: start;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.panel-title {
display: flex;
align-items: center;
gap: 10px;
}
.panel-title h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.01em;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.18);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--ink);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.icon-btn:hover {
background: #262833;
border-color: var(--line-2);
}
.chev {
width: 8px;
height: 8px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
transition: transform 0.2s ease;
margin-top: -3px;
}
.panel.collapsed .chev {
transform: rotate(-135deg);
margin-top: 3px;
}
.panel-body {
padding: 16px;
display: grid;
gap: 16px;
transition: opacity 0.2s ease;
}
.panel.collapsed .panel-body {
display: none;
}
.field {
display: grid;
gap: 9px;
}
.field-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.field-label output {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: var(--accent-2);
text-transform: none;
}
/* ---------- Swatches ---------- */
.swatches {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.swatch {
position: relative;
height: 34px;
border-radius: var(--r-sm);
border: 2px solid transparent;
cursor: pointer;
padding: 0;
outline: none;
transition: transform 0.15s ease, border-color 0.15s ease;
}
.swatch:hover {
transform: translateY(-2px);
}
.swatch[aria-checked="true"] {
border-color: var(--ink);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
}
.swatch:focus-visible {
border-color: var(--accent-2);
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.35);
}
/* ---------- Sliders ---------- */
.slider-field {
cursor: pointer;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
transition: transform 0.12s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.12);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--surface);
cursor: pointer;
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.35);
}
/* ---------- Buttons ---------- */
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 14px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.btn:hover {
background: #262833;
border-color: var(--line-2);
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.35);
}
.btn-primary {
background: linear-gradient(180deg, #9a6bff, var(--accent));
border-color: transparent;
color: #fff;
}
.btn-primary:hover {
background: linear-gradient(180deg, #a97dff, #7c4ff0);
}
.btn-primary[aria-pressed="true"] {
background: var(--surface-2);
color: var(--ink);
border-color: var(--line-2);
}
.ico-pause {
width: 10px;
height: 12px;
border-left: 3px solid currentColor;
border-right: 3px solid currentColor;
}
.btn-primary[aria-pressed="true"] .ico-pause {
width: 0;
height: 0;
border-left: 9px solid currentColor;
border-right: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
}
.meta {
margin: 2px 0 0;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.02em;
}
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
body {
overflow: auto;
}
.stage {
grid-template-columns: 1fr;
align-items: start;
}
.hero {
align-self: start;
min-height: 0;
order: 1;
}
.panel {
order: 2;
width: 100%;
max-width: 420px;
}
}
@media (max-width: 520px) {
.stage {
padding: 16px;
gap: 16px;
}
.hero p {
font-size: 14px;
}
.swatch {
height: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.gradient-canvas.css-fallback {
animation: none;
}
}/* Shader Gradient Background — vanilla WebGL, no libraries. */
(function () {
"use strict";
// ---- Color schemes: 4 stops each, normalized 0..1 RGB ----
const HEX = (h) => [
parseInt(h.slice(1, 3), 16) / 255,
parseInt(h.slice(3, 5), 16) / 255,
parseInt(h.slice(5, 7), 16) / 255,
];
const SCHEMES = [
{ id: "aurora", label: "Aurora", colors: ["#8b5cf6", "#22d3ee", "#34d399", "#0c0d10"] },
{ id: "ember", label: "Ember", colors: ["#f87171", "#fbbf24", "#8b5cf6", "#1d1f27"] },
{ id: "mint", label: "Mint", colors: ["#34d399", "#22d3ee", "#0ea5e9", "#0c0d10"] },
{ id: "candy", label: "Candy", colors: ["#f472b6", "#a78bfa", "#22d3ee", "#15161b"] },
{ id: "dusk", label: "Dusk", colors: ["#1e3a8a", "#8b5cf6", "#f472b6", "#fbbf24"] },
];
const canvas = document.getElementById("gl");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const state = {
scheme: 0,
speed: 1.0,
warp: 0.55,
grain: 0.06,
paused: reduceMotion,
};
// ---------- Shader sources ----------
const VERT = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;
const FRAG = `
precision highp float;
uniform vec2 u_res;
uniform float u_time;
uniform float u_warp;
uniform float u_grain;
uniform vec3 u_c0;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
// hash + value noise
float hash(vec2 p){
p = fract(p * vec2(123.34, 345.45));
p += dot(p, p + 34.345);
return fract(p.x * p.y);
}
float noise(vec2 p){
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p){
float v = 0.0;
float amp = 0.5;
for (int i = 0; i < 5; i++){
v += amp * noise(p);
p *= 2.0;
amp *= 0.5;
}
return v;
}
float rand(vec2 co){
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
void main(){
vec2 uv = gl_FragCoord.xy / u_res.xy;
float aspect = u_res.x / u_res.y;
vec2 p = uv;
p.x *= aspect;
float t = u_time * 0.15;
// domain warp — feed noise into noise
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t * 0.8)));
vec2 r = vec2(
fbm(p + u_warp * 2.0 * q + vec2(1.7, 9.2) + 0.15 * t),
fbm(p + u_warp * 2.0 * q + vec2(8.3, 2.8) - 0.12 * t)
);
float f = fbm(p + u_warp * 2.0 * r);
// map field to palette across three blends
vec3 col = mix(u_c0, u_c1, clamp(f * 1.7, 0.0, 1.0));
col = mix(col, u_c2, clamp(length(q) * 0.9, 0.0, 1.0));
col = mix(col, u_c3, clamp(r.x * 0.65, 0.0, 1.0));
// subtle vignette for depth
float vig = smoothstep(1.25, 0.25, distance(uv, vec2(0.5)));
col *= 0.65 + 0.35 * vig;
// film grain
float g = (rand(uv * u_res.xy + u_time) - 0.5) * u_grain;
col += g;
gl_FragColor = vec4(col, 1.0);
}
`;
// ---------- WebGL bootstrap ----------
function createShader(gl, type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(s));
gl.deleteShader(s);
return null;
}
return s;
}
function initGL() {
const gl =
canvas.getContext("webgl", { antialias: false, powerPreference: "low-power" }) ||
canvas.getContext("experimental-webgl");
if (!gl) return null;
const vs = createShader(gl, gl.VERTEX_SHADER, VERT);
const fs = createShader(gl, gl.FRAGMENT_SHADER, FRAG);
if (!vs || !fs) return null;
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(prog));
return null;
}
gl.useProgram(prog);
// full-screen triangle
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "a_pos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
return {
gl,
prog,
u: {
res: gl.getUniformLocation(prog, "u_res"),
time: gl.getUniformLocation(prog, "u_time"),
warp: gl.getUniformLocation(prog, "u_warp"),
grain: gl.getUniformLocation(prog, "u_grain"),
c0: gl.getUniformLocation(prog, "u_c0"),
c1: gl.getUniformLocation(prog, "u_c1"),
c2: gl.getUniformLocation(prog, "u_c2"),
c3: gl.getUniformLocation(prog, "u_c3"),
},
};
}
const ctx = initGL();
const engineBadge = document.getElementById("engine-badge");
const resEl = document.getElementById("res");
const fpsEl = document.getElementById("fps");
// ---------- Fallback path ----------
if (!ctx) {
canvas.classList.add("css-fallback");
engineBadge.textContent = "CSS fallback · WebGL unavailable";
engineBadge.style.borderColor = "var(--warn)";
resEl.textContent = "no GPU context";
fpsEl.textContent = "—";
// Fallback scheme swap still works via gradient stops.
buildSwatches(false);
setupSlidersInert();
return;
}
const { gl, u } = ctx;
// ---------- Sizing ----------
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
resEl.textContent = w + "×" + h + " @" + dpr + "x";
}
if ("ResizeObserver" in window) {
new ResizeObserver(resize).observe(canvas);
} else {
window.addEventListener("resize", resize);
}
resize();
// ---------- Palette upload ----------
function applyScheme(idx) {
state.scheme = idx;
const c = SCHEMES[idx].colors.map(HEX);
gl.uniform3fv(u.c0, c[0]);
gl.uniform3fv(u.c1, c[1]);
gl.uniform3fv(u.c2, c[2]);
gl.uniform3fv(u.c3, c[3]);
document.querySelectorAll(".swatch").forEach((el, i) => {
el.setAttribute("aria-checked", i === idx ? "true" : "false");
el.tabIndex = i === idx ? 0 : -1;
});
}
// ---------- Render loop ----------
let t = 0;
let last = performance.now();
let frames = 0;
let fpsLast = last;
function frame(now) {
const dt = (now - last) / 1000;
last = now;
if (!state.paused) t += dt * state.speed;
gl.uniform2f(u.res, canvas.width, canvas.height);
gl.uniform1f(u.time, t);
gl.uniform1f(u.warp, state.warp);
gl.uniform1f(u.grain, state.grain);
gl.drawArrays(gl.TRIANGLES, 0, 3);
frames++;
if (now - fpsLast >= 500) {
fpsEl.textContent = Math.round((frames * 1000) / (now - fpsLast));
frames = 0;
fpsLast = now;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ---------- UI wiring ----------
buildSwatches(true);
applyScheme(0);
const speed = document.getElementById("speed");
const warp = document.getElementById("warp");
const grain = document.getElementById("grain");
const speedVal = document.getElementById("speed-val");
const warpVal = document.getElementById("warp-val");
const grainVal = document.getElementById("grain-val");
function syncLabels() {
speedVal.textContent = state.speed.toFixed(1) + "×";
warpVal.textContent = state.warp.toFixed(2);
grainVal.textContent = state.grain.toFixed(2);
}
speed.addEventListener("input", () => {
state.speed = speed.value / 100;
syncLabels();
});
warp.addEventListener("input", () => {
state.warp = warp.value / 100;
syncLabels();
});
grain.addEventListener("input", () => {
state.grain = grain.value / 100;
syncLabels();
});
syncLabels();
// pause
const pauseBtn = document.getElementById("pause-btn");
const pauseLabel = document.getElementById("pause-label");
function setPaused(v) {
state.paused = v;
pauseBtn.setAttribute("aria-pressed", String(v));
pauseLabel.textContent = v ? "Play" : "Pause";
}
pauseBtn.addEventListener("click", () => setPaused(!state.paused));
if (reduceMotion) setPaused(true);
// randomize
document.getElementById("random-btn").addEventListener("click", () => {
applyScheme(Math.floor(Math.random() * SCHEMES.length));
speed.value = 40 + Math.floor(Math.random() * 200);
warp.value = 25 + Math.floor(Math.random() * 65);
grain.value = Math.floor(Math.random() * 18);
state.speed = speed.value / 100;
state.warp = warp.value / 100;
state.grain = grain.value / 100;
syncLabels();
if (state.paused) setPaused(false);
});
// collapse
const panel = document.getElementById("panel");
const collapseBtn = document.getElementById("collapse-btn");
collapseBtn.addEventListener("click", () => {
const open = panel.classList.toggle("collapsed");
collapseBtn.setAttribute("aria-expanded", String(!open));
});
// ---------- Swatch builder (shared with fallback) ----------
function buildSwatches(interactive) {
const wrap = document.getElementById("schemes");
wrap.innerHTML = "";
SCHEMES.forEach((s, i) => {
const b = document.createElement("button");
b.className = "swatch";
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", i === 0 ? "true" : "false");
b.setAttribute("aria-label", s.label);
b.title = s.label;
b.tabIndex = i === 0 ? 0 : -1;
b.style.background =
"linear-gradient(135deg," + s.colors[0] + "," + s.colors[1] + "," + s.colors[2] + ")";
b.addEventListener("click", () => selectScheme(i, interactive));
b.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
selectScheme((i + 1) % SCHEMES.length, interactive, true);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
selectScheme((i - 1 + SCHEMES.length) % SCHEMES.length, interactive, true);
}
});
wrap.appendChild(b);
});
}
function selectScheme(i, interactive, focus) {
if (interactive) {
applyScheme(i);
} else {
// fallback: swap the CSS gradient stops
const c = SCHEMES[i].colors;
canvas.style.background =
"linear-gradient(120deg," + c[0] + "," + c[1] + "," + c[2] + "," + c[3] + ")";
canvas.style.backgroundSize = "300% 300%";
document.querySelectorAll(".swatch").forEach((el, k) => {
el.setAttribute("aria-checked", k === i ? "true" : "false");
el.tabIndex = k === i ? 0 : -1;
});
}
if (focus) document.querySelectorAll(".swatch")[i].focus();
}
// ---------- Fallback: disable live-only sliders ----------
function setupSlidersInert() {
["speed", "warp", "grain"].forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.disabled = true;
el.title = "Requires WebGL";
}
});
const pb = document.getElementById("pause-btn");
if (pb) pb.disabled = true;
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shader Gradient Background</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- GPU-rendered gradient lives here; CSS fallback class added if WebGL is missing -->
<canvas id="gl" class="gradient-canvas" aria-hidden="true"></canvas>
<main class="stage">
<header class="hero">
<span class="badge" id="engine-badge">WebGL · fragment shader</span>
<h1>Shader Gradient Background</h1>
<p>
A full-viewport color field painted by a hand-written WebGL fragment shader.
Fractal noise warps four palette colors, animated from a <code>u_time</code>
uniform on every <code>requestAnimationFrame</code> tick.
</p>
</header>
<section class="panel" aria-label="Gradient controls" id="panel">
<div class="panel-head">
<div class="panel-title">
<span class="dot" aria-hidden="true"></span>
<h2>Controls</h2>
</div>
<button class="icon-btn" id="collapse-btn" aria-expanded="true" aria-controls="panel-body" title="Collapse panel">
<span class="chev" aria-hidden="true"></span>
<span class="sr-only">Toggle panel</span>
</button>
</div>
<div class="panel-body" id="panel-body">
<div class="field">
<span class="field-label">Color scheme</span>
<div class="swatches" id="schemes" role="radiogroup" aria-label="Color scheme"></div>
</div>
<label class="field slider-field" for="speed">
<span class="field-label">Speed <output id="speed-val">1.0×</output></span>
<input type="range" id="speed" min="0" max="300" value="100" step="1" />
</label>
<label class="field slider-field" for="warp">
<span class="field-label">Warp <output id="warp-val">0.55</output></span>
<input type="range" id="warp" min="0" max="100" value="55" step="1" />
</label>
<label class="field slider-field" for="grain">
<span class="field-label">Grain <output id="grain-val">0.06</output></span>
<input type="range" id="grain" min="0" max="100" value="6" step="1" />
</label>
<div class="actions">
<button class="btn btn-primary" id="pause-btn" aria-pressed="false">
<span class="ico-pause" aria-hidden="true"></span>
<span id="pause-label">Pause</span>
</button>
<button class="btn" id="random-btn">Randomize</button>
</div>
<p class="meta"><span id="fps">–</span> fps · <span id="res">–</span></p>
</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>Shader Gradient Background
A living gradient rendered entirely on the GPU. Instead of a static CSS linear-gradient, this component compiles a tiny vertex + fragment shader pair with raw WebGL, draws a single full-screen triangle, and drives an animated fractal-Brownian-motion noise field from a u_time uniform ticked by requestAnimationFrame. The noise warps and folds four palette colors into a smooth, endlessly flowing field that never repeats.
The floating control panel is fully interactive: switch between five curated color schemes, scrub animation speed, add filmic grain, and dial the domain-warp intensity — every slider feeds a uniform the shader reads each frame, so changes are instant. A pause toggle freezes time, and the panel can be collapsed to admire the background. The canvas is resize-aware via ResizeObserver and respects devicePixelRatio for crisp rendering on retina displays.
This demonstrates the low-level WebGL rendering pipeline — shader compilation, uniform plumbing, and a rAF render loop — while degrading gracefully: if getContext("webgl") fails, an animated CSS gradient stands in and the panel reports the fallback. Controls are keyboard-operable with visible focus rings, and animation honours prefers-reduced-motion.