A liquid glass effect library for the web. Live Demo Apply realistic glass refraction, blur, chromatic aberration, and lighting effects to any HTML element using WebGL shaders.
https://liquid-glass.ybouane.com/
npm install @ybouane/liquidglassOr skip the install and import directly from a CDN:
<script type="module">
import { LiquidGlass } from 'https://cdn.jsdelivr.net/npm/@ybouane/liquidglass/dist/index.js';
</script><div id="root">
<!-- Background image (sibling of the glass, captured by the shader) -->
<img class="bg" src="background.jpg" alt="">
<!-- Static content -->
<h1 class="title">Hello World</h1>
<!-- Animated content needs data-dynamic so it re-captures every frame -->
<div class="counter" data-dynamic>0</div>
<!-- Glass element -->
<div class="my-glass">Glass Panel</div>
</div>
<script type="module">
import { LiquidGlass } from '@ybouane/liquidglass';
const glassEl = document.querySelector('.my-glass');
glassEl.dataset.config = JSON.stringify({
floating: true,
blurAmount: 0.25,
});
const instance = await LiquidGlass.init({
root: document.querySelector('#root'),
glassElements: [glassEl],
});
// Later, to tear down:
// instance.destroy();
</script>- Non-glass children of the root are rasterised onto a hidden canvas using
html-to-image(which clones the subtree, inlines computed styles, and renders via SVGforeignObject). Static children are captured once and cached; children withdata-dynamic(or any<video>) are re-captured every frame. <img>,<canvas>, and<video>are drawn directly viactx.drawImage(faster thanhtml-to-image, and the only way to capture live video frames).- Glass elements receive an injected child
<canvas>that displays the WebGL output. For each glass element, the renderer crops the scene at the panel's location, runs an optional Gaussian blur, then runs a fragment shader that applies refraction, chromatic aberration, Fresnel reflection, multi-light specular highlights, an inner-stroke rim, and a drop shadow. - Layered compositing writes each rendered glass canvas back to the compositing canvas before the next glass element runs, so a glass element above another sees the lower one in its refraction.
Async — creates and starts a LiquidGlass instance. Resolves once the page's webfonts have been pre-fetched and every glass element's content has been pre-captured.
| Option | Type | Default | Description |
|---|---|---|---|
root |
HTMLElement |
(required) | The container element. Glass elements must be direct children of this element. |
glassElements |
NodeList | HTMLElement[] |
[] |
Elements to apply the glass effect to. |
defaults |
Partial<GlassConfig> |
{} |
Override the default per-element configuration values for this instance. |
Returns a Promise<LiquidGlass> resolving to the instance, which exposes:
.fps: number— current measured frames-per-second (updated once per second)..destroy(): void— stop the render loop, remove injected canvases, restore mutated inline styles, and free WebGL resources..markChanged(element?: HTMLElement): void— manually flag content the library can't observe on its own (see below).
The library also exports:
invalidateFontEmbedCache(): void— call after dynamically loading new font stylesheets so the nextinit()rebuilds the embedded font cache.
const instance = await LiquidGlass.init({
root: document.querySelector('#root'),
glassElements: document.querySelectorAll('.glass'),
defaults: {
cornerRadius: 24,
refraction: 0.8,
},
});Configure individual glass elements by setting data-config to a JSON string:
element.dataset.config = JSON.stringify({
blurAmount: 0.25,
floating: true,
cornerRadius: 40,
});The library re-reads data-config whenever it changes (via a MutationObserver), so you can update it dynamically.
| Option | Type | Default | Description |
|---|---|---|---|
blurAmount |
number |
0.00 |
Background blur strength (0 = sharp, 1 = maximum blur) |
refraction |
number |
0.69 |
How much the glass bends the image behind it |
chromAberration |
number |
0.05 |
Chromatic aberration / colour fringing at edges |
edgeHighlight |
number |
0.05 |
Edge glow / rim lighting intensity |
specular |
number |
0.00 |
Specular highlight intensity (multi-light Blinn-Phong) |
fresnel |
number |
1.00 |
Fresnel reflection at grazing angles |
distortion |
number |
0.00 |
Micro-distortion noise strength |
cornerRadius |
number |
65 |
Corner radius in CSS pixels |
zRadius |
number |
40 |
Bevel depth — controls the curvature of the pill's cross-section |
opacity |
number |
1.00 |
Overall glass panel opacity |
saturation |
number |
0.00 |
Saturation adjustment (-1 = grayscale, 0 = normal, 1 = vivid) |
tintStrength |
number |
0.00 |
Cool blue glass tint strength |
brightness |
number |
0.00 |
Brightness adjustment (-0.5 to 0.5) |
shadowOpacity |
number |
0.30 |
Drop shadow opacity |
shadowSpread |
number |
10 |
Drop shadow spread in CSS pixels |
shadowOffsetY |
number |
1 |
Shadow vertical offset in CSS pixels |
floating |
boolean |
false |
Enable drag-to-move via Pointer Events |
button |
boolean |
false |
Button mode — hovering brightens the panel; pressing flattens the bevel and deepens the shadow |
bevelMode |
0 | 1 |
0 |
0 = biconvex pill (default). 1 = dome / plano-convex; pair with cornerRadius === zRadius for a half-sphere magnifier. |
Add data-dynamic to any direct child of the root whose contents change every frame (counters, animated text, charts). Without it, that wrapper is captured once and cached forever.
<div id="root">
<div class="static-bg">...</div> <!-- captured once, cached -->
<div class="counter" data-dynamic>...</div> <!-- re-captured every frame -->
<div class="glass">...</div>
</div>data-dynamic elements are treated as always dirty by definition — the library re-rasterises them every frame and re-runs the shader for every glass that overlaps them. Use it sparingly: it's the only thing on the page that defeats the per-element dirty-tracking optimisation.
<video> elements are auto-detected as dynamic — you don't need to add data-dynamic to them.
For one-shot updates that don't happen every frame, prefer instance.markChanged() (see below) — it costs nothing on idle frames.
JSON string of per-element configuration options (see the table above). Must decode to an object; invalid JSON or non-object values are ignored with a console warning.
The library auto-detects most things that affect the glass: DOM mutations inside glass subtrees, data-config changes, layout shifts, drag, hover/press, window resize, async capture cache landings, and data-dynamic / <video> elements (which are treated as always dirty by definition and re-rendered every frame).
What it cannot detect:
- A
<canvas>whose pixels you just updated viagetContext('2d')/ WebGL. - An
<img>whosesrcyou just swapped via JS. - A wrapper whose CSS
background-imageor other paint property you just updated. - Anything else that changes visually without firing a DOM mutation the library is watching.
For these cases, call:
const instance = await LiquidGlass.init({ root, glassElements });
// You just repainted a wrapper — only glasses overlapping it will re-render.
instance.markChanged(myCanvasElement);
// Or invalidate everything on this instance:
instance.markChanged();markChanged(element) walks every glass on the instance, finds the ones whose sample rect intersects the element's bounding rect, and marks just those for a re-render on the next frame. Glasses that don't overlap the element keep their cached output and skip the WebGL pipeline entirely.
markChanged() with no argument flags every glass — useful as a "I don't know what changed but please redraw" escape hatch.
For elements with data-dynamic, calling markChanged is harmless but unnecessary — the library already treats them as dirty every frame.
The library re-implements the CSS stacking-context spec to decide painting order on the compositing canvas. It recognises the following stacking-context triggers on direct children of the root:
- Non-static
position(with z-index) - Grid/flex item with explicit
z-index opacity < 1transform,filter,perspective,clip-path,mix-blend-mode,isolation,backdrop-filter,mask-image,contain: layout|paint|strict|contentwill-changelisting any of the above properties
If you put an overlay above a background image and the glass shows the bg but not the overlay, you've hit a missing trigger — file an issue with the property name.
- Glass elements must be direct children of the root. Nested glass is rejected at init with a console warning. If you need glass inside a wrapper, give the wrapper its own
LiquidGlass.init()call. - The root itself is never captured. The shader samples the root's children, so any background image, padding, or border on the root is invisible to the glass effect. Put backgrounds in a sibling element inside the root.
- A
<canvas>is injected as the glass element's first child for shader output. Avoid:first-childselectors on glass elements. - Multiple LiquidGlass roots cannot share refraction. A glass element in one root cannot see what another root's glass elements are rendering — they each have their own compositing canvas.
- The shadow halo extends beyond the glass element. The injected canvas overflows its parent's box and will be clipped by any ancestor with
overflow: hidden.
- Capturing DOM into a canvas is expensive. Every non-glass wrapper is rasterised via
html-to-image(style inlining + SVG-foreignObject decode). Keep wrappers small and shallow. data-dynamicre-captures every frame. Use it sparingly — only for content that actually changes.- Each LiquidGlass instance opens its own WebGL context. Browsers cap concurrent contexts (typically 16 system-wide); don't spawn dozens.
- Window resize re-captures everything. Don't drive layout in a tight resize loop.
- The render loop short-circuits when nothing is dirty — a static page with no
<video>and nodata-dynamiccontent does almost no work per frame.
- Webfonts must be loaded before
init()and served with CORS-friendly headers. Google Fonts, jsdelivr, and unpkg work out of the box. Webfonts loaded after init will fall back to system fonts inside captured rasters. CallinvalidateFontEmbedCache()then re-init if you load fonts dynamically. - Cross-origin
<img>elements needcrossorigin="anonymous". Tainted canvases break texture upload and disable the glass effect for the entire root.
LiquidGlass.init()is async. It resolves only after the font CSS prefetch, glass content pre-capture, and static-content pre-warm have all completed (typically 100–500 ms on a fresh page).data-dynamiconly catches direct children of the root. Live content nested inside a wrapper that lacksdata-dynamicwill not trigger re-captures.destroy()does not restore an element's originalposition: staticif the library overwrote it withrelative. Re-init on the same elements is fine; exotic external mutation in between is not.
Requires WebGL 1.0 + Canvas 2D + SVG foreignObject. Effectively all evergreen browsers (Chrome, Firefox, Safari, Edge). WebGL context loss is recovered automatically.
MIT
