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 } from "@vectojs/core";
class GradientBg extends Entity {
isPointInside() {
return false;
}
render(r) {
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);
}
}
}
let grainCache = null;
let grainW = 0;
let grainH = 0;
function getGrain(w, h) {
if (grainCache && grainW === w && grainH === h) return grainCache;
grainW = w;
grainH = h;
grainCache = document.createElement("canvas");
grainCache.width = w;
grainCache.height = h;
const ctx = grainCache.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);
return grainCache;
}
function hexRgba(hex, a) {
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;
tilt = 0;
constructor(x, y, w, h, tilt) {
super("FrostedPanel");
this.baseX = x;
this.baseY = y;
this._x = x;
this._y = y;
this.pw = w;
this.ph = h;
this.tilt = tilt || 0;
}
isPointInside(px, py) {
const dx = px - this._x;
const dy = py - this._y;
const rad = (-this.tilt * Math.PI) / 180;
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) {
const ctx = r.getContext();
r.save();
r.translate(this._x, this._y);
r.rotate((this.tilt * Math.PI) / 180);
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.canvas is the DPR-scaled backing store (physical pixels), but
// this._x/_y/pw/ph are logical scene-space (CSS pixel) coordinates —
// drawImage's source rect always reads physical pixels regardless of
// the renderer's ctx.scale(dpr,dpr) transform, so the source rect must
// be scaled up by dpr or it captures the wrong (much smaller) region.
const dpr = ctx.canvas.width / this.scene.width;
bctx.drawImage(
ctx.canvas,
this._x * dpr,
this._y * dpr,
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);
r.globalAlpha = Math.min(1, this.grainIntensity * 3);
r.drawImage(grain, 0, 0, this.pw, this.ph);
r.globalAlpha = 1;
}
r.strokeStyle = "rgba(255,255,255,0.25)";
r.lineWidth = 1.5;
roundedRect(ctx, 0.5, 0.5, this.pw - 1, this.ph - 1, this.cr);
r.stroke();
r.restore();
}
}
function roundedRect(ctx, x, y, w, h, r) {
r = Math.min(r, 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");
const scene = new Scene(canvas, {
renderMode: "always",
maxFPS: 60,
disableWindowResize: true,
maxDPR: 2,
});
scene.add(new GradientBg());
const panels = [];
function createPanels(w, h) {
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 = 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);
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");
const grainInput = document.getElementById("input-grain");
const tintInput = document.getElementById("input-tint");
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.11.3"
}
}
</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>