API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Frosted Glass Backdrop
Drag the frosted panel over the cards. Switch between blur modes that go beyond CSS backdrop-filter — directional blur, tilt-shift, and custom gaussian kernels with color tinting.
drag the panel to reveal the blur over different content
1. Background cards live inside a
<canvas layoutsubtree> — fully styled with CSS
2. drawElementImage() captures the background layer as canvas pixels
3. The region behind the frost panel is processed through a custom WebGL blur shader
4. Blurred pixels + color tint are composited back, then the panel's HTML content is drawn on top via drawElementImage()
CSS backdrop-filter can only do standard gaussian blur. This demo applies directional blur, tilt-shift, and custom kernels — only possible with HTML-in-Canvas compositing between layers.
Draggable frosted glass panel with custom gaussian, directional, and tilt-shift blur effects composited between HTML rendering layers.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Frosted Glass Backdrop — 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: 520px;
line-height: 1.4;
}
/* ============================================================
Scene
============================================================ */
.scene {
position: relative;
width: 600px;
max-width: 100%;
}
/*
* The canvas is a CSS grid with a single named "stack" area.
* Both children (#bg-layer and #frost-panel) live in that area
* so they overlay each other. We can't use `position: absolute`
* on the direct children — Chrome's <canvas layoutsubtree> forces
* direct children to `position: static` regardless of CSS — so
* grid stacking + transform offsets are the workaround.
*/
#canvas {
width: 100%;
aspect-ratio: 4 / 3;
display: grid;
grid-template-areas: "stack";
border-radius: 16px;
background: #0d0d1a;
position: relative;
}
/* ============================================================
Background layer — 2x2 card grid
============================================================ */
#bg-layer {
grid-area: stack;
width: 100%;
height: 100%;
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 14px;
overflow: hidden;
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
.bg-card {
border-radius: 14px;
padding: 18px;
position: relative;
overflow: hidden;
}
/* -- Weather card ------------------------------------------ */
.card-weather {
background: linear-gradient(135deg, #1a3a5c 0%, #0d2a4a 50%, #0a1628 100%);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.sun-icon {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
box-shadow: 0 0 16px rgba(251, 191, 36, 0.4);
margin-bottom: 8px;
}
.weather-temp {
font-size: 2.6rem;
font-weight: 800;
background: linear-gradient(135deg, #60a5fa, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 4px;
}
.weather-city {
font-size: 0.8rem;
color: #94a3b8;
margin-bottom: 2px;
}
.weather-detail {
font-size: 0.65rem;
color: #475569;
}
/* -- Music card --------------------------------------------- */
.card-music {
background: linear-gradient(
135deg,
#2d1854 0%,
#1a0e3e 50%,
#110a2e 100%
);
border: 1px solid rgba(139, 92, 246, 0.2);
}
.album-art {
width: 44px;
height: 44px;
border-radius: 8px;
background: linear-gradient(135deg, #f59e0b, #ef4444, #8b5cf6);
margin-bottom: 8px;
flex-shrink: 0;
}
.track-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 2px;
}
.track-artist {
font-size: 0.68rem;
color: #a78bfa;
margin-bottom: 10px;
}
.progress-bar {
height: 3px;
background: #2a2050;
border-radius: 2px;
margin-bottom: 10px;
overflow: hidden;
}
.progress-fill {
width: 35%;
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
border-radius: 2px;
}
.playback {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
color: #c4b5fd;
}
.play-circle {
width: 30px;
height: 30px;
border-radius: 50%;
background: #8b5cf6;
display: flex;
align-items: center;
justify-content: center;
}
.play-tri {
width: 0;
height: 0;
border-left: 9px solid #fff;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
margin-left: 2px;
}
.skip-icon {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: -1px;
}
/* -- Notifications card ------------------------------------- */
.card-notif {
background: linear-gradient(135deg, #1a1a30 0%, #12121f 100%);
border: 1px solid #282840;
}
.notif-header {
font-size: 0.72rem;
font-weight: 600;
color: #8a8aaf;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.notif-item:last-child {
margin-bottom: 0;
}
.notif-dot {
width: 7px;
height: 7px;
border-radius: 50%;
margin-top: 3px;
flex-shrink: 0;
}
.notif-dot.green {
background: #00e5b9;
}
.notif-dot.purple {
background: #8b5cf6;
}
.notif-dot.amber {
background: #f59e0b;
}
.notif-text {
font-size: 0.7rem;
color: #c0c0d0;
line-height: 1.3;
}
.notif-time {
font-size: 0.58rem;
color: #4a4a60;
margin-top: 1px;
}
/* -- Chart card --------------------------------------------- */
.card-chart {
background: linear-gradient(
135deg,
#0f2027 0%,
#0a1a1a 50%,
#0d1117 100%
);
border: 1px solid rgba(0, 229, 185, 0.15);
}
.chart-title {
font-size: 0.72rem;
font-weight: 600;
color: #8a8aaf;
margin-bottom: 10px;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 5px;
height: 70px;
margin-bottom: 5px;
}
.bar {
flex: 1;
border-radius: 3px 3px 0 0;
background: linear-gradient(180deg, #00e5b9, #00bd81);
min-height: 3px;
}
.chart-labels {
display: flex;
gap: 5px;
}
.chart-labels span {
flex: 1;
text-align: center;
font-size: 0.55rem;
color: #4a4a60;
}
/* ============================================================
Frost panel (positioned inside canvas, draggable)
============================================================
Same grid cell as #bg-layer — overlaid via the "stack" area.
Position offset is via transform because position:absolute
doesn't work on direct canvas children (see #canvas comment).
The drag handler updates --panel-x / --panel-y custom props.
*/
#frost-panel {
grid-area: stack;
justify-self: start;
align-self: start;
--panel-x: 170px;
--panel-y: 115px;
transform: translate(var(--panel-x), var(--panel-y));
width: 240px;
background: transparent;
cursor: grab;
user-select: none;
-webkit-user-select: none;
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
#frost-panel.dragging {
cursor: grabbing;
}
.frost-inner {
padding: 18px;
border-radius: 16px;
background: transparent;
}
.frost-grip {
display: flex;
justify-content: center;
gap: 3px;
margin-bottom: 10px;
}
.frost-grip span {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
}
.frost-title {
font-size: 0.9rem;
font-weight: 700;
text-align: center;
margin-bottom: 3px;
color: #f0f0f0;
}
.frost-desc {
font-size: 0.64rem;
color: rgba(255, 255, 255, 0.55);
text-align: center;
line-height: 1.45;
margin-bottom: 14px;
}
.frost-toggles {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 7px;
margin-bottom: 14px;
}
.frost-toggle {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 4px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.toggle-icon {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
}
.toggle-icon.blue {
background: rgba(59, 130, 246, 0.3);
color: #60a5fa;
}
.toggle-icon.green {
background: rgba(0, 229, 185, 0.3);
color: #00e5b9;
}
.toggle-icon.purple {
background: rgba(139, 92, 246, 0.3);
color: #a78bfa;
}
.toggle-label {
font-size: 0.55rem;
color: rgba(255, 255, 255, 0.45);
}
.frost-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 0.58rem;
color: rgba(0, 229, 185, 0.7);
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #00e5b9;
}
/* ============================================================
Controls
============================================================ */
.controls {
margin-top: 1rem;
max-width: 600px;
width: 100%;
padding: 0.85rem 1rem;
background: #14141f;
border: 1px solid #282840;
border-radius: 12px;
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
align-items: flex-start;
}
.control-group {
flex: 1;
min-width: 120px;
}
.control-group label {
display: block;
font-size: 0.68rem;
color: #6a6a80;
margin-bottom: 5px;
}
.mode-buttons {
display: flex;
gap: 4px;
}
.mode-btn {
flex: 1;
padding: 5px 6px;
border: 1px solid #333355;
border-radius: 7px;
background: #1a1a30;
color: #8a8aaf;
font-family: "Inter", sans-serif;
font-size: 0.62rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-btn:hover {
background: #252540;
color: #f0f0f0;
}
.mode-btn.active {
background: rgba(108, 65, 240, 0.2);
border-color: rgba(108, 65, 240, 0.4);
color: #a78bfa;
}
input[type="range"] {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #282840;
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #6c41f0;
cursor: pointer;
}
/* ============================================================
Hint + pipeline info
============================================================ */
.hint {
margin-top: 1rem;
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-info {
margin-top: 1rem;
max-width: 600px;
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: #00e5b9;
}
.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: 640px) {
body,
:host {
padding: 1rem 0.75rem;
}
#bg-layer {
padding: 12px;
gap: 10px;
}
.bg-card {
padding: 12px;
}
.weather-temp {
font-size: 2rem;
}
#frost-panel {
left: 50px;
top: 70px;
width: 200px;
}
.frost-inner {
padding: 14px;
}
.controls {
flex-direction: column;
}
}
</style>
</head>
<body>
<h1>Frosted Glass Backdrop</h1>
<p class="page-subtitle">
Drag the frosted panel over the cards. Switch between blur modes that go
beyond CSS backdrop-filter — directional blur, tilt-shift, and custom
gaussian kernels with color tinting.
</p>
<div class="scene" id="scene">
<canvas id="canvas" layoutsubtree>
<!-- Background card grid -->
<div id="bg-layer">
<!-- Weather -->
<div class="bg-card card-weather">
<div class="sun-icon"></div>
<div class="weather-temp">24°</div>
<div class="weather-city">Los Angeles, CA</div>
<div class="weather-detail">H: 28° L: 18° | UV 7</div>
</div>
<!-- Music -->
<div class="bg-card card-music">
<div class="album-art"></div>
<div class="track-title">Midnight City</div>
<div class="track-artist">M83</div>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="playback">
<span class="skip-icon">|<</span>
<div class="play-circle"><div class="play-tri"></div></div>
<span class="skip-icon">>|</span>
</div>
</div>
<!-- Notifications -->
<div class="bg-card card-notif">
<div class="notif-header">Notifications</div>
<div class="notif-item">
<div class="notif-dot green"></div>
<div>
<div class="notif-text">Build deployed</div>
<div class="notif-time">2 min ago</div>
</div>
</div>
<div class="notif-item">
<div class="notif-dot purple"></div>
<div>
<div class="notif-text">New comment on PR #42</div>
<div class="notif-time">15 min ago</div>
</div>
</div>
<div class="notif-item">
<div class="notif-dot amber"></div>
<div>
<div class="notif-text">Security scan passed</div>
<div class="notif-time">1 hr ago</div>
</div>
</div>
</div>
<!-- Chart -->
<div class="bg-card card-chart">
<div class="chart-title">Weekly Revenue</div>
<div class="chart-bars">
<div class="bar" style="height: 40%"></div>
<div class="bar" style="height: 65%"></div>
<div class="bar" style="height: 45%"></div>
<div class="bar" style="height: 80%"></div>
<div class="bar" style="height: 55%"></div>
<div class="bar" style="height: 92%"></div>
<div class="bar" style="height: 70%"></div>
</div>
<div class="chart-labels">
<span>M</span><span>T</span><span>W</span><span>T</span
><span>F</span><span>S</span><span>S</span>
</div>
</div>
</div>
<!-- Frost panel -->
<div id="frost-panel">
<div class="frost-inner" id="frost-content">
<div class="frost-grip">
<span></span><span></span><span></span>
</div>
<div class="frost-title">Control Center</div>
<div class="frost-desc">
Custom blur composited between HTML layers
</div>
<div class="frost-toggles">
<div class="frost-toggle">
<div class="toggle-icon blue">W</div>
<span class="toggle-label">WiFi</span>
</div>
<div class="frost-toggle">
<div class="toggle-icon green">B</div>
<span class="toggle-label">Bluetooth</span>
</div>
<div class="frost-toggle">
<div class="toggle-icon purple">A</div>
<span class="toggle-label">AirDrop</span>
</div>
</div>
<div class="frost-badge">
<span class="badge-dot"></span>
Canvas Composited
</div>
</div>
</div>
</canvas>
</div>
<p class="hint">
<kbd>drag</kbd> the panel to reveal the blur over different content
</p>
<!-- Controls -->
<div class="controls" id="controls">
<div class="control-group">
<label>Blur Mode</label>
<div class="mode-buttons" id="mode-buttons">
<button class="mode-btn active" data-mode="gaussian">Gaussian</button>
<button class="mode-btn" data-mode="directional">Directional</button>
<button class="mode-btn" data-mode="tiltshift">Tilt-Shift</button>
</div>
</div>
<div class="control-group">
<label>Radius: <span id="radius-value">12</span>px</label>
<input type="range" id="radius-slider" min="2" max="30" value="12" />
</div>
<div class="control-group" id="angle-group" style="display: none">
<label>Angle: <span id="angle-value">0</span>°</label>
<input type="range" id="angle-slider" min="0" max="360" value="0" />
</div>
<div class="control-group" id="focus-group" style="display: none">
<label>Focus: <span id="focus-value">50</span>%</label>
<input type="range" id="focus-slider" min="10" max="90" value="50" />
</div>
</div>
<div class="pipeline-info">
<p>
<strong>1.</strong> Background cards live inside a
<code><canvas layoutsubtree></code> — fully styled with CSS
</p>
<p>
<strong>2.</strong>
<span class="hl">drawElementImage()</span> captures the background layer
as canvas pixels
</p>
<p>
<strong>3.</strong> The region behind the frost panel is processed
through a <span class="hl">custom WebGL blur shader</span>
</p>
<p>
<strong>4.</strong> Blurred pixels + color tint are composited back,
then the panel's HTML content is drawn on top via
<span class="hl">drawElementImage()</span>
</p>
<p class="note">
CSS backdrop-filter can only do standard gaussian blur. This demo
applies directional blur, tilt-shift, and custom kernels — only possible
with HTML-in-Canvas compositing between layers.
</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 ctx = canvas.getContext("2d");
const bgLayer = $("bg-layer");
const frostPanel = $("frost-panel");
const frostContent = $("frost-content");
/* ==============================================================
State
============================================================== */
let blurMode = "gaussian";
let blurRadius = 12;
let blurAngle = 0;
let focusY = 0.5;
/* ==============================================================
Offscreen canvases for blur processing
============================================================== */
const tempCanvas = document.createElement("canvas");
const tempCtx = tempCanvas.getContext("2d");
const blurCanvas = document.createElement("canvas");
const gl = blurCanvas.getContext("webgl", {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
alpha: true,
});
if (!gl) {
const msg = document.createElement("p");
msg.textContent = "WebGL is not available in this browser.";
msg.style.cssText = "padding:2rem;color:#f44";
canvas.replaceWith(msg);
throw new Error("WebGL unavailable");
}
/* ==============================================================
WebGL shader — configurable gaussian blur
============================================================== */
const VERT_SRC = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
v_uv.y = 1.0 - v_uv.y;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const FRAG_SRC = `
precision highp float;
uniform sampler2D u_image;
uniform vec2 u_resolution;
uniform vec2 u_direction;
uniform float u_radius;
uniform float u_tiltshift;
uniform float u_focusY;
uniform float u_focusRange;
varying vec2 v_uv;
void main() {
float radius = u_radius;
/* Tilt-shift: modulate blur radius by distance from focus line */
if (u_tiltshift > 0.5) {
float dist = abs(v_uv.y - u_focusY);
float factor = smoothstep(0.0, u_focusRange, dist);
radius *= factor;
}
/* Skip blur for negligible radius */
if (radius < 0.5) {
gl_FragColor = texture2D(u_image, v_uv);
return;
}
vec2 texel = u_direction / u_resolution;
vec4 color = vec4(0.0);
float total = 0.0;
float sigma = max(radius * 0.4, 1.0);
float coeff = -0.5 / (sigma * sigma);
/* 25-tap weighted gaussian sampling */
for (int i = -12; i <= 12; i++) {
float fi = float(i);
float offset = fi * (radius / 12.0);
float weight = exp(offset * offset * coeff);
vec2 sampleUV = clamp(v_uv + texel * offset, 0.0, 1.0);
color += texture2D(u_image, sampleUV) * weight;
total += weight;
}
gl_FragColor = color / total;
}`;
/* ----------------------------------------------------------
Shader compilation helpers
---------------------------------------------------------- */
function compileShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(s);
gl.deleteShader(s);
throw new Error("Shader: " + log);
}
return s;
}
function linkProgram(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(p);
gl.deleteProgram(p);
throw new Error("Program: " + log);
}
return p;
}
/* Build the blur program */
const vsObj = compileShader(gl.VERTEX_SHADER, VERT_SRC);
const fsObj = compileShader(gl.FRAGMENT_SHADER, FRAG_SRC);
const program = linkProgram(vsObj, fsObj);
gl.deleteShader(vsObj);
gl.deleteShader(fsObj);
/* Uniform & attribute locations */
const aPosition = gl.getAttribLocation(program, "a_position");
const uImage = gl.getUniformLocation(program, "u_image");
const uResolution = gl.getUniformLocation(program, "u_resolution");
const uDirection = gl.getUniformLocation(program, "u_direction");
const uRadius = gl.getUniformLocation(program, "u_radius");
const uTiltshift = gl.getUniformLocation(program, "u_tiltshift");
const uFocusY = gl.getUniformLocation(program, "u_focusY");
const uFocusRange = gl.getUniformLocation(program, "u_focusRange");
/* Full-screen quad buffer */
const quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
/* Source texture (receives the extracted frost region) */
const srcTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
/* Framebuffer texture (for two-pass separable blur) */
const fbTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, fbTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
const fb = gl.createFramebuffer();
let fbWidth = 0;
let fbHeight = 0;
function ensureFramebuffer(w, h) {
if (w === fbWidth && h === fbHeight) return;
fbWidth = w;
fbHeight = h;
gl.bindTexture(gl.TEXTURE_2D, fbTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
w,
h,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
fbTex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
/* ----------------------------------------------------------
Execute a single blur pass
---------------------------------------------------------- */
function blurPass(
inputTex,
outputFB,
w,
h,
dirX,
dirY,
radius,
tiltshift,
focusYVal,
focusRange,
) {
gl.bindFramebuffer(gl.FRAMEBUFFER, outputFB);
gl.viewport(0, 0, w, h);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, inputTex);
gl.uniform1i(uImage, 0);
gl.uniform2f(uResolution, w, h);
gl.uniform2f(uDirection, dirX, dirY);
gl.uniform1f(uRadius, radius);
gl.uniform1f(uTiltshift, tiltshift);
gl.uniform1f(uFocusY, focusYVal);
gl.uniform1f(uFocusRange, focusRange);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
/* ----------------------------------------------------------
Process blur on the extracted region (in tempCanvas)
---------------------------------------------------------- */
function processBlur(w, h) {
blurCanvas.width = w;
blurCanvas.height = h;
ensureFramebuffer(w, h);
/* Upload the extracted region */
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
tempCanvas,
);
const r = blurRadius * devicePixelRatio;
const ts = blurMode === "tiltshift" ? 1.0 : 0.0;
const fy = focusY;
const fr = 0.3;
if (blurMode === "directional") {
/* Single pass along the angle direction */
const rad = (blurAngle * Math.PI) / 180;
blurPass(
srcTex,
null,
w,
h,
Math.cos(rad),
Math.sin(rad),
r * 1.4,
0.0,
0.5,
0.3,
);
} else {
/* Two-pass separable: horizontal → framebuffer, vertical → screen */
blurPass(srcTex, fb, w, h, 1.0, 0.0, r, ts, fy, fr);
blurPass(fbTex, null, w, h, 0.0, 1.0, r, ts, fy, fr);
}
}
/* ==============================================================
Canvas paint callback — multi-layer compositing
============================================================== */
canvas.onpaint = () => {
const dpr = devicePixelRatio;
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
const w = canvas.width;
const h = canvas.height;
if (w === 0 || h === 0) return;
/* 1. Clear */
ctx.clearRect(0, 0, w, h);
/* 2. Draw background cards */
ctx.drawElementImage(bgLayer, 0, 0, cssW, cssH);
/* 3. Measure frost panel in CSS pixels.
* panelX/panelY are the logical position tracked in JS — the
* panel's CSS layout position is at (0,0) of the canvas grid
* cell, with a transform offsetting the visual rendering.
* offsetWidth/offsetHeight still report the panel's box size. */
const px = panelX;
const py = panelY;
const pw = frostPanel.offsetWidth;
const ph = frostPanel.offsetHeight;
if (pw <= 0 || ph <= 0) return;
/* Device-pixel coordinates */
const dpx = Math.round(px * dpr);
const dpy = Math.round(py * dpr);
const dpw = Math.round(pw * dpr);
const dph = Math.round(ph * dpr);
/* 4. Extract the region behind the frost panel */
const sx = Math.max(0, dpx);
const sy = Math.max(0, dpy);
const ex = Math.min(w, dpx + dpw);
const ey = Math.min(h, dpy + dph);
const sw = ex - sx;
const sh = ey - sy;
if (sw <= 0 || sh <= 0) return;
tempCanvas.width = dpw;
tempCanvas.height = dph;
tempCtx.clearRect(0, 0, dpw, dph);
tempCtx.drawImage(canvas, sx, sy, sw, sh, sx - dpx, sy - dpy, sw, sh);
/* 5. Blur via WebGL */
processBlur(dpw, dph);
/* 6. Composite blurred backdrop + tint + border */
ctx.save();
ctx.beginPath();
ctx.roundRect(px, py, pw, ph, 16);
ctx.clip();
/* Draw the blurred image */
ctx.drawImage(blurCanvas, 0, 0, dpw, dph, px, py, pw, ph);
/* Color tint overlay — dark blue-purple for a glassy feel */
ctx.fillStyle = "rgba(16, 16, 40, 0.35)";
ctx.fill();
/* Subtle gradient highlight (light from above) */
const grad = ctx.createLinearGradient(px, py, px, py + ph);
grad.addColorStop(0, "rgba(255, 255, 255, 0.06)");
grad.addColorStop(0.5, "rgba(255, 255, 255, 0.0)");
grad.addColorStop(1, "rgba(255, 255, 255, 0.02)");
ctx.fillStyle = grad;
ctx.fill();
/* Border */
ctx.strokeStyle = "rgba(255, 255, 255, 0.14)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
/* 7. Draw frost panel content on top.
* drawElementImage requires a direct child of the canvas, so we
* draw the panel itself (which is transparent) and let its
* child .frost-inner come along for the ride. */
ctx.drawElementImage(frostPanel, px, py, pw, ph);
};
/* ==============================================================
Resize observer
============================================================== */
function syncSize() {
const rect = canvas.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;
ctx.scale(dpr, dpr);
requestRepaint();
}
}
function requestRepaint() {
if (canvas.requestPaint) canvas.requestPaint();
else if (canvas.onpaint) canvas.onpaint();
}
const ro = new ResizeObserver(() => syncSize());
ro.observe(canvas);
/* ==============================================================
Drag handling for the frost panel.
The panel is positioned via a CSS transform driven by two
custom properties on the element (--panel-x, --panel-y),
because Chrome's <canvas layoutsubtree> forces direct canvas
children to position:static. We track the panel's logical
position in JS and use it for both the visual transform AND
the paint code's source coordinates.
============================================================== */
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panelStartX = 0;
let panelStartY = 0;
let panelX = 170;
let panelY = 115;
function applyPanelPosition() {
frostPanel.style.setProperty("--panel-x", panelX + "px");
frostPanel.style.setProperty("--panel-y", panelY + "px");
}
function clampPanel(newX, newY) {
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
const pw = frostPanel.offsetWidth;
const ph = frostPanel.offsetHeight;
return {
x: Math.max(0, Math.min(cssW - pw, newX)),
y: Math.max(0, Math.min(cssH - ph, newY)),
};
}
frostPanel.addEventListener("mousedown", (e) => {
isDragging = true;
frostPanel.classList.add("dragging");
dragStartX = e.clientX;
dragStartY = e.clientY;
panelStartX = panelX;
panelStartY = panelY;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const pos = clampPanel(panelStartX + dx, panelStartY + dy);
panelX = pos.x;
panelY = pos.y;
applyPanelPosition();
requestRepaint();
});
window.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
frostPanel.classList.remove("dragging");
});
/* Touch support */
frostPanel.addEventListener(
"touchstart",
(e) => {
isDragging = true;
frostPanel.classList.add("dragging");
const touch = e.touches[0];
dragStartX = touch.clientX;
dragStartY = touch.clientY;
panelStartX = panelX;
panelStartY = panelY;
},
{ passive: true },
);
window.addEventListener(
"touchmove",
(e) => {
if (!isDragging) return;
const touch = e.touches[0];
const dx = touch.clientX - dragStartX;
const dy = touch.clientY - dragStartY;
const pos = clampPanel(panelStartX + dx, panelStartY + dy);
panelX = pos.x;
panelY = pos.y;
applyPanelPosition();
requestRepaint();
},
{ passive: true },
);
window.addEventListener("touchend", () => {
if (!isDragging) return;
isDragging = false;
frostPanel.classList.remove("dragging");
});
/* ==============================================================
Control panel event handlers
============================================================== */
const modeButtons = $("mode-buttons");
const radiusSlider = $("radius-slider");
const radiusValue = $("radius-value");
const angleGroup = $("angle-group");
const angleSlider = $("angle-slider");
const angleValue = $("angle-value");
const focusGroup = $("focus-group");
const focusSlider = $("focus-slider");
const focusValue = $("focus-value");
modeButtons.addEventListener("click", (e) => {
const btn = e.target.closest(".mode-btn");
if (!btn) return;
modeButtons
.querySelectorAll(".mode-btn")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
blurMode = btn.dataset.mode;
angleGroup.style.display = blurMode === "directional" ? "" : "none";
focusGroup.style.display = blurMode === "tiltshift" ? "" : "none";
requestRepaint();
});
radiusSlider.addEventListener("input", () => {
blurRadius = parseFloat(radiusSlider.value);
radiusValue.textContent = blurRadius;
requestRepaint();
});
angleSlider.addEventListener("input", () => {
blurAngle = parseFloat(angleSlider.value);
angleValue.textContent = blurAngle;
requestRepaint();
});
focusSlider.addEventListener("input", () => {
focusY = parseFloat(focusSlider.value) / 100;
focusValue.textContent = focusSlider.value;
requestRepaint();
});
/* ==============================================================
Initial setup
============================================================== */
syncSize();
</script>
</body>
</html>