Canvas Scramble Text
A DPR-aware HTML canvas headline that resolves out of random glyphs with an RGB channel-split fringe and rolling scanline glitch, retriggering on hover or click and cycling through a set of phrases.
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 {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
background:
radial-gradient(1100px 600px at 20% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 520px at 110% 20%, rgba(34, 211, 238, 0.12), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.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;
}
/* ---------- Card ---------- */
.stage {
width: 100%;
max-width: 640px;
}
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 40%),
var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 26px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 30px 70px -30px rgba(0, 0, 0, 0.75);
}
.card__head {
margin-bottom: 18px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--muted);
text-transform: lowercase;
font-family: "JetBrains Mono", ui-monospace, monospace;
background: var(--surface-2);
border: 1px solid var(--line);
padding: 5px 10px;
border-radius: 999px;
}
.tag .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-2);
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.18);
}
h1 {
margin: 14px 0 6px;
font-size: clamp(20px, 5vw, 26px);
font-weight: 700;
letter-spacing: -0.01em;
}
.sub {
margin: 0;
max-width: 46ch;
color: var(--muted);
font-size: 14px;
}
/* ---------- Canvas screen ---------- */
.screen {
position: relative;
margin: 0 0 18px;
border-radius: var(--r-md);
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(139, 92, 246, 0.06), rgba(34, 211, 238, 0.04)),
#0a0a0d;
overflow: hidden;
cursor: crosshair;
isolation: isolate;
}
.screen::after {
/* subtle vignette + film grain overlay */
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(120% 80% at 50% 50%, transparent 55%, rgba(0, 0, 0, 0.5));
mix-blend-mode: multiply;
}
.screen:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.screen__canvas {
display: block;
width: 100%;
height: 200px;
}
.screen__hint {
position: absolute;
right: 10px;
bottom: 9px;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 999px;
background: rgba(12, 13, 16, 0.6);
border: 1px solid var(--line);
backdrop-filter: blur(4px);
transition: opacity 0.3s ease;
z-index: 1;
}
.screen.is-busy .screen__hint {
opacity: 0;
}
/* ---------- Controls ---------- */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px 18px;
padding: 14px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.ctrl {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
color: var(--ink);
}
.ctrl__label {
color: var(--muted);
white-space: nowrap;
}
/* range */
.ctrl--range {
flex: 1 1 200px;
min-width: 180px;
}
.ctrl--range input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
outline: none;
cursor: pointer;
}
.ctrl--range input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--surface-2);
box-shadow: 0 0 0 1px var(--line-2);
cursor: grab;
}
.ctrl--range input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--surface-2);
box-shadow: 0 0 0 1px var(--line-2);
cursor: grab;
}
.ctrl--range input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
border-radius: 999px;
}
.ctrl__val {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
color: var(--accent-2);
min-width: 40px;
text-align: right;
}
/* switch */
.ctrl--switch {
cursor: pointer;
user-select: none;
}
.ctrl--switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ctrl__track {
position: relative;
width: 38px;
height: 22px;
border-radius: 999px;
background: #2b2d38;
border: 1px solid var(--line);
transition: background 0.2s ease;
flex: none;
}
.ctrl__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
transition: transform 0.2s ease;
}
.ctrl--switch input:checked + .ctrl__track {
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
.ctrl--switch input:checked + .ctrl__track .ctrl__thumb {
transform: translateX(16px);
}
.ctrl--switch input:focus-visible + .ctrl__track {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
/* button */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent), var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 8px 12px;
cursor: pointer;
transition: transform 0.08s ease, border-color 0.2s ease, background 0.2s ease;
}
.btn:hover {
border-color: var(--accent);
background: rgba(139, 92, 246, 0.12);
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn kbd {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11px;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 5px;
padding: 1px 6px;
}
/* ---------- Footer ---------- */
.card__foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.meta {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.02em;
}
.meta--mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
color: var(--ok);
}
@media (max-width: 520px) {
.card {
padding: 20px;
}
.screen__canvas {
height: 160px;
}
.btn {
margin-left: 0;
}
.ctrl--range {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.ctrl__thumb,
.ctrl__track,
.screen__hint {
transition: none;
}
}/* Canvas Scramble Text
* DPR-aware Canvas 2D headline that decodes out of random glyphs with an
* RGB channel-split fringe and a rolling scanline glitch band.
* Vanilla JS, zero dependencies.
*/
(function () {
"use strict";
var canvas = document.getElementById("scramble");
var screen = document.getElementById("screen");
var live = document.getElementById("live");
var hint = document.getElementById("hint");
var intensityEl = document.getElementById("intensity");
var intensityVal = document.getElementById("intensity-val");
var splitEl = document.getElementById("split");
var nextBtn = document.getElementById("next");
var metaPhrase = document.getElementById("meta-phrase");
var metaFps = document.getElementById("meta-fps");
if (!canvas || !canvas.getContext) {
// Graceful fallback: no canvas support -> plain text.
if (screen) screen.textContent = "Canvas Scramble Text";
return;
}
var ctx = canvas.getContext("2d");
var prefersReduced =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var PHRASES = [
"STEAL THIS",
"DECODE MODE",
"GLITCH.EXE",
"SHIP FAST",
];
var GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#%@&";
// ---- state ----
var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
var cssW = 0;
var cssH = 0;
var phraseIndex = 0;
var chars = []; // per-character resolve state
var settled = false;
var settleAt = 0; // timestamp when fully resolved (for auto-cycle)
var lastFrame = 0;
var fpsSmooth = 60;
var scanline = 0; // 0..1 vertical position of glitch band
var intensity = 0.55;
var splitOn = true;
function rand(pool) {
return pool.charAt((Math.random() * pool.length) | 0);
}
// ---- sizing (DPR aware) ----
function resize() {
var rect = canvas.getBoundingClientRect();
cssW = Math.max(1, rect.width);
cssH = Math.max(1, rect.height);
dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels, crisp on retina
}
// ---- (re)start a scramble for the current phrase ----
function start() {
var text = PHRASES[phraseIndex];
chars = [];
for (var i = 0; i < text.length; i++) {
var target = text.charAt(i);
chars.push({
target: target,
current: target === " " ? " " : rand(GLYPHS),
// spaces resolve instantly; others get a staggered start + duration
startDelay: target === " " ? 0 : Math.random() * 420,
duration: 260 + Math.random() * 560,
elapsed: 0,
resolved: target === " ",
flicker: 0,
});
}
settled = false;
if (screen) screen.classList.add("is-busy");
if (metaPhrase) metaPhrase.textContent =
"phrase " + (phraseIndex + 1) + " / " + PHRASES.length;
if (live) live.textContent = "Decoding: " + text;
}
function nextPhrase() {
phraseIndex = (phraseIndex + 1) % PHRASES.length;
start();
}
// ---- per-frame simulation ----
function update(dt) {
if (settled) return;
var allDone = true;
var flickerRate = 0.4 + intensity * 0.55; // higher intensity = faster churn
for (var i = 0; i < chars.length; i++) {
var c = chars[i];
if (c.resolved) continue;
allDone = false;
c.elapsed += dt;
if (c.elapsed < c.startDelay) continue;
var t = (c.elapsed - c.startDelay) / c.duration;
if (t >= 1) {
c.current = c.target;
c.resolved = true;
} else {
// churn the glyph occasionally rather than every frame
c.flicker += dt;
var step = 34 + (1 - flickerRate) * 60;
if (c.flicker >= step) {
c.flicker = 0;
c.current = rand(GLYPHS);
}
}
}
if (allDone && !settled) {
settled = true;
settleAt = performance.now();
if (screen) screen.classList.remove("is-busy");
if (live) live.textContent = "Resolved: " + PHRASES[phraseIndex];
}
}
// ---- rendering ----
function fontSize() {
// fit the longest phrase to width
var longest = 0;
for (var i = 0; i < PHRASES.length; i++)
longest = Math.max(longest, PHRASES[i].length);
var byWidth = (cssW * 1.7) / longest;
return Math.max(20, Math.min(64, byWidth));
}
function draw(now) {
ctx.clearRect(0, 0, cssW, cssH);
var fs = fontSize();
ctx.font = "700 " + fs + "px 'JetBrains Mono', monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
var str = "";
for (var i = 0; i < chars.length; i++) str += chars[i].current;
var cx = cssW / 2;
var cy = cssH / 2;
// channel-split offset scales with intensity and eases out once settled
var restAmp = settled ? Math.max(0, 1 - (now - settleAt) / 700) : 1;
var amp = (1.5 + intensity * 7) * (0.35 + 0.65 * restAmp);
if (splitOn && amp > 0.4) {
var jx = (Math.random() - 0.5) * amp;
var jy = (Math.random() - 0.5) * amp * 0.5;
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = "rgba(248, 113, 113, 0.9)"; // red channel
ctx.fillText(str, cx - amp + jx, cy + jy);
ctx.fillStyle = "rgba(34, 211, 238, 0.9)"; // cyan channel
ctx.fillText(str, cx + amp - jx, cy - jy);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(233, 234, 240, 0.96)"; // white core
ctx.fillText(str, cx, cy);
} else {
ctx.fillStyle = "#e9eaf0";
ctx.fillText(str, cx, cy);
}
// rolling scanline glitch band
if (!prefersReduced && intensity > 0.02) {
var speed = 0.00035 + intensity * 0.0009;
scanline = (scanline + speed * 16) % 1.4;
var bandY = (scanline - 0.2) * cssH;
var bandH = 6 + intensity * 22;
var grad = ctx.createLinearGradient(0, bandY, 0, bandY + bandH);
grad.addColorStop(0, "rgba(139,92,246,0)");
grad.addColorStop(0.5, "rgba(139,92,246," + (0.12 + intensity * 0.16) + ")");
grad.addColorStop(1, "rgba(34,211,238,0)");
ctx.fillStyle = grad;
ctx.fillRect(0, bandY, cssW, bandH);
// occasional horizontal tear slice on the text row
if (Math.random() < intensity * 0.25) {
var sliceH = 4 + Math.random() * 10;
var sliceY = cy - fs / 2 + Math.random() * fs;
var shift = (Math.random() - 0.5) * intensity * 26;
try {
var slice = ctx.getImageData(0, sliceY * dpr, canvas.width, sliceH * dpr);
ctx.putImageData(slice, shift * dpr, sliceY * dpr);
} catch (e) {
/* getImageData can throw in rare tainted contexts — ignore */
}
}
}
// faint scan-grid texture
ctx.strokeStyle = "rgba(255,255,255,0.025)";
ctx.lineWidth = 1;
for (var y = 0; y < cssH; y += 3) {
ctx.beginPath();
ctx.moveTo(0, y + 0.5);
ctx.lineTo(cssW, y + 0.5);
ctx.stroke();
}
}
// ---- main loop ----
function loop(now) {
var dt = lastFrame ? now - lastFrame : 16;
lastFrame = now;
if (dt > 0) {
var fps = 1000 / dt;
fpsSmooth += (fps - fpsSmooth) * 0.1;
if (metaFps) metaFps.textContent = Math.round(fpsSmooth) + " fps";
}
update(dt);
draw(now);
// auto-cycle a couple seconds after settling
if (settled && now - settleAt > 2200) {
nextPhrase();
}
requestAnimationFrame(loop);
}
// ---- controls ----
intensityEl.addEventListener("input", function () {
intensity = intensityEl.value / 100;
intensityVal.textContent = intensityEl.value + "%";
});
splitEl.addEventListener("change", function () {
splitOn = splitEl.checked;
});
nextBtn.addEventListener("click", nextPhrase);
function retrigger() {
if (!settled) return; // don't interrupt an in-progress decode
// re-scramble current phrase
start();
}
screen.addEventListener("mouseenter", retrigger);
screen.addEventListener("click", retrigger);
// keyboard access on the canvas region
screen.tabIndex = 0;
screen.setAttribute("role", "button");
screen.setAttribute("aria-label", "Re-trigger scramble effect");
screen.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
retrigger();
}
});
var resizeTimer;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resize, 120);
});
// ---- init ----
intensity = intensityEl.value / 100;
intensityVal.textContent = intensityEl.value + "%";
splitOn = splitEl.checked;
resize();
start();
requestAnimationFrame(loop);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas Scramble Text</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;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<section class="card" aria-labelledby="demo-title">
<header class="card__head">
<div class="tag" aria-hidden="true">
<span class="dot"></span> canvas 2d · dpr-aware
</div>
<h1 id="demo-title">Canvas Scramble Text</h1>
<p class="sub">
Glyphs resolve out of noise with an RGB channel-split and rolling
scanline glitch. Hover or click the canvas to re-trigger.
</p>
</header>
<figure class="screen" id="screen">
<canvas id="scramble" class="screen__canvas" role="img"
aria-label="Animated glitch headline"></canvas>
<div class="screen__hint" id="hint">hover / click to decode</div>
<figcaption class="sr-only" id="live" aria-live="polite"></figcaption>
</figure>
<div class="controls" role="group" aria-label="Effect controls">
<label class="ctrl ctrl--range" for="intensity">
<span class="ctrl__label">Glitch</span>
<input id="intensity" type="range" min="0" max="100" value="55"
aria-describedby="intensity-val" />
<output id="intensity-val" class="ctrl__val">55%</output>
</label>
<label class="ctrl ctrl--switch" for="split">
<input id="split" type="checkbox" checked />
<span class="ctrl__track" aria-hidden="true"><span class="ctrl__thumb"></span></span>
<span class="ctrl__label">Channel split</span>
</label>
<button id="next" class="btn" type="button">
Next phrase
<kbd>↵</kbd>
</button>
</div>
<footer class="card__foot">
<span class="meta" id="meta-phrase">phrase 1 / 4</span>
<span class="meta meta--mono" id="meta-fps">— fps</span>
</footer>
</section>
</main>
<script src="script.js"></script>
</body>
</html>Canvas Scramble Text
A headline rendered entirely on an HTML <canvas> that decodes out of a storm of random glyphs. Each character position runs its own countdown: while unresolved it flickers through a symbol pool, then snaps to its final letter. On top of the decode, the effect draws the text three times — offset in red, cyan, and white — for an RGB channel-split fringe, and sweeps a translucent scanline band down the frame to sell the glitch.
The demo is genuinely interactive: it auto-plays on load, re-triggers when you hover or click the canvas, and cycles through several phrases on a timer. A small control row lets you scrub the glitch intensity, toggle the channel split, and step to the next phrase. Progress and the active phrase are announced in an aria-live region so the effect is legible to assistive tech even though the visible text lives in pixels.
Under the hood it demonstrates the Canvas 2D API with device-pixel-ratio scaling: the backing store is sized to width * devicePixelRatio and the context is scaled so text stays crisp on retina displays. Animation is driven by requestAnimationFrame with delta-timed per-glyph resolution, and everything is vanilla JS with zero dependencies.