Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LiquidGlass.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import LG from "./liquid-glass.js";
const GLASS_KEYS = [
"mode", "frost", "refraction", "depth", "dispersion", "splay",
"lightAngle", "lightIntensity", "curvature", "convexity", "zoom", "bevel",
"tint", "tintOpacity", "sheen", "sheenColor", "sheenAngle", "saturate", "brightness", "shadow", "radius", "background",
"tint", "tintOpacity", "sheen", "sheenColor", "sheenAngle", "saturate", "brightness", "shadow", "radius", "background", "live",
];

const LiquidGlass = React.forwardRef(function LiquidGlass({ as: Tag = "div", children, ...props }, forwardedRef) {
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import Glass from 'glasskit-js/react';
| `svg` | ✓ | **Chromium** | live backdrop | the "wow" surface on Chrome/Edge |
| `svg-clone` | ✓ | **all** | a cloned DOM element | cross-browser refraction over DOM |
| `webgl` | ✓ | **all** | an img/canvas/video | hero over a fixed background/video |
| `auto` | — | — | — | Chromium→`svg`, else `svg-clone` if `background` set, else `css` |
| `auto` | — | — | — | phones→`css`, Chromium→`svg`, else `svg-clone` if `background` set, else `css` |

`svg-clone` is the cross-browser trick: Safari/Firefox don't allow an SVG filter in
`backdrop-filter`, so it **clones the background element and filters the clone** instead.
Expand All @@ -82,6 +82,20 @@ It refracts DOM, not `<canvas>` pixels — use `webgl` for canvas/video backgrou
- **`radius`** sets `border-radius` on the element *and* the refraction map — `radius={999}` alone gives you a pill; no need to also set it in `style`.
- **`shadow`** is any CSS `box-shadow` (`"none"` removes it); the inner light border/bezel follow `lightIntensity`.

## Performance

Rendering is **demand-driven**: `svg-clone` and `webgl` idle completely when nothing moves
(zero layouts, zero filter re-runs, zero GL passes) and wake on scroll / resize / pointer /
CSS motion / `update()`. Video and canvas backgrounds are the exception — their pixels can
change without any layout signal, so they re-render per frame; pass **`live: false`** for a
canvas you only draw once (call `refresh()` after redrawing it), or `live: true` to force a
permanent per-frame loop.

On phones, `auto` resolves to **`css`** — Chromium re-runs the whole SVG filter chain on
every scrolled frame, which janks mobile GPUs. `css` (blur + tint + sheen + rim, no
refraction) is the mode to reach for on mobile; the refraction modes remain available by
requesting them explicitly.

## Shapes

Any **rounded rectangle (incl. pills & circles)** works with zero extra code — a
Expand Down
132 changes: 132 additions & 0 deletions bench/verify-perf.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* Verifies the demand-driven rendering work:
* - svg-clone / webgl loops spin down when nothing moves (0 rAF, 0 SVG attr writes, 0 GL draws)
* - they wake on scroll and on update(), then spin down again
* - a scrolled svg-clone stays byte-identical to a freshly-applied instance (no stale sync)
* - video/canvas backgrounds keep rendering per frame by default; `live: false` opts out
* - `auto` resolves to css on mobile, still svg on desktop Chromium
* Runs self-contained (no dev server needed). */
import { chromium, devices } from "playwright-core";

const b = await chromium.launch({ channel: "chrome", headless: true });
const p = await b.newPage({ viewport: { width: 800, height: 600 } });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
let fail = false;
const check = (name, ok, extra = "") => {
console.log(`${name}: ${ok ? "✓" : "✗ FAIL"}${extra ? " (" + extra + ")" : ""}`);
if (!ok) fail = true;
};

await p.setContent(`<body style="margin:0">
<div id="bg" style="position:fixed;inset:0;background:linear-gradient(35deg,#123,#f80 40%,#3af)"></div>
<div style="height:3000px"></div>
<div id="g" style="position:absolute;left:80px;top:200px;width:300px;height:180px;border-radius:24px"></div>
</body>`);
await p.addScriptTag({ path: "site/liquid-glass.js" });
// instrument BEFORE any apply: count rAF callbacks, SVG attribute writes and GL draws
await p.evaluate(() => {
window.__raf = 0; window.__attrs = 0; window.__draws = 0;
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (cb) => raf((t) => { window.__raf++; cb(t); });
const sa = SVGElement.prototype.setAttribute;
SVGElement.prototype.setAttribute = function (...a) { window.__attrs++; return sa.apply(this, a); };
const da = WebGLRenderingContext.prototype.drawArrays;
WebGLRenderingContext.prototype.drawArrays = function (...a) { window.__draws++; return da.apply(this, a); };
});
const delta = async (key, ms) => {
const a = await p.evaluate((k) => window[k], key);
await p.waitForTimeout(ms);
return (await p.evaluate((k) => window[k], key)) - a;
};

/* ---------------- svg-clone: idle → wake on scroll → realign → idle ---------------- */
await p.evaluate(() => { window.__i = Glasskit.apply(document.querySelector("#g"), { mode: "svg-clone", background: "#bg" }); });
await p.waitForTimeout(2200); // > IDLE_LIMIT (60 frames) — loop must have spun down
check("svg-clone: rAF idle after settle", (await delta("__raf", 1000)) <= 5);
check("svg-clone: no SVG attr writes while idle", (await delta("__attrs", 1000)) === 0);

const topBefore = await p.evaluate(() => document.querySelector("#g").firstElementChild.firstElementChild.style.top);
await p.mouse.move(400, 300);
await p.mouse.wheel(0, 100); // fixed bg + in-flow glass: relative offset changes by 100px
await p.waitForTimeout(500);
const topAfter = await p.evaluate(() => document.querySelector("#g").firstElementChild.firstElementChild.style.top);
check("svg-clone: scroll wakes the loop and re-syncs the clone", topBefore !== topAfter, `${topBefore} → ${topAfter}`);

// alignment truth: the scrolled instance must render like a fresh one applied here.
// (Chromium's SVG filter output isn't bit-stable across filter instances — ±3/255 noise —
// so compare with a tight tolerance; a genuinely stale clone would be ~100px off and huge.)
const CLIP = { x: 80, y: 100, width: 300, height: 180 }; // glass viewport rect after the 100px scroll
await p.waitForTimeout(600);
const scrolled = await p.screenshot({ clip: CLIP });
await p.evaluate(() => { window.__i.destroy(); window.__i = Glasskit.apply(document.querySelector("#g"), { mode: "svg-clone", background: "#bg" }); });
await p.waitForTimeout(600);
const fresh = await p.screenshot({ clip: CLIP });
const maxDelta = await p.evaluate(async ({ a, c }) => {
const load = (b64) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.src = "data:image/png;base64," + b64; });
const [ia, ic] = await Promise.all([load(a), load(c)]);
const cv = document.createElement("canvas"); cv.width = ia.width; cv.height = ia.height;
const cx = cv.getContext("2d", { willReadFrequently: true });
cx.drawImage(ia, 0, 0); const da = cx.getImageData(0, 0, cv.width, cv.height).data;
cx.clearRect(0, 0, cv.width, cv.height);
cx.drawImage(ic, 0, 0); const dc = cx.getImageData(0, 0, cv.width, cv.height).data;
let max = 0;
for (let i = 0; i < da.length; i++) if (i % 4 !== 3) max = Math.max(max, Math.abs(da[i] - dc[i]));
return max;
}, { a: scrolled.toString("base64"), c: fresh.toString("base64") });
check("svg-clone: scrolled render matches fresh-apply render", maxDelta <= 4, `max channel delta ${maxDelta}`);
await p.waitForTimeout(1800);
check("svg-clone: idles again after motion stops", (await delta("__raf", 1000)) <= 5);
await p.evaluate(() => window.__i.destroy());

