API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Two distinct HTML text layouts morph between each other using canvas pixel manipulation — crossfade, dissolve, wave wipe, and pixel sort transitions that CSS alone cannot achieve across different DOM structures.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Morphing Text Transitions — HTML-in-Canvas</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;800&display=swap"
rel="stylesheet"
/>
<style>
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body,
:host {
margin: 0;
min-height: 100%;
background: #0a0a0f;
color: #f0f0f0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem;
overflow-x: hidden;
}
/* ============================================================
Controls
============================================================ */
.controls {
display: flex;
gap: 0.4rem;
margin-bottom: 1rem;
flex-wrap: wrap;
justify-content: center;
max-width: 420px;
}
.controls button {
padding: 0.35rem 0.7rem;
background: #14141f;
border: 1px solid #282840;
border-radius: 6px;
color: #8a8aaf;
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.01em;
}
.controls button:hover {
border-color: #6c41f0;
color: #c0b0ff;
}
.controls button.active {
background: #6c41f0;
border-color: #6c41f0;
color: #fff;
}
.morph-btn {
display: block;
margin: 0 auto 1rem;
padding: 0.5rem 1.6rem;
background: linear-gradient(135deg, #6c41f0 0%, #f0416c 100%);
border: none;
border-radius: 8px;
color: #fff;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
letter-spacing: 0.02em;
}
.morph-btn:hover {
opacity: 0.85;
}
.morph-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============================================================
Canvas container
============================================================ */
.scene {
position: relative;
width: 420px;
max-width: 100%;
}
#canvas {
width: 100%;
height: auto;
display: grid;
grid-template-areas: "stack";
border-radius: 16px;
}
/*
* Both layouts live in the same grid cell so they overlay each
* other. Position: absolute doesn't work on direct canvas
* children (Chrome forces them static), but display: grid does
* — and grid-area: stack lets two children share the same area.
*
* They're both always laid out so each has a cached paint
* record, which drawElementImage requires. We control which
* one is "shown" purely via canvas paint code (which one we
* draw in onpaint), not via display: none.
*/
#canvas > .layout-a,
#canvas > .layout-b {
grid-area: stack;
}
/* ============================================================
Text layout A — editorial magazine style
============================================================ */
.layout-a {
width: 420px;
padding: 2.5rem 2rem;
background: linear-gradient(160deg, #16162a 0%, #0d0d1a 100%);
font-family: "Inter", system-ui, -apple-system, sans-serif;
color: #f0f0f0;
position: relative;
overflow: hidden;
}
.layout-a .glow {
position: absolute;
top: -60px;
right: -40px;
width: 260px;
height: 260px;
background: radial-gradient(
circle,
rgba(108, 65, 240, 0.2) 0%,
transparent 70%
);
pointer-events: none;
}
.layout-a .content {
position: relative;
z-index: 1;
}
.layout-a .eyebrow {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2em;
color: #a78bfa;
margin-bottom: 0.75rem;
}
.layout-a .headline {
font-size: 1.8rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.03em;
margin-bottom: 0.75rem;
background: linear-gradient(135deg, #f0f0f0 30%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.layout-a .body-text {
font-size: 0.82rem;
color: #8a8aaf;
line-height: 1.65;
margin-bottom: 1.25rem;
}
.layout-a .tag-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.layout-a .tag {
padding: 0.2rem 0.55rem;
background: rgba(108, 65, 240, 0.12);
border: 1px solid rgba(108, 65, 240, 0.25);
border-radius: 100px;
font-size: 0.62rem;
color: #a78bfa;
font-weight: 500;
}
.layout-a .divider {
width: 40px;
height: 2px;
background: linear-gradient(90deg, #6c41f0, transparent);
margin-bottom: 1rem;
}
/* ============================================================
Text layout B — code announcement / changelog style
============================================================ */
.layout-b {
width: 420px;
padding: 2rem;
background: linear-gradient(160deg, #0d1a1a 0%, #0a0f1a 100%);
font-family: "Inter", system-ui, -apple-system, sans-serif;
color: #f0f0f0;
position: relative;
overflow: hidden;
}
.layout-b .glow {
position: absolute;
bottom: -50px;
left: -30px;
width: 240px;
height: 240px;
background: radial-gradient(
circle,
rgba(0, 229, 185, 0.15) 0%,
transparent 70%
);
pointer-events: none;
}
.layout-b .content {
position: relative;
z-index: 1;
}
.layout-b .version-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
background: rgba(0, 229, 185, 0.1);
border: 1px solid rgba(0, 229, 185, 0.3);
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
color: #00e5b9;
margin-bottom: 0.75rem;
font-variant-numeric: tabular-nums;
}
.layout-b .title {
font-size: 1.3rem;
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
color: #e0f0ea;
}
.layout-b .subtitle {
font-size: 0.78rem;
color: #5a8a80;
line-height: 1.5;
margin-bottom: 1.25rem;
}
.layout-b .feature-list {
list-style: none;
padding: 0;
margin: 0 0 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.layout-b .feature-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.75rem;
color: #8ab0a5;
line-height: 1.5;
}
.layout-b .feature-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
background: rgba(0, 229, 185, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: #00e5b9;
margin-top: 1px;
}
.layout-b .code-block {
background: #0a1218;
border: 1px solid #1a2e2a;
border-radius: 8px;
padding: 0.75rem 0.85rem;
font-family: "Courier New", monospace;
font-size: 0.65rem;
line-height: 1.7;
color: #7aaa9a;
overflow: hidden;
}
.layout-b .code-block .kw {
color: #00e5b9;
}
.layout-b .code-block .str {
color: #f0a040;
}
.layout-b .code-block .cm {
color: #3a5a50;
}
/* ============================================================
Status indicator + hint
============================================================ */
.status-bar {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
color: #4a4a60;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
transition: background 0.3s;
}
.status-dot.ready {
background: #00c875;
}
.status-dot.morphing {
background: #a78bfa;
animation: pulse 0.6s ease-in-out infinite alternate;
}
@keyframes pulse {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
.hint {
margin-top: 0.75rem;
font-size: 0.7rem;
color: #4a4a60;
text-align: center;
}
.hint kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
background: #1a1a30;
border: 1px solid #333;
border-radius: 3px;
font-family: inherit;
font-size: 0.65rem;
color: #6a6a80;
}
/* ============================================================
Pipeline explanation
============================================================ */
.pipeline-info {
margin-top: 1.25rem;
max-width: 420px;
width: 100%;
padding: 0.85rem 1rem;
background: #14141f;
border: 1px solid #282840;
border-radius: 10px;
font-size: 0.72rem;
color: #6a6a80;
line-height: 1.55;
}
.pipeline-info p {
margin-bottom: 0.3rem;
}
.pipeline-info p:last-child {
margin-bottom: 0;
}
.pipeline-info strong {
color: #8a8aaf;
}
.pipeline-info .hl {
color: #f0416c;
}
.pipeline-info code {
color: #6c41f0;
font-family: "Courier New", monospace;
font-size: 0.68rem;
}
.pipeline-info .note {
color: #3a3a50;
font-size: 0.65rem;
margin-top: 0.4rem;
}
@media (max-width: 480px) {
body,
:host {
padding: 1rem 0.75rem;
}
.layout-a,
.layout-b {
width: 100%;
}
.layout-a {
padding: 2rem 1.5rem;
}
.layout-b {
padding: 1.5rem;
}
.layout-a .headline {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="controls" id="controls">
<button class="active" data-mode="crossfade">Crossfade</button>
<button data-mode="dissolve">Dissolve</button>
<button data-mode="wave">Wave Wipe</button>
<button data-mode="pixelsort">Pixel Sort</button>
</div>
<button class="morph-btn" id="morph-btn">Morph</button>
<div class="scene" id="scene">
<canvas id="canvas" width="840" height="1120" layoutsubtree>
<!-- Layout A: Magazine editorial style -->
<div class="layout-a" id="layout-a">
<div class="glow"></div>
<div class="content">
<div class="eyebrow">The Future of the Web</div>
<div class="divider"></div>
<div class="headline">Pixels Meet the DOM</div>
<p class="body-text">
For the first time, browsers can render full HTML and CSS directly
into a canvas bitmap. This unlocks visual effects that were
previously impossible — from particle explosions to shader
pipelines, all driven by real DOM content.
</p>
<div class="tag-row">
<span class="tag">HTML-in-Canvas</span>
<span class="tag">Creative Coding</span>
<span class="tag">Web Standards</span>
</div>
</div>
</div>
<!-- Layout B: Changelog / code announcement style -->
<div class="layout-b" id="layout-b">
<div class="glow"></div>
<div class="content">
<div class="version-badge">spec v1.0</div>
<div class="title">drawElementImage() Shipped</div>
<p class="subtitle">
Render any DOM element into a canvas context with full CSS fidelity.
No screenshots, no hacks — native pixel access to live HTML.
</p>
<ul class="feature-list">
<li class="feature-item">
<div class="feature-icon">✓</div>
<span>Pixel-perfect HTML rendering into 2D and WebGL contexts</span>
</li>
<li class="feature-item">
<div class="feature-icon">✓</div>
<span>Full getImageData() access for post-processing effects</span>
</li>
<li class="feature-item">
<div class="feature-icon">✓</div>
<span>Compositing with other canvas draw calls and shaders</span>
</li>
</ul>
<div class="code-block">
<span class="cm">// Render HTML into canvas</span><br />
<span class="kw">ctx</span>.drawElementImage(<span class="str">el</span>, 0, 0, w, h);
</div>
</div>
</div>
</canvas>
</div>
<div class="status-bar">
<div class="status-dot ready" id="status-dot"></div>
<span id="status-text">Showing layout A — ready to morph</span>
</div>
<script>
// Resolve the script's root: a ShadowRoot when mounted via shadow
// DOM, or `document` when this file is served standalone.
const root = window.__demoRoot ?? document;
const $ = (id) => root.getElementById(id);
/* ==============================================================
DOM references
============================================================== */
const scene = $("scene");
const canvas = $("canvas");
const layoutA = $("layout-a");
const layoutB = $("layout-b");
const morphBtn = $("morph-btn");
const statusDot = $("status-dot");
const statusText = $("status-text");
const controlsEl = $("controls");
const ctx = canvas.getContext("2d");
/* ==============================================================
State
============================================================== */
let currentLayout = "a"; /* 'a' or 'b' */
let transitionMode = "crossfade";
let isMorphing = false;
let morphProgress = 0;
let morphStart = 0;
/* Pixel buffers for source and target */
let sourcePixels = null;
let targetPixels = null;
/* Noise seed for dissolve pattern */
let noiseSeed = null;
const MORPH_DURATION = 1.6; /* seconds */
/* ==============================================================
Canvas sizing — match CSS layout at device pixel ratio
============================================================== */
function syncCanvasSize() {
const rect = scene.getBoundingClientRect();
const dpr = devicePixelRatio;
const w = Math.round(rect.width * dpr);
/* Both layouts are always laid out (grid-stack), so use the
taller of the two for the canvas aspect ratio. */
const aRect = layoutA.getBoundingClientRect();
const bRect = layoutB.getBoundingClientRect();
const layoutW = Math.max(aRect.width, bRect.width);
const layoutH = Math.max(aRect.height, bRect.height);
const aspect = layoutH / layoutW;
const h = Math.round(w * aspect);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function requestRepaint() {
if (canvas.requestPaint) {
canvas.requestPaint();
} else if (canvas.onpaint) {
canvas.onpaint();
}
}
const ro = new ResizeObserver(() => {
if (!isMorphing) {
syncCanvasSize();
requestRepaint();
}
});
ro.observe(scene);
/* ==============================================================
Paint handler — draws the current layout into the canvas
============================================================== */
canvas.onpaint = () => {
if (isMorphing) return;
const el = currentLayout === "a" ? layoutA : layoutB;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(el, 0, 0, canvas.width, canvas.height);
};
/* ==============================================================
Capture a layout's pixels into an ImageData buffer.
Both layouts are always laid out and have cached paint
records, so no display toggling is needed.
============================================================== */
function captureLayout(layoutEl) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(layoutEl, 0, 0, canvas.width, canvas.height);
return ctx.getImageData(0, 0, canvas.width, canvas.height);
}
/* ==============================================================
Noise generation for dissolve effect
============================================================== */
function generateNoise(w, h) {
const buf = new Float32Array(w * h);
/* Simple value noise with some spatial coherence */
const scale = 8;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
/* Mix random with spatial coordinates for organic feel */
const base = Math.random();
const spatial =
(Math.sin(x / scale + y * 0.7) * 0.5 + 0.5) * 0.3 +
(Math.cos(y / scale - x * 0.3) * 0.5 + 0.5) * 0.2;
buf[y * w + x] = base * 0.5 + spatial;
}
}
return buf;
}
/* ==============================================================
Transition algorithms — all operate on raw pixel data
============================================================== */
/**
* Crossfade: simple alpha blend between source and target.
* Clean and classic, but with a luminance-weighted curve that
* keeps bright text legible throughout the transition.
*/
function transitionCrossfade(out, src, tgt, w, h, t) {
const d = out.data;
const s = src.data;
const g = tgt.data;
const len = d.length;
/* Smooth ease-in-out curve */
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
for (let i = 0; i < len; i += 4) {
/* Luminance-weighted: bright pixels transition slightly faster
so text remains sharp during the crossfade */
const srcLum = (s[i] * 0.299 + s[i + 1] * 0.587 + s[i + 2] * 0.114) / 255;
const tgtLum = (g[i] * 0.299 + g[i + 1] * 0.587 + g[i + 2] * 0.114) / 255;
const lumBias = (srcLum + tgtLum) * 0.08;
const localT = Math.min(1, Math.max(0, ease + (lumBias - 0.08)));
d[i] = s[i] + (g[i] - s[i]) * localT;
d[i + 1] = s[i + 1] + (g[i + 1] - s[i + 1]) * localT;
d[i + 2] = s[i + 2] + (g[i + 2] - s[i + 2]) * localT;
d[i + 3] = s[i + 3] + (g[i + 3] - s[i + 3]) * localT;
}
}
/**
* Dissolve: threshold a noise pattern against progress,
* revealing the target wherever noise < t. Creates an organic,
* film-grain dissolution between the two layouts.
*/
function transitionDissolve(out, src, tgt, w, h, t) {
const d = out.data;
const s = src.data;
const g = tgt.data;
/* Ease curve for smooth start/end */
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
/* Soft edge width — pixels near the threshold get blended */
const edgeWidth = 0.08;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const pi = idx * 4;
const n = noiseSeed[idx];
/* Soft threshold: blend over edgeWidth range */
let blend;
if (n < ease - edgeWidth) {
blend = 1;
} else if (n > ease + edgeWidth) {
blend = 0;
} else {
blend = 1 - (n - (ease - edgeWidth)) / (edgeWidth * 2);
}
d[pi] = s[pi] + (g[pi] - s[pi]) * blend;
d[pi + 1] = s[pi + 1] + (g[pi + 1] - s[pi + 1]) * blend;
d[pi + 2] = s[pi + 2] + (g[pi + 2] - s[pi + 2]) * blend;
d[pi + 3] = s[pi + 3] + (g[pi + 3] - s[pi + 3]) * blend;
}
}
}
/**
* Wave wipe: a sinusoidal boundary sweeps across the canvas,
* revealing the target layout with a wavy edge. Combines
* horizontal sweep with vertical wave displacement.
*/
function transitionWave(out, src, tgt, w, h, t) {
const d = out.data;
const s = src.data;
const g = tgt.data;
/* Ease the overall progress */
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
/* Wave parameters */
const waveAmp = w * 0.06; /* wave amplitude in pixels */
const waveFreq = 4; /* wave cycles across height */
const edgeSoftness = w * 0.03; /* soft edge in pixels */
/* Sweep position: from left edge to right edge */
const sweepX = ease * (w + waveAmp * 2) - waveAmp;
for (let y = 0; y < h; y++) {
/* Sinusoidal wave offset based on Y position and time */
const waveOffset =
Math.sin((y / h) * Math.PI * waveFreq + t * Math.PI * 2) *
waveAmp;
const boundary = sweepX + waveOffset;
for (let x = 0; x < w; x++) {
const pi = (y * w + x) * 4;
const dist = x - boundary;
let blend;
if (dist < -edgeSoftness) {
blend = 1; /* fully target */
} else if (dist > edgeSoftness) {
blend = 0; /* fully source */
} else {
/* Smooth step across the edge */
const u = (dist + edgeSoftness) / (edgeSoftness * 2);
blend = 1 - u * u * (3 - 2 * u);
}
d[pi] = s[pi] + (g[pi] - s[pi]) * blend;
d[pi + 1] = s[pi + 1] + (g[pi + 1] - s[pi + 1]) * blend;
d[pi + 2] = s[pi + 2] + (g[pi + 2] - s[pi + 2]) * blend;
d[pi + 3] = s[pi + 3] + (g[pi + 3] - s[pi + 3]) * blend;
}
}
}
/**
* Pixel sort: sorts pixels by luminance in horizontal bands,
* creating a glitch-art effect that gradually resolves into
* the target layout. Mimics the "pixel sorting" aesthetic
* popular in creative coding.
*/
function transitionPixelSort(out, src, tgt, w, h, t) {
const d = out.data;
const s = src.data;
const g = tgt.data;
/* Ease with a sharp middle peak for the sort intensity */
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
/* Sort intensity peaks at t=0.5, creating max chaos in the middle */
const sortIntensity = 1 - Math.abs(t - 0.5) * 2;
const sortStrength = sortIntensity * sortIntensity * 0.4;
/* First: blend source and target based on ease */
const blended = new Uint8ClampedArray(d.length);
for (let i = 0; i < d.length; i += 4) {
blended[i] = s[i] + (g[i] - s[i]) * ease;
blended[i + 1] = s[i + 1] + (g[i + 1] - s[i + 1]) * ease;
blended[i + 2] = s[i + 2] + (g[i + 2] - s[i + 2]) * ease;
blended[i + 3] = s[i + 3] + (g[i + 3] - s[i + 3]) * ease;
}
if (sortStrength < 0.01) {
/* No sorting needed — just copy blended result */
d.set(blended);
return;
}
/* Sort horizontal bands by luminance */
const bandHeight = Math.max(1, Math.round(2 + sortIntensity * 4));
for (let bandY = 0; bandY < h; bandY += bandHeight) {
const rowEnd = Math.min(bandY + bandHeight, h);
/* Collect pixel columns in this band */
const cols = [];
for (let x = 0; x < w; x++) {
/* Average luminance across the band for this column */
let lum = 0;
let count = 0;
for (let y = bandY; y < rowEnd; y++) {
const pi = (y * w + x) * 4;
lum +=
blended[pi] * 0.299 +
blended[pi + 1] * 0.587 +
blended[pi + 2] * 0.114;
count++;
}
cols.push({ x, lum: lum / count });
}
/* Partially sort: blend between original and sorted positions */
const sorted = cols.slice().sort((a, b) => a.lum - b.lum);
for (let i = 0; i < cols.length; i++) {
const origX = cols[i].x;
const sortedX = sorted[i].x;
/* Interpolate between original and sorted column position */
const srcX = Math.round(
origX + (sortedX - origX) * sortStrength
);
const clampedX = Math.max(0, Math.min(w - 1, srcX));
for (let y = bandY; y < rowEnd; y++) {
const dstIdx = (y * w + origX) * 4;
const srcIdx = (y * w + clampedX) * 4;
d[dstIdx] = blended[srcIdx];
d[dstIdx + 1] = blended[srcIdx + 1];
d[dstIdx + 2] = blended[srcIdx + 2];
d[dstIdx + 3] = blended[srcIdx + 3];
}
}
}
}
/* Transition dispatch */
const transitions = {
crossfade: transitionCrossfade,
dissolve: transitionDissolve,
wave: transitionWave,
pixelsort: transitionPixelSort,
};
/* ==============================================================
Controls — transition mode selection
============================================================== */
controlsEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-mode]");
if (!btn || isMorphing) return;
transitionMode = btn.dataset.mode;
controlsEl
.querySelectorAll("button")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
});
/* ==============================================================
Morph trigger
============================================================== */
morphBtn.addEventListener("click", startMorph);
function startMorph() {
if (isMorphing) return;
const w = canvas.width;
const h = canvas.height;
/* Determine source and target */
const srcEl = currentLayout === "a" ? layoutA : layoutB;
const tgtEl = currentLayout === "a" ? layoutB : layoutA;
/* Capture both layouts' pixels at the current canvas dims.
Both are always laid out (grid stack), so both have cached
paint records. */
sourcePixels = captureLayout(srcEl);
targetPixels = captureLayout(tgtEl);
/* Generate noise for dissolve */
if (transitionMode === "dissolve") {
noiseSeed = generateNoise(w, h);
}
isMorphing = true;
morphProgress = 0;
morphStart = 0;
morphBtn.disabled = true;
updateStatus("morphing", "Morphing\u2026");
requestAnimationFrame(animateMorph);
}
/* ==============================================================
Morph animation loop
============================================================== */
function animateMorph(timestamp) {
if (!morphStart) morphStart = timestamp;
const elapsed = (timestamp - morphStart) / 1000;
morphProgress = Math.min(1, elapsed / MORPH_DURATION);
const w = canvas.width;
const h = canvas.height;
/* Create output buffer */
const output = ctx.createImageData(w, h);
/* Run the selected transition */
transitions[transitionMode](
output,
sourcePixels,
targetPixels,
w,
h,
morphProgress
);
/* Write the blended frame to canvas */
ctx.putImageData(output, 0, 0);
if (morphProgress < 1) {
requestAnimationFrame(animateMorph);
return;
}
/* Morph complete — switch to target layout */
currentLayout = currentLayout === "a" ? "b" : "a";
isMorphing = false;
morphBtn.disabled = false;
sourcePixels = null;
targetPixels = null;
noiseSeed = null;
/* Re-render the target layout cleanly */
requestRepaint();
const label = currentLayout === "a" ? "A" : "B";
updateStatus("ready", `Showing layout ${label} \u2014 ready to morph`);
}
/* ==============================================================
Status updates
============================================================== */
function updateStatus(state, text) {
statusDot.className = "status-dot " + state;
statusText.textContent = text;
}
/* ==============================================================
Initial setup — both layouts always laid out via grid stack.
============================================================== */
syncCanvasSize();
requestRepaint();
</script>
</body>
</html>