Pluggable canvas visualizer
One canvas, a registry of draw modes, and the rule that each mode owns its entire frame — including the background — so a persistence mode can wash instead of clear.
Reach for it when building
- audio visualizer modes
- metrics sparkline that switches to a heatmap
- live signal displays
- waveform displays
- generative canvas backgrounds
<div class="pcv-panel">
<div class="pcv-controls" role="group" aria-label="Visualization mode">
<button type="button" class="pcv-btn is-on" data-pcv-mode="bars">Bars + peak hold</button>
<button type="button" class="pcv-btn" data-pcv-mode="scope">Oscilloscope</button>
<button type="button" class="pcv-btn" data-pcv-mode="vu">VU needles</button>
<button type="button" class="pcv-btn" data-pcv-mode="matrix">Dot matrix</button>
<button type="button" class="pcv-btn" data-pcv-mode="radial">Radial</button>
<button type="button" class="pcv-btn" data-pcv-mode="ambience">Ambience</button>
<button type="button" class="pcv-btn" id="pcv-pause">Pause</button>
</div>
<div class="pcv-wrap"><canvas id="pcv-canvas"></canvas></div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.pcv-panel { display: flex; flex-direction: column; gap: 0.75rem; }
.pcv-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pcv-btn {
font: inherit;
font-size: 0.9375rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.pcv-btn.is-on {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
.pcv-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.pcv-wrap {
background: var(--ground);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
#pcv-canvas { display: block; width: 100%; height: 240px; }(function () {
var canvas = document.getElementById('pcv-canvas');
var context = canvas.getContext('2d');
var root = document.documentElement;
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var binCount = 64;
var spectrum = new Uint8Array(binCount);
var waveform = new Uint8Array(256);
var peaks = new Float32Array(binCount);
var needle = { position: 0, velocity: 0 };
var mode = 'bars';
var running = true;
var clock = 0;
var frameCount = 0;
// Deterministic value noise. A real analyser's hiss is genuinely random, but
// a seeded hash keeps a screenshot of frame N identical between runs, which
// is what makes this reproducible in a visual test.
function hashNoise(first, second) {
var mixed = Math.sin(first * 127.1 + second * 311.7) * 43758.5453;
return mixed - Math.floor(mixed);
}
// A synthetic signal generator so the pattern needs no audio permission and
// no media file. It has the same shape a real analyser node would hand back:
// a frequency-magnitude array and a time-domain waveform array, both 0-255.
function synthesize() {
clock += 0.016;
frameCount += 1;
for (var bin = 0; bin < binCount; bin += 1) {
var tilt = Math.pow(1 - bin / binCount, 1.6);
var beat = 0.55 + 0.45 * Math.sin(clock * 3.1 + bin * 0.08);
var slow = 0.6 + 0.4 * Math.sin(clock * 0.7 + bin * 0.02);
var noise = hashNoise(bin, frameCount) * 0.22;
spectrum[bin] = Math.max(0, Math.min(255, (tilt * beat * slow + noise) * 235));
}
for (var sample = 0; sample < waveform.length; sample += 1) {
var phase = (sample / waveform.length) * Math.PI * 2;
var value =
Math.sin(phase * 3 + clock * 5) * 0.5 +
Math.sin(phase * 7 + clock * 2) * 0.25 +
Math.sin(phase * 13 + clock * 9) * 0.12 +
(hashNoise(sample, frameCount) - 0.5) * 0.06;
waveform[sample] = Math.max(0, Math.min(255, 128 + value * 110));
}
}
// Read the palette from the live custom properties, not a fixed table, so
// every mode follows a theme switch without the visualizer knowing it happened.
// No hex fallback: an unset token is a bug in the host page, and silently
// standing in for it would hide exactly that bug.
function palette() {
var styles = getComputedStyle(root);
return {
ground: styles.getPropertyValue('--ground').trim(),
accent: styles.getPropertyValue('--accent').trim(),
dim: styles.getPropertyValue('--dim').trim(),
ink: styles.getPropertyValue('--ink').trim()
};
}
function barHeights(count) {
var out = new Float32Array(count);
for (var index = 0; index < count; index += 1) {
var from = Math.floor((index * binCount) / count);
var to = Math.max(from + 1, Math.floor(((index + 1) * binCount) / count));
var total = 0;
for (var bin = from; bin < to; bin += 1) total += spectrum[bin];
out[index] = total / (to - from) / 255;
}
return out;
}
// Every mode is a plain (frame) => void that paints the *whole* canvas,
// background included. The host never clears between frames -- that single
// rule is what makes a persistence mode (see "ambience") possible at all,
// and it lets a consuming app register a replacement mode under an existing
// key without forking this file.
var modes = {
bars: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var count = 48,
gap = 3;
var barWidth = (width - gap * (count - 1)) / count;
var heights = barHeights(count);
for (var index = 0; index < count; index += 1) {
var value = heights[index];
// Peak hold: the marker only falls, and only at a fixed decay rate.
peaks[index] = Math.max(value, Math.max(peaks[index] - 0.015, 0));
var x = index * (barWidth + gap);
context.fillStyle = colors.accent;
context.fillRect(x, height - value * height, barWidth, value * height);
context.fillStyle = colors.ink;
context.fillRect(x, height - peaks[index] * height - 2, barWidth, 2);
}
},
scope: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
context.strokeStyle = colors.accent;
context.lineWidth = 2;
context.beginPath();
for (var sample = 0; sample < waveform.length; sample += 1) {
var x = (sample / (waveform.length - 1)) * width;
var y = height / 2 + ((waveform[sample] - 128) / 128) * (height / 2 - 8);
if (sample === 0) context.moveTo(x, y);
else context.lineTo(x, y);
}
context.stroke();
},
vu: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var total = 0;
for (var sample = 0; sample < waveform.length; sample += 1) {
var normalized = (waveform[sample] - 128) / 128;
total += normalized * normalized;
}
var rootMeanSquare = Math.sqrt(total / waveform.length);
// Spring-damper integrated once per frame, clamped just above 1 so the
// needle can overshoot into the red without leaving the dial.
var stiffness = 0.18,
damping = 0.3;
var acceleration = stiffness * (rootMeanSquare - needle.position) - damping * needle.velocity;
needle.velocity += acceleration;
needle.position = Math.max(0, Math.min(1.05, needle.position + needle.velocity));
[0.28, 0.72].forEach(function (share, dialIndex) {
var centerX = width * share;
var centerY = height * 0.86;
var radius = Math.min(width * 0.2, height * 0.72);
context.strokeStyle = colors.dim;
context.lineWidth = 2;
context.beginPath();
context.arc(centerX, centerY, radius, Math.PI * 1.17, Math.PI * 1.83);
context.stroke();
context.strokeStyle = colors.accent;
context.lineWidth = 4;
context.beginPath();
context.arc(centerX, centerY, radius, Math.PI * 1.66, Math.PI * 1.83);
context.stroke();
var wobble = dialIndex === 0 ? 1 : 0.88;
var angle = Math.PI * 1.17 + Math.PI * 0.66 * Math.min(1, needle.position * wobble);
context.strokeStyle = colors.ink;
context.lineWidth = 2.5;
context.beginPath();
context.moveTo(centerX, centerY);
context.lineTo(
centerX + Math.cos(angle) * radius * 0.94,
centerY + Math.sin(angle) * radius * 0.94
);
context.stroke();
});
},
matrix: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var columns = 40,
rows = 14;
var heights = barHeights(columns);
var cellWidth = width / columns,
cellHeight = height / rows;
var dot = Math.min(cellWidth, cellHeight) * 0.42;
for (var column = 0; column < columns; column += 1) {
var lit = heights[column] * rows;
for (var row = 0; row < rows; row += 1) {
var fromBottom = rows - 1 - row;
var intensity =
fromBottom < Math.floor(lit) ? 1 : fromBottom === Math.floor(lit) ? lit % 1 : 0;
context.globalAlpha = intensity > 0 ? 0.25 + intensity * 0.75 : 0.18;
context.fillStyle = intensity > 0 ? colors.accent : colors.dim;
context.beginPath();
context.arc(
column * cellWidth + cellWidth / 2,
row * cellHeight + cellHeight / 2,
dot,
0,
Math.PI * 2
);
context.fill();
}
}
context.globalAlpha = 1;
},
radial: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var centerX = width / 2,
centerY = height / 2;
var radius = Math.min(width, height) * 0.46;
var inner = 0.42;
var count = 72;
var heights = barHeights(count);
context.strokeStyle = colors.accent;
context.lineWidth = Math.max(2, (Math.PI * 2 * radius * inner) / count - 2);
for (var index = 0; index < count; index += 1) {
var angle = -Math.PI / 2 + (index / count) * Math.PI * 2;
var reach = inner + heights[index] * (1 - inner);
context.beginPath();
context.moveTo(centerX + Math.cos(angle) * radius * inner, centerY + Math.sin(angle) * radius * inner);
context.lineTo(centerX + Math.cos(angle) * radius * reach, centerY + Math.sin(angle) * radius * reach);
context.stroke();
}
},
ambience: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
// Persistence: a near-opaque wash over the previous frame instead of a
// clear, so the trail decays instead of vanishing. globalAlpha is used
// rather than an rgba() string built from the token, because a palette
// entry is as likely to be a CSS custom property as a hex value, and
// splicing an alpha channel into an arbitrary custom property silently
// parses to nothing.
context.globalAlpha = 0.06;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
context.globalAlpha = 1;
// The trace itself has to stay a theme color too: a hue cycled on its
// own timeline, independent of the palette, can land on a color close
// to the ground's own lightness and read as nearly invisible.
context.strokeStyle = colors.accent;
context.shadowBlur = 12;
context.shadowColor = colors.accent;
context.lineWidth = 2;
context.beginPath();
for (var sample = 0; sample < waveform.length; sample += 1) {
var x = (sample / (waveform.length - 1)) * width;
var y = height / 2 + ((waveform[sample] - 128) / 128) * (height / 2 - 10);
if (sample === 0) context.moveTo(x, y);
else context.lineTo(x, y);
}
context.stroke();
context.shadowBlur = 0;
}
};
// Size the backing buffer to the element's CSS size times the device pixel
// ratio, then remap the context so every draw function above still works in
// plain CSS pixels.
function sizeCanvas() {
var ratio = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
var targetWidth = Math.round(rect.width * ratio);
var targetHeight = Math.round(rect.height * ratio);
// Assigning canvas.width or canvas.height resets and clears the whole
// bitmap even when set to its current value (the HTML spec requires the
// reset "whenever the width and height attributes are set, whether to a
// new value or to the previous value"). Calling this every frame was
// clearing the canvas every frame, which is exactly what the host-never-
// clears rule above forbids -- it silently broke the ambience mode's
// persistence trail. Only touch the bitmap, and only reapply the
// transform, when the target size has actually changed.
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
canvas.width = targetWidth;
canvas.height = targetHeight;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
}
return true;
}
function paintFrame() {
// An element with no layout yet (display: none, not yet attached) reports
// a zero-size rect. Painting into that would divide by zero downstream in
// every mode above, so the frame is skipped rather than guarded per mode.
if (!sizeCanvas()) return;
synthesize();
var rect = canvas.getBoundingClientRect();
modes[mode]({ width: rect.width, height: rect.height, colors: palette() });
}
function tick() {
if (running) paintFrame();
requestAnimationFrame(tick);
}
function resetScratchState() {
// Scratch state belongs to the mode that wrote it. Wiping it on every
// switch is what stops a peak-hold trail or a needle's velocity from
// bleeding into a mode that never produced it.
peaks.fill(0);
needle.position = 0;
needle.velocity = 0;
context.clearRect(0, 0, canvas.width, canvas.height);
}
Array.prototype.forEach.call(document.querySelectorAll('[data-pcv-mode]'), function (button) {
button.addEventListener('click', function () {
mode = button.getAttribute('data-pcv-mode');
resetScratchState();
Array.prototype.forEach.call(document.querySelectorAll('[data-pcv-mode]'), function (other) {
other.classList.toggle('is-on', other === button);
});
if (reducedMotion) paintFrame();
});
});
var pauseButton = document.getElementById('pcv-pause');
if (reducedMotion) {
// A single static frame stands in for the animation loop entirely --
// there is nothing running for a pause control to pause.
pauseButton.disabled = true;
pauseButton.textContent = 'Static frame';
paintFrame();
} else {
pauseButton.addEventListener('click', function () {
running = !running;
pauseButton.textContent = running ? 'Pause' : 'Resume';
});
requestAnimationFrame(tick);
}
})();Paste this into an agent to rebuild the pattern from scratch.
Build a canvas visualizer that renders live data through a swappable set of draw modes, chosen from a registry rather than an if/else ladder, so a consuming app can add or override a mode by name without forking the component.
Reach for this whenever a canvas needs more than one look at the same underlying signal — an audio meter with a bars view and a needle view, a metrics sparkline that can switch to a heatmap, anything where "visualization type" is a setting rather than a fixed decision. Skip it for a single fixed chart with no mode switch in its future; the registry indirection buys nothing until there is a second mode.
The core rule is the whole pattern: a draw mode is a plain function of shape `(frame) => void` that paints the *entire* canvas on every call, including the background. The host component never clears between frames. That single constraint is what makes a persistence mode possible — a mode can wash the previous frame with a near-transparent fill instead of erasing it, producing a fading trail, which a "the host clears, then the mode draws" architecture cannot do without a special case. It also means a consuming app can register a replacement under an existing mode's key and nothing else has to change.
Each mode receives a frame object carrying the canvas's CSS width and height and a palette read fresh from `getComputedStyle(document.documentElement)` on every frame, not cached at startup, so the visualizer tracks a live theme toggle. Size the backing buffer to `clientWidth/clientHeight * devicePixelRatio`, but only when that size has actually changed — assigning `canvas.width` or `canvas.height` resets and clears the entire bitmap even when set to its current value, per the HTML spec, so doing that on every frame silently defeats the host-never-clears rule above and a persistence mode never accumulates a trail. Call `context.setTransform(dpr, 0, 0, dpr, 0, 0)` in that same branch, immediately after the resize, so every mode's drawing code can work in plain CSS pixels. Skip the frame entirely, rather than guarding every mode individually, when the element reports a zero-size layout box — that happens whenever the canvas isn't attached or visible yet, and drawing into it divides by zero downstream.
Any mode that keeps scratch state between frames — a peak-hold array, needle velocity — must have that state wiped on every mode switch, or a trail from the previous mode bleeds into the new one's first few frames. A peak-hold marker follows `peak = max(current, max(previousPeak - decay, 0))`: it snaps up instantly and falls at a fixed rate. A needle-style meter is a spring-damper integrated once per frame — `acceleration = stiffness * (target - position) - damping * velocity` — with the position clamped just past 1 so it can overshoot into a redline without ever leaving the dial. A persistence/trail mode fades the previous frame with `context.globalAlpha` rather than building an rgba() string from a palette value, because a theme token is as likely to be a CSS custom property as a literal hex color, and splicing an alpha channel into an arbitrary custom-property string silently produces nothing. Every stroke or shadow color a mode draws still has to come from that frame's palette rather than a hardcoded or procedurally generated value — a hue cycled on its own timeline, independent of the theme, can land at any moment on a color close to the ground's own lightness, measured as low as 1.07:1 against a light theme's background, with nothing in the architecture positioned to catch it.
Drive the demo from a synthetic signal generator rather than a live audio graph, so the pattern can be evaluated with no permission prompt and no media file — a few layered sine waves with noise stand in for a frequency spectrum and a waveform convincingly enough to exercise every mode. Wrap the whole animation loop in a `prefers-reduced-motion` check and render one static frame instead of starting `requestAnimationFrame`.