/* ---------------- webgl: static image idles; update() wakes; canvas is live ---------------- */
await p.evaluate(async () => {
const cv = document.createElement("canvas"); cv.width = 512; cv.height = 512;
const cx = cv.getContext("2d");
const gr = cx.createLinearGradient(0, 0, 512, 512); gr.addColorStop(0, "#16a"); gr.addColorStop(1, "#fa0");
cx.fillStyle = gr; cx.fillRect(0, 0, 512, 512);
window.__cv = cv;
const img = new Image(); img.src = cv.toDataURL();
await new Promise((r) => { img.onload = r; });
img.style.cssText = "position:fixed;inset:0;width:100%;height:100%";
document.body.appendChild(img);
const g2 = document.createElement("div");
g2.style.cssText = "position:fixed;left:80px;top:100px;width:300px;height:180px;border-radius:24px";
document.body.appendChild(g2);
window.__g2 = g2;
window.__iw = Glasskit.apply(g2, { mode: "webgl", background: img });
});
await p.waitForTimeout(2200);
check("webgl img: 0 GL draws/s while idle", (await delta("__draws", 1000)) === 0);
check("webgl img: rAF idle", (await delta("__raf", 1000)) <= 5);

const d0 = await p.evaluate(() => window.__draws);
await p.evaluate(() => window.__iw.update({ refraction: 130 }));
await p.waitForTimeout(400);
check("webgl img: update() triggers a re-render", (await p.evaluate(() => window.__draws)) > d0);
await p.waitForTimeout(1800);
check("webgl img: idles again after update()", (await delta("__draws", 1000)) === 0);
await p.evaluate(() => window.__iw.destroy());

await p.evaluate(() => { window.__ic = Glasskit.apply(window.__g2, { mode: "webgl", background: window.__cv }); });
await p.waitForTimeout(1200);
check("webgl canvas (live by default): keeps rendering", (await delta("__draws", 1000)) >= 30);
await p.evaluate(() => window.__ic.update({ live: false }));
await p.waitForTimeout(2200);
check("webgl canvas live:false: idles", (await delta("__draws", 1000)) === 0);
await p.evaluate(() => window.__ic.destroy());

/* ---------------- auto mode: css on phones, svg on desktop Chromium ---------------- */
const mob = await b.newContext({ ...devices["Pixel 7"] });
const mp = await mob.newPage();
await mp.setContent("<div id='g' style='width:200px;height:100px'></div>");
await mp.addScriptTag({ path: "site/liquid-glass.js" });
check("auto on mobile (Pixel 7) → css", (await mp.evaluate(() => Glasskit.apply(document.querySelector("#g"), { mode: "auto" }).mode)) === "css");
await mob.close();
check("auto on desktop Chromium → svg", (await p.evaluate(() => {
const d = document.createElement("div"); document.body.appendChild(d);
return Glasskit.apply(d, { mode: "auto" }).mode;
})) === "svg");

check("no page errors", errs.length === 0, errs.join("; "));
await b.close();
process.exit(fail ? 1 : 0);
11 changes: 10 additions & 1 deletion liquid-glass.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export interface LiquidGlassOptions {
* - `svg` real refraction on the live backdrop. Chromium only.
* - `svg-clone` real refraction in Chrome/Safari/Firefox (clones `background`). DOM only.
* - `webgl` real refraction of a supplied `background` (img/canvas/video).
* - `auto` Chromium→svg; else `background` set→svg-clone; else css.
* - `auto` phones→css (mobile GPUs jank on filter chains); Chromium→svg;
* else `background` set→svg-clone; else css.
* @default "auto"
*/
mode?: LiquidGlassMode;
Expand Down Expand Up @@ -60,6 +61,14 @@ export interface LiquidGlassOptions {
radius?: number | null;
/** Element or selector to refract. Required for `svg-clone` and `webgl`. */
background?: Element | string | null;
/**
* Per-frame re-rendering (`svg-clone`/`webgl`). By default the engine renders on demand —
* it idles when nothing moves and wakes on scroll/resize/pointer/CSS motion — except for
* video/canvas backgrounds, whose pixels can change without any layout signal, so they
* stay per-frame. `false` forces on-demand even for a (static) canvas; `true` forces a
* permanent per-frame loop. @default null (auto)
*/
live?: boolean | null;
}

export interface LiquidGlassInstance {
Expand Down
Loading