https://github.com/ybouane/liquidglass
A liquid glass effect library for the web. Apply realistic glass refraction, blur, chromatic aberration, and lighting effects to any HTML element using WebGL shaders.
https://github.com/ybouane/liquidglass
chromatic-aberration effect filter glass glassmorphism ios26-liquid-glass liquid refraction ui-design ui-ux webgl
Last synced: 11 days ago
JSON representation
A liquid glass effect library for the web. Apply realistic glass refraction, blur, chromatic aberration, and lighting effects to any HTML element using WebGL shaders.
- Host: GitHub
- URL: https://github.com/ybouane/liquidglass
- Owner: ybouane
- Created: 2026-04-04T04:40:16.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-10T15:50:17.000Z (2 months ago)
- Last Synced: 2026-05-06T23:05:01.939Z (about 1 month ago)
- Topics: chromatic-aberration, effect, filter, glass, glassmorphism, ios26-liquid-glass, liquid, refraction, ui-design, ui-ux, webgl
- Language: TypeScript
- Homepage: https://liquid-glass.ybouane.com/
- Size: 18.2 MB
- Stars: 148
- Watchers: 1
- Forks: 8
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
- awesome-starred - ybouane/liquidglass - A liquid glass effect library for the web. Apply realistic glass refraction, blur, chromatic aberration, and lighting effects to any HTML element using WebGL shaders. (TypeScript)
README
# LiquidGlass
[](https://liquid-glass.ybouane.com/)
A liquid glass effect library for the web. **[Live Demo](https://liquid-glass.ybouane.com/)** Apply realistic glass refraction, blur, chromatic aberration, and lighting effects to any HTML element using WebGL shaders.
## Demo
[https://liquid-glass.ybouane.com/](https://liquid-glass.ybouane.com/)
## Installation
```bash
npm install @ybouane/liquidglass
```
Or skip the install and import directly from a CDN:
```html
import { LiquidGlass } from 'https://cdn.jsdelivr.net/npm/@ybouane/liquidglass/dist/index.js';
```
## Quick Start
```html
Hello World
0
Glass Panel
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();
```
## How It Works
1. **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 SVG `foreignObject`). Static children are captured once and cached; children with `data-dynamic` (or any ``) are re-captured every frame.
2. **`
`, ``, and ``** are drawn directly via `ctx.drawImage` (faster than `html-to-image`, and the only way to capture live video frames).
3. **Glass elements** receive an injected child `` 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.
4. **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.
## API
### `LiquidGlass.init(options)`
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` | `{}` | Override the default per-element configuration values for this instance. |
**Returns** a `Promise` 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 next `init()` rebuilds the embedded font cache.
```javascript
const instance = await LiquidGlass.init({
root: document.querySelector('#root'),
glassElements: document.querySelectorAll('.glass'),
defaults: {
cornerRadius: 24,
refraction: 0.8,
},
});
```
## Per-Element Configuration
Configure individual glass elements by setting `data-config` to a JSON string:
```javascript
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.
### Available Options
| 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. |
## Element Attributes
### `data-dynamic`
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.
```html
...
...
...
```
`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.
`` 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.
### `data-config`
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.
## Manually invalidating content: `instance.markChanged()`
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` / `` elements (which are treated as **always dirty by definition** and re-rendered every frame).
What it cannot detect:
- A `` whose pixels you just updated via `getContext('2d')` / WebGL.
- An `
` whose `src` you just swapped via JS.
- A wrapper whose CSS `background-image` or other paint property you just updated.
- Anything else that changes visually without firing a DOM mutation the library is watching.
For these cases, call:
```javascript
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.
## Stacking & Z-Index
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 < 1`
- `transform`, `filter`, `perspective`, `clip-path`, `mix-blend-mode`, `isolation`, `backdrop-filter`, `mask-image`, `contain: layout|paint|strict|content`
- `will-change` listing 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.
## Limitations & Gotchas
### Structural
- **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 `` is injected as the glass element's first child** for shader output. Avoid `:first-child` selectors 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`.
### Performance
- **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-dynamic` re-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 `` and no `data-dynamic` content does almost no work per frame.
### Text & fonts
- **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. Call `invalidateFontEmbedCache()` then re-init if you load fonts dynamically.
- **Cross-origin `
` elements need `crossorigin="anonymous"`.** Tainted canvases break texture upload and disable the glass effect for the entire root.
### API
- **`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-dynamic` only catches direct children of the root.** Live content nested inside a wrapper that lacks `data-dynamic` will not trigger re-captures.
- **`destroy()` does not restore an element's original `position: static`** if the library overwrote it with `relative`. Re-init on the same elements is fine; exotic external mutation in between is not.
## Browser Support
Requires WebGL 1.0 + Canvas 2D + SVG `foreignObject`. Effectively all evergreen browsers (Chrome, Firefox, Safari, Edge). WebGL context loss is recovered automatically.
## License
MIT