API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Pixel Disintegration
Click the card to disintegrate it into thousands of particles sampled from the rendered pixels. Click again to reassemble. A Thanos snap for HTML.
click to snap — click again to reassemble
1. A tweet card lives inside a
<canvas layoutsubtree> — fully styled with CSS
2. drawElementImage() renders the live HTML into the canvas bitmap
3. getImageData() reads every pixel's color and position to seed particles
4. Particles explode outward with velocity, gravity, and drag — then reassemble on demand
This effect requires pixel-level access to rendered HTML — impossible without HTML-in-Canvas.
A richly styled HTML profile card rendered into canvas, then disintegrated into thousands of color-sampled particles on click. Particles explode outward with physics-based drift, then reassemble on a second click — a Thanos snap effect powered by getImageData.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pixel Disintegration — 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;
}
h1 {
font-size: 1.15rem;
font-weight: 700;
text-align: center;
margin-bottom: 0.25rem;
}
.page-subtitle {
text-align: center;
font-size: 0.8rem;
color: #6a6a80;
margin-bottom: 1.5rem;
max-width: 420px;
line-height: 1.4;
}
/* ============================================================
Canvas container
============================================================ */
.scene {
position: relative;
width: 420px;
max-width: 100%;
cursor: pointer;
}
#canvas {
width: 100%;
height: auto;
display: block;
border-radius: 20px;
}
/* ============================================================
Card styles (rendered inside the canvas via drawElementImage)
============================================================ */
.card {
width: 420px;
padding: 2rem;
background: linear-gradient(160deg, #16162a 0%, #0d0d1a 100%);
font-family: "Inter", system-ui, -apple-system, sans-serif;
color: #f0f0f0;
position: relative;
overflow: hidden;
}
.card-glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.card-glow-1 {
top: -80px;
right: -60px;
width: 280px;
height: 280px;
background: radial-gradient(
circle,
rgba(240, 65, 108, 0.18) 0%,
transparent 70%
);
}
.card-glow-2 {
bottom: -50px;
left: -40px;
width: 220px;
height: 220px;
background: radial-gradient(
circle,
rgba(108, 65, 240, 0.12) 0%,
transparent 70%
);
}
.card-inner {
position: relative;
z-index: 1;
}
.avatar-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #f0416c 0%, #6c41f0 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
flex-shrink: 0;
box-shadow: 0 4px 24px rgba(240, 65, 108, 0.3);
}
.avatar-info h2 {
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 0.1rem;
letter-spacing: -0.01em;
}
.avatar-info .handle {
font-size: 0.78rem;
color: #6a6a80;
font-weight: 400;
}
.tweet-body {
font-size: 0.88rem;
color: #d0d0e0;
line-height: 1.6;
margin-bottom: 1.15rem;
}
.tweet-body .highlight {
color: #a78bfa;
font-weight: 500;
}
.tweet-media {
width: 100%;
height: 140px;
border-radius: 14px;
background: linear-gradient(
135deg,
#1a1a3a 0%,
#0f0f2a 40%,
#1a0a2a 100%
);
margin-bottom: 1.15rem;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.media-pattern {
position: absolute;
inset: 0;
opacity: 0.15;
background-image: radial-gradient(
circle at 20% 40%,
#f0416c 1px,
transparent 1px
),
radial-gradient(circle at 60% 25%, #6c41f0 1.5px, transparent 1.5px),
radial-gradient(circle at 80% 70%, #a78bfa 1px, transparent 1px),
radial-gradient(circle at 35% 75%, #f0416c 2px, transparent 2px),
radial-gradient(circle at 50% 50%, #6c41f0 1px, transparent 1px);
background-size: 80px 80px, 60px 60px, 90px 90px, 70px 70px, 50px 50px;
}
.media-text {
font-size: 0.72rem;
color: #4a4a60;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.tweet-actions {
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding-top: 0.75rem;
border-top: 1px solid #1e1e38;
}
.action {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
color: #4a4a60;
font-weight: 500;
}
.action-icon {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
}
.action-reply .action-icon {
color: #4a9eff;
}
.action-repost .action-icon {
color: #00c875;
}
.action-like .action-icon {
color: #f0416c;
}
.action-share .action-icon {
color: #6c41f0;
}
/* ============================================================
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.intact {
background: #00c875;
}
.status-dot.disintegrating {
background: #f0416c;
animation: pulse 0.6s ease-in-out infinite alternate;
}
.status-dot.scattered {
background: #6c41f0;
}
.status-dot.reassembling {
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;
}
.card {
width: 100%;
padding: 1.5rem;
}
}
</style>
</head>
<body>
<h1>Pixel Disintegration</h1>
<p class="page-subtitle">
Click the card to disintegrate it into thousands of particles sampled from
the rendered pixels. Click again to reassemble. A Thanos snap for HTML.
</p>
<div class="scene" id="scene">
<canvas id="canvas" width="840" height="1120" layoutsubtree>
<div class="card" id="card">
<div class="card-glow card-glow-1"></div>
<div class="card-glow card-glow-2"></div>
<div class="card-inner">
<div class="avatar-row">
<div class="avatar">✨</div>
<div class="avatar-info">
<h2>Mika Torres</h2>
<div class="handle">@mika_builds</div>
</div>
</div>
<p class="tweet-body">
Just discovered you can render
<span class="highlight">full HTML + CSS</span> directly into a
canvas and then explode the pixels into particles. The future of
the web is absolutely wild.
<span class="highlight">#HTMLInCanvas</span>
</p>
<div class="tweet-media">
<div class="media-pattern"></div>
<span class="media-text">html-in-canvas preview</span>
</div>
<div class="tweet-actions">
<div class="action action-reply">
<div class="action-icon">↩</div>
<span>247</span>
</div>
<div class="action action-repost">
<div class="action-icon">↻</div>
<span>1.2k</span>
</div>
<div class="action action-like">
<div class="action-icon">♥</div>
<span>4.8k</span>
</div>
<div class="action action-share">
<div class="action-icon">↗</div>
<span>Share</span>
</div>
</div>
</div>
</div>
</canvas>
</div>
<div class="status-bar">
<div class="status-dot intact" id="status-dot"></div>
<span id="status-text">Intact — click to disintegrate</span>
</div>
<p class="hint">
<kbd>click</kbd> to snap — <kbd>click</kbd> again to reassemble
</p>
<div class="pipeline-info">
<p>
<strong>1.</strong> A tweet card lives inside a
<code><canvas layoutsubtree></code> — fully styled with CSS
</p>
<p>
<strong>2.</strong>
<span class="hl">drawElementImage()</span> renders the live HTML into
the canvas bitmap
</p>
<p>
<strong>3.</strong>
<span class="hl">getImageData()</span> reads every pixel's color and
position to seed particles
</p>
<p>
<strong>4.</strong> Particles <span class="hl">explode outward</span>
with velocity, gravity, and drag — then reassemble on demand
</p>
<p class="note">
This effect requires pixel-level access to rendered HTML — impossible
without HTML-in-Canvas.
</p>
</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 card = $("card");
const statusDot = $("status-dot");
const statusText = $("status-text");
const ctx = canvas.getContext("2d");
/* ==============================================================
State machine
============================================================== */
const State = {
INTACT: "intact",
DISINTEGRATING: "disintegrating",
SCATTERED: "scattered",
REASSEMBLING: "reassembling",
};
let state = State.INTACT;
let particles = [];
let animationProgress = 0;
let lastFrameTime = 0;
/* ==============================================================
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);
const h = Math.round(rect.height * dpr);
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(() => {
syncCanvasSize();
if (state === State.INTACT) {
requestRepaint();
}
});
ro.observe(scene);
/* ==============================================================
Paint handler — draws the HTML card into the canvas
============================================================== */
canvas.onpaint = () => {
if (state !== State.INTACT) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(card, 0, 0, canvas.width, canvas.height);
};
/* ==============================================================
Particle system
============================================================== */
/* Sampling density — controls particle count vs. performance */
const SAMPLE_STEP = 4;
/* Physics constants */
const DISINTEGRATE_DURATION = 1.8;
const REASSEMBLE_DURATION = 1.4;
/**
* Harvest pixel data from the canvas and build the particle array.
* We sample every SAMPLE_STEP pixels to keep the count manageable
* while still capturing the full visual appearance.
*/
function createParticles() {
const w = canvas.width;
const h = canvas.height;
const imageData = ctx.getImageData(0, 0, w, h);
const data = imageData.data;
particles = [];
for (let y = 0; y < h; y += SAMPLE_STEP) {
for (let x = 0; x < w; x += SAMPLE_STEP) {
const i = (y * w + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
/* Skip fully transparent pixels */
if (a < 10) continue;
/* Normalized position from card center */
const cx = w / 2;
const cy = h / 2;
const dx = x - cx;
const dy = y - cy;
/* Disintegration sweeps left-to-right: particles on the right
start moving first, creating the classic snap wave */
const normalizedX = x / w;
/* Random explosion angle biased outward from center */
const baseAngle = Math.atan2(dy, dx);
const spread = (Math.random() - 0.5) * 1.2;
const angle = baseAngle + spread;
/* Speed varies: further from center = faster */
const distFromCenter = Math.sqrt(dx * dx + dy * dy);
const maxDist = Math.sqrt(cx * cx + cy * cy);
const normalizedDist = distFromCenter / maxDist;
const speed =
(80 + Math.random() * 200 + normalizedDist * 120) *
(0.6 + Math.random() * 0.8);
particles.push({
/* Original (home) position */
homeX: x,
homeY: y,
/* Current animated position */
x: x,
y: y,
/* Velocity for explosion */
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
/* Color */
r: r,
g: g,
b: b,
a: a / 255,
/* Per-particle timing: wave delay based on X position */
delay: normalizedX * 0.5 + Math.random() * 0.15,
/* Size variation */
size: SAMPLE_STEP * (0.6 + Math.random() * 0.6),
/* Gravity and drag per-particle for organic feel */
gravity: 20 + Math.random() * 60,
drag: 0.96 + Math.random() * 0.03,
/* Rotation for visual interest */
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 4,
/* Fade timing */
fadeStart: 0.5 + Math.random() * 0.3,
});
}
}
}
/* ==============================================================
Render particles onto the canvas
============================================================== */
function renderParticles(progress, isReassembling) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
/* Compute per-particle progress accounting for wave delay */
let t;
if (isReassembling) {
/* Reassembly: reverse the delay order (right-side returns last) */
const reverseDelay = (1 - p.delay / 0.65) * 0.4;
t = Math.max(0, Math.min(1, (progress - reverseDelay) / 0.6));
} else {
t = Math.max(0, Math.min(1, (progress - p.delay) / 0.5));
}
if (t <= 0 && !isReassembling) {
/* Particle hasn't started moving yet — draw at home */
ctx.globalAlpha = p.a;
ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`;
ctx.fillRect(p.homeX, p.homeY, SAMPLE_STEP, SAMPLE_STEP);
continue;
}
let px, py, alpha, size;
if (isReassembling) {
/* Ease-out: particles decelerate as they approach home */
const ease = 1 - Math.pow(1 - t, 3);
px = p.x + (p.homeX - p.x) * ease;
py = p.y + (p.homeY - p.y) * ease;
alpha = p.a * (0.3 + t * 0.7);
size = p.size * (0.4 + t * 0.6);
} else {
/* Ease-in: particles accelerate outward */
const ease = t * t;
/* Apply velocity with drag */
const dragFactor = Math.pow(p.drag, t * 60);
const dt = ease;
px = p.homeX + p.vx * dt * dragFactor;
py = p.homeY + p.vy * dt * dragFactor + p.gravity * dt * dt;
/* Fade out */
alpha =
t > p.fadeStart
? p.a * (1 - (t - p.fadeStart) / (1 - p.fadeStart))
: p.a;
size = p.size * (1 + t * 0.5);
}
if (alpha < 0.01) continue;
ctx.globalAlpha = Math.max(0, alpha);
ctx.fillStyle = `rgb(${p.r},${p.g},${p.b})`;
/* Draw as a small rotated square for organic feel */
ctx.save();
ctx.translate(px, py);
ctx.rotate(p.rotation + p.rotationSpeed * t);
const half = size / 2;
ctx.fillRect(-half, -half, size, size);
ctx.restore();
}
ctx.globalAlpha = 1;
}
/* ==============================================================
Animation loop
============================================================== */
function animate(timestamp) {
if (state === State.INTACT) return;
if (!lastFrameTime) lastFrameTime = timestamp;
const dt = (timestamp - lastFrameTime) / 1000;
lastFrameTime = timestamp;
const duration =
state === State.DISINTEGRATING
? DISINTEGRATE_DURATION
: REASSEMBLE_DURATION;
animationProgress += dt / duration;
if (animationProgress >= 1) {
animationProgress = 1;
if (state === State.DISINTEGRATING) {
state = State.SCATTERED;
updateStatus();
/* Render final scattered state */
renderParticles(1, false);
return;
}
if (state === State.REASSEMBLING) {
state = State.INTACT;
updateStatus();
particles = [];
/* Re-render the original HTML card */
requestRepaint();
return;
}
}
renderParticles(
animationProgress,
state === State.REASSEMBLING,
);
requestAnimationFrame(animate);
}
/* ==============================================================
Click handler — toggle disintegrate / reassemble
============================================================== */
scene.addEventListener("click", () => {
if (state === State.INTACT) {
/* Capture the rendered HTML card into pixels */
syncCanvasSize();
/* Ensure the card is drawn before sampling */
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(card, 0, 0, canvas.width, canvas.height);
createParticles();
state = State.DISINTEGRATING;
animationProgress = 0;
lastFrameTime = 0;
updateStatus();
requestAnimationFrame(animate);
return;
}
if (state === State.SCATTERED) {
state = State.REASSEMBLING;
animationProgress = 0;
lastFrameTime = 0;
updateStatus();
requestAnimationFrame(animate);
return;
}
/* Ignore clicks during animation */
});
/* ==============================================================
Status indicator
============================================================== */
function updateStatus() {
statusDot.className = "status-dot " + state;
const messages = {
[State.INTACT]: "Intact \u2014 click to disintegrate",
[State.DISINTEGRATING]: "Disintegrating\u2026",
[State.SCATTERED]: "Scattered \u2014 click to reassemble",
[State.REASSEMBLING]: "Reassembling\u2026",
};
statusText.textContent = messages[state];
}
/* ==============================================================
Initial paint
============================================================== */
syncCanvasSize();
requestRepaint();
</script>
</body>
</html>