Frosted Glass
Draggable frosted-glass panels with adjustable blur, grain texture, and tilt — a translucent, etched-surface alternative to liquid glass
Draggable frosted-glass panels with adjustable blur, grain texture, and tilt — a translucent, etched-surface alternative to liquid glass
import { Scene, Entity, CanvasRenderer } from '@vectojs/core';
class GradientBg extends Entity {
isPointInside() {
return false;
}
render(r: CanvasRenderer) {
const ctx = r.getContext();
const W = this.scene.width;
const H = this.scene.height;
ctx.fillStyle = '#f7f4ee';
ctx.fillRect(0, 0, W, H);
const blobs = [
{ x: W * 0.18, y: H * 0.25, r: Math.min(W, H) * 0.4, c: '#d97757' },
{ x: W * 0.75, y: H * 0.65, r: Math.min(W, H) * 0.35, c: '#38bdf8' },
{ x: W * 0.5, y: H * 0.15, r: Math.min(W, H) * 0.25, c: '#f2b880' },
{ x: W * 0.85, y: H * 0.2, r: Math.min(W, H) * 0.2, c: '#a78bfa' },
];
for (const b of blobs) {
const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
g.addColorStop(0, hexRgba(b.c, 0.25));
g.addColorStop(1, hexRgba(b.c, 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
}
}
// Keyed by "wxh" rather than a single last-used slot: the three panels have
// different sizes, so a single-slot cache thrashed every render call (each
// panel's draw evicted the previous panel's grain), regenerating a brand
// new random noise texture on every single frame for whichever panel didn't
// match the slot — which read as the grain visibly crawling/flickering
// instead of being a static per-panel texture.
const grainCacheBySize = new Map<string, HTMLCanvasElement>();
function getGrain(w: number, h: number): HTMLCanvasElement {
const key = `${w}x${h}`;
const cached = grainCacheBySize.get(key);
if (cached) return cached;
const grain = document.createElement('canvas');
grain.width = w;
grain.height = h;
const ctx = grain.getContext('2d')!;
const id = ctx.createImageData(w, h);
const d = id.data;
for (let i = 0; i < d.length; i += 4) {
const v = Math.random() * 255;
d[i] = v;
d[i + 1] = v;
d[i + 2] = v;
d[i + 3] = 60;
}
ctx.putImageData(id, 0, 0);
grainCacheBySize.set(key, grain);
return grain;
}
function hexRgba(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
class FrostedPanel extends Entity {
blurRadius = 10;
grainIntensity = 0.06;
tintOpacity = 0.25;
cr = 14;
pw: number;
ph: number;
private _bg: HTMLCanvasElement | null = null;
private _bgW = 0;
private _bgH = 0;
constructor(x: number, y: number, w: number, h: number, tiltDeg: number) {
super('FrostedPanel');
// Use Entity's own x/y/rotation rather than custom fields: Scene's
// renderNode() already does translate(node.x, node.y) + rotate(node.rotation)
// for every entity before calling render(). A prior version of this demo
// stored position in fields named `_x`/`_y` and translated/rotated a
// SECOND time inside render() — but `_x`/`_y` are the exact private
// backing-field names Entity.x/Entity.y already use internally, so that
// write silently aliased the same slot Entity reads for its own
// transform. The net effect doubled every panel's on-screen position
// every frame while isPointInside() (and the drag handlers) kept
// computing hit-testing against the single, un-doubled value — so the
// draggable area never matched what was drawn, and the smallest/most
// tilted panel landed far enough off to look entirely invisible.
this.x = x;
this.y = y;
this.rotation = (tiltDeg * Math.PI) / 180;
this.pw = w;
this.ph = h;
}
override isPointInside(px: number, py: number) {
const dx = px - this.x;
const dy = py - this.y;
const rad = -this.rotation;
const rx = dx * Math.cos(rad) - dy * Math.sin(rad);
const ry = dx * Math.sin(rad) + dy * Math.cos(rad);
return rx >= 0 && rx <= this.pw && ry >= 0 && ry <= this.ph;
}
render(r: CanvasRenderer) {
const ctx = r.getContext();
// Scene's renderNode() has already applied translate(x,y) + rotate(rotation)
// before calling render(), so local drawing starts at (0,0) with no tilt.
roundedRect(ctx, 0, 0, this.pw, this.ph, this.cr);
ctx.clip();
if (!this._bg || this._bgW !== this.pw || this._bgH !== this.ph) {
this._bg = document.createElement('canvas');
this._bgW = this.pw;
this._bgH = this.ph;
this._bg.width = this.pw;
this._bg.height = this.ph;
}
const bctx = this._bg.getContext('2d')!;
bctx.clearRect(0, 0, this.pw, this.ph);
// ctx.getTransform() gives the CURRENT accumulated transform (translate
// + rotate applied by Scene, at DPR scale) — mapping this entity's local
// (0,0) origin to its true position on the physical backing store, so
// the source rect below is correct even while the panel is rotated.
const t = ctx.getTransform();
const dpr = ctx.canvas.width / this.scene.width;
bctx.drawImage(ctx.canvas, t.e, t.f, this.pw * dpr, this.ph * dpr, 0, 0, this.pw, this.ph);
bctx.filter = `blur(${this.blurRadius}px)`;
bctx.drawImage(this._bg, 0, 0);
bctx.filter = 'none';
// IRenderer.drawImage requires the full 5-arg (source, dx, dy, dw, dh)
// signature — unlike native CanvasRenderingContext2D.drawImage, it has
// no 3-arg overload, so omitting dw/dh passes them through as
// `undefined` and the native call silently draws nothing.
r.drawImage(this._bg, 0, 0, this.pw, this.ph);
ctx.fillStyle = `rgba(255,255,255,${this.tintOpacity})`;
ctx.fillRect(0, 0, this.pw, this.ph);
if (this.grainIntensity > 0) {
const grain = getGrain(this.pw, this.ph);
// IRenderer exposes setGlobalAlpha(a) — there is NO `globalAlpha`
// property. Assigning one just adds an expando to the renderer and the
// draw stays fully opaque, which is why this slider did nothing.
r.setGlobalAlpha(Math.min(1, this.grainIntensity * 3));
r.drawImage(grain, 0, 0, this.pw, this.ph);
r.setGlobalAlpha(1);
}
// Same class of mistake: `stroke()` takes the color and width as ARGUMENTS.
// `r.strokeStyle = …` / `r.lineWidth = …` were no-ops, so the rim drew with
// the context default (#000000 at 1px) — a black hairline, not a white rim.
roundedRect(ctx, 0.5, 0.5, this.pw - 1, this.ph - 1, this.cr);
r.stroke('rgba(255,255,255,0.25)', 1.5);
}
}
function roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
radius: number,
) {
const r = Math.min(radius, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
const app = document.getElementById('app')!;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
// Nothing here animates: no entity has an update(), and the panels only move
// when dragged. Every mutation marks the scene dirty, and scene.resize() marks
// it internally, so 'always' would just redraw an unchanged frame forever —
// measured at a steady 2.00Hz in Chrome 150 before this was set.
const scene = new Scene(canvas, {
maxFPS: 60,
disableWindowResize: true,
maxDPR: 2,
renderMode: 'onDemand',
});
scene.add(new GradientBg());
const panels: FrostedPanel[] = [];
function createPanels(w: number, h: number) {
for (const p of panels) scene.remove(p);
panels.length = 0;
const pw = Math.min(220, w * 0.28);
const ph = Math.min(300, h * 0.55);
panels.push(new FrostedPanel(w * 0.12, h * 0.2, pw, ph, -3));
panels.push(new FrostedPanel(w * 0.62, h * 0.35, pw * 0.9, ph * 0.8, 2));
panels.push(new FrostedPanel(w * 0.35, h * 0.18, pw * 0.75, ph * 0.65, 5));
for (const p of panels) scene.add(p);
}
let dragging: FrostedPanel | null = null;
let dragOffX = 0;
let dragOffY = 0;
canvas.addEventListener('pointerdown', (e) => {
const r = canvas.getBoundingClientRect();
const mx = e.clientX - r.left;
const my = e.clientY - r.top;
for (let i = panels.length - 1; i >= 0; i--) {
const p = panels[i];
if (p.isPointInside(mx, my)) {
dragging = p;
dragOffX = mx - p.x;
dragOffY = my - p.y;
scene.remove(p);
scene.add(p);
panels.splice(i, 1);
panels.push(p);
// Raising the grabbed panel changes what is drawn on top, so the scene
// has to repaint even though nothing moved yet.
scene.markDirty();
break;
}
}
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const r = canvas.getBoundingClientRect();
dragging.x = e.clientX - r.left - dragOffX;
dragging.y = e.clientY - r.top - dragOffY;
scene.markDirty();
});
canvas.addEventListener('pointerup', () => {
dragging = null;
});
canvas.addEventListener('pointerleave', () => {
dragging = null;
});
const blurInput = document.getElementById('input-blur') as HTMLInputElement;
const grainInput = document.getElementById('input-grain') as HTMLInputElement;
const tintInput = document.getElementById('input-tint') as HTMLInputElement;
function updateAllPanels() {
for (const p of panels) {
p.blurRadius = Number(blurInput.value);
p.grainIntensity = Number(grainInput.value) / 100;
p.tintOpacity = Number(tintInput.value) / 100;
}
scene.markDirty();
}
blurInput.addEventListener('input', () => {
document.getElementById('value-blur')!.textContent = `${blurInput.value}px`;
updateAllPanels();
});
grainInput.addEventListener('input', () => {
document.getElementById('value-grain')!.textContent = (Number(grainInput.value) / 100).toFixed(2);
updateAllPanels();
});
tintInput.addEventListener('input', () => {
document.getElementById('value-tint')!.textContent = (Number(tintInput.value) / 100).toFixed(2);
updateAllPanels();
});
const observer = new ResizeObserver(() => {
const w = app.clientWidth;
const h = app.clientHeight;
if (w > 0 && h > 0) {
scene.resize(w, h);
createPanels(w, h);
}
});
observer.observe(app);
scene.start();
document.getElementById('hud')!.textContent =
'drag panels to rearrange · blur/grain/tint adjustable';
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/no-ff-webgpu.js"></script>
<title>Frosted Glass — Motif</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;900&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #f7f4ee;
}
#app {
position: relative;
width: 100%;
height: 100%;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#panel {
position: absolute;
top: 14px;
left: 14px;
right: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font:
500 12px "Inter",
sans-serif;
z-index: 10;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(42, 39, 35, 0.16);
border-radius: 10px;
padding: 10px 12px;
color: #2a2723;
}
#panel .field {
display: flex;
align-items: center;
gap: 6px;
}
#panel label {
color: #6b6459;
font-size: 11px;
white-space: nowrap;
}
#panel input[type="range"] {
width: 72px;
accent-color: #d97757;
}
#panel .value {
font:
500 11px "JetBrains Mono",
monospace;
color: #6b6459;
min-width: 28px;
}
#panel .divider {
width: 1px;
height: 20px;
background: rgba(42, 39, 35, 0.14);
}
#hud {
position: absolute;
top: 68px;
left: 14px;
font:
500 12px "JetBrains Mono",
monospace;
color: #2a2723;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(42, 39, 35, 0.16);
border-radius: 7px;
padding: 8px 12px;
line-height: 1.6;
z-index: 10;
white-space: pre;
}
</style>
<script type="importmap">
{
"imports": {
"@vectojs/core": "https://esm.sh/@vectojs/core@1.26.0"
}
}
</script>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<div id="panel">
<div class="field">
<label for="input-blur">Blur</label>
<input
id="input-blur"
type="range"
min="2"
max="24"
step="1"
value="10"
/>
<span class="value" id="value-blur">10px</span>
</div>
<div class="field">
<label for="input-grain">Grain</label>
<input
id="input-grain"
type="range"
min="0"
max="20"
step="1"
value="6"
/>
<span class="value" id="value-grain">0.06</span>
</div>
<div class="field">
<label for="input-tint">Tint</label>
<input
id="input-tint"
type="range"
min="5"
max="50"
step="1"
value="25"
/>
<span class="value" id="value-tint">0.25</span>
</div>
</div>
<div id="hud">drag panels to rearrange</div>
</div>
<script type="module" src="./demo.js"></script>
</body>
</html>