React · DOM · Chromium, Safari, Firefox

Liquid glass on the web,
at a third of the GPU.

Glass that bends the real page behind it — text, pictures, anything in the DOM — not a blurred screenshot. web-glass draws every surface with its own backdrop filter, renders its maps on the CPU when there is no WebGPU, and was measured at every step. The card below is live.

GlassRegular

Drag the lens. Everything on this card is live glass over the picture behind it.

Regular material
Drift
Refraction60 px
GPU, glass moving
34–40%
was 72–90%
GPU thread
82–117
ms per second, was 566–1007
Dropped frames
0
final run, was 23
Same look
0.99
SSIM against the old path
Without WebGPU
Same pixels
CPU maps, SSIM 1.000

Upstream needs a WebGPU adapter to draw glass at all. web-glass falls back to computing the maps on the CPU, and the page looks the same: the largest difference in a full-page screenshot is 3 of 255 levels.

Chrome on an Apple-silicon Mac, 1180×820 CSS px at 2× density, the full control set, 3 s per animation. GPU is the whole machine’s utilisation from the macOS accelerator counters; GPU thread is Chrome’s own busy time from a trace.

Recorded

Twenty-six seconds of glass

Recorded in Chrome at 1280×800, 2×, from the demo app in the repository: tabs switching the backdrop, a drifting lens, the refraction slider, the material switch and a menu. Every picture in it is drawn by code.

Components

Every control, live

Each card is its own small scene over a different backdrop — a rendered picture, a page of text, soft colour. Press, drag and switch; the state underneath is ordinary React.

GlassButtonPress and hold: the glass grows under the pointer.
GlassTabsSelected: Read. The indicator is its own glass.
Wi-Fi
Focus
Volume62
GlassSwitch · GlassSliderWi-Fi on, focus off, volume 62.
GlassMenuNothing chosen yet.
GlassToolbarBold is on.
ClearRefraction, no frost
RegularFrost and tone
GlassSurfaceDrag either panel. Clear on the left, Regular on the right, over text and a picture.
Measured

Where the GPU time went

The same page, the same controls, the same animations, drawn by upstream’s whole-scene filter and by web-glass’s per-surface filters, back to back in one run.

Chrome · GPU utilisation while glass moves (sky picture)
scene filter (upstream) per-surface backdrop (web-glass)
Moving panel
72.0%
39.0%
Slider drag
84.7%
39.7%
Tab switch
76.6%
38.3%
Scrolling content
88.8%
37.6%
Chrome · GPU main thread, busy ms per second
scene filter (upstream) per-surface backdrop (web-glass)
Moving panel
566 ms/s
92 ms/s
Slider drag
908 ms/s
97 ms/s
Tab switch
720 ms/s
110 ms/s
Scrolling content
1007 ms/s
85 ms/s
GPU by backdrop, mean of four animations
scene filter (upstream) per-surface backdrop (web-glass)
Sky image
80.5%
38.7%
Text page
84.4%
35.7%
Gradient
85.3%
35.0%
Dropped frames, all animations of a run
scene filter (upstream) per-surface backdrop (web-glass)
First run, 6 animations
108
3
Second run, 4 animations
77
1
Final run, 12 animations
23
0

Same look — SSIM, old path vs new

At rest0.995 sky · 0.988 text
Button pressed0.994 sky · 0.988 text
Tabs mid-flight0.994 sky · 0.987 text
Menu open0.997 sky · 0.994 text
Switch on0.996 sky · 0.989 text

1.000 is identical. What remains sits on anti-aliased rims, where two filter paths round differently.

CPU maps vs WebGPU maps

Chrome, sky1.0000 · max 2 / 255
Chrome, text1.0000 · max 2 / 255
WebKit, sky1.0000 · max 2 / 255
WebKit, text1.0000 · max 3 / 255

Whole-page screenshots with the maps forced onto each renderer. No pixel differs by more than 16 levels; most do not differ at all.

WebKit · mean frame time by scene size
moving glass panel scrolling content under glass 16.7 ms = 60 fps
820 × 700
16.8 ms
25.1 ms
1180 × 820
30.4 ms
47.1 ms
1640 × 1200
39.0 ms
58.6 ms

Safari is a different problem. It filters the content under glass on the CPU, so the cost of moving glass grows with the scene: a panel gliding over an 820×700 scene holds 60 fps, the same panel over a 1640×1200 scene gets about 26. Glass that stays still costs nothing, about 5% of one core.

How it works

Five changes, one look

One filter per surface
Upstream draws the glass with one SVG filter over the whole scene, so any moving glass makes Chromium re-filter every pixel of it. web-glass gives each surface its own backdrop-filter: url(#…) over its own box. Moving a panel re-filters the panel, not the page.
Independent graphs
Each surface's filter graph reads only its own backdrop. Chromium evaluates a shared filter input once per consumer, so chaining one surface's glass into another's dropped it to 6 fps; glass that holds glass draws on its own layer instead.
Maps without WebGPU
The refraction, rim and highlight of a shape live in two small map images. vgpu renders them with WGSL on WebGPU; without an adapter the same maths runs on the CPU and produces the same pixels, within 3 of 255 levels.
A shorter material graph
Frost is two blurs and one weighted composite instead of a blend and a composite; the light tone curve folds into the colour matrix, one pass instead of two. Both stay within a few levels of the original.
WebKit: small scenes, still content
Safari re-filters the content under glass in software on every frame the glass moves. Static glass is free; moving glass costs with the size of the scene. This page keeps each scene small, keeps what sits under glass still, and only lets the hero lens drift where that is cheap.
Fallback

Before the script runs, when a scene reports an error, or when you ask your system for reduced transparency, the same controls render as CSS glass or as solid surfaces. Switch this page between them:

Use it

Same API as upstream

web-glass keeps the package name and every prop of liquid-glass, so it drops into code written for upstream. It ships as a tarball; the source is available on request.

$ npm install ./glass-sdk-liquid-glass-0.0.3-webglass.1.tgz
import { GlassScene, GlassContent, GlassSurface, GlassSwitch } from "@glass-sdk/liquid-glass";
import "@glass-sdk/liquid-glass/styles.css";

<GlassScene material="regular" style={{ height: 320, transform: "translateZ(0)" }}>
  <GlassContent>
    <img src="/ridge.webp" alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
  </GlassContent>
  <GlassSurface radius={28} style={{ position: "absolute", left: 24, top: 24, padding: 20 }}>
    <label>Notifications <GlassSwitch defaultChecked /></label>
  </GlassSurface>
</GlassScene>

React 19. The translateZ(0) on the scene keeps WebKit from painting a scene blank when it sits far down a page.