Motif· VectoJS gallery
WebGPUcore 1.11.3GitHub

Frosted 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>
liveesm.sh@vectojs/coreMIT — copy freely