@el-gladiador/liquid-glass-react
v0.1.1
Published
High-performance liquid glass / iOS-26 style refraction effects for React, powered by WebGL2 with multi-layer refraction and a real Gaussian blur kernel.
Maintainers
Readme
liquid-glass-react
A React component for iOS-26-style liquid glass / refraction effects. Single shared WebGL2 context, three-layer refraction (edge / rim / centre), real 13-tap Gaussian blur sampled in-shader, and ambient colour pickup from the surrounding background. No per-element canvas, no per-frame allocation, no requestAnimationFrame spinning when nothing's changed.
Install
npm install @el-gladiador/liquid-glass-reacthtml-to-image is bundled as a dependency. react and react-dom are peer deps (>=18).
Quick start
import { LiquidGlassProvider, LiquidGlass } from '@el-gladiador/liquid-glass-react';
export default function App() {
return (
<LiquidGlassProvider style={{ position: 'relative', minHeight: '100vh' }}>
{/* Anything here becomes the "background" that gets refracted */}
<img src="/hero.jpg" alt="" style={{ width: '100%' }} />
<LiquidGlass
style={{
position: 'absolute',
top: 40,
left: 40,
width: 280,
height: 120,
padding: 24,
}}
borderRadius={24}
refraction={0.06}
blur={4}
chromaticAberration={0.4}
>
<h2>Hello</h2>
</LiquidGlass>
</LiquidGlassProvider>
);
}That's the whole API for typical use. The provider owns one shared WebGL context; every <LiquidGlass> inside it is just a uniform-state record that draws to the same canvas.
Props
<LiquidGlassProvider>
| Prop | Type | Default | Notes |
|---|---|---|---|
| maxDpr | number | 2 | Caps the canvas pixel ratio. Higher = sharper, more GPU & memory. Most retina screens look fine at 2. |
| autoInvalidate | boolean | true | If true, watches the DOM with a MutationObserver and recaptures on changes (debounced). |
| invalidateDebounceMs | number | 120 | Debounce window for mutation-driven recapture. Mutation bursts (typical of React reconciliation) wait this long after the last change before triggering a capture. |
| captureCooldownMs | number | 50 | Minimum interval between successive captures from any source. Acts as a floor that coalesces scroll, mutation, and dynamic-mode captures. Lower = more responsive scrolling, higher = less main-thread cost. |
| captureScale | number (0.25–1) | 1 | Texture-resolution multiplier for the captured background. Drop to 0.7 for ~50% capture cost reduction; the result is invisible after blur. |
| pauseWhenOffscreen | boolean | true | Pause the rAF render loop entirely when the provider scrolls out of the viewport. Recommended for long pages. |
| as | ElementType | 'div' | Tag to render as. |
Imperative ref: { invalidate(): void } — call it to manually trigger a recapture.
<LiquidGlass>
All visual props are optional; sensible defaults are baked in. Refraction is modelled as three independent layers — edge (rim bend), rim (thin lip just inside the edge), and base (broad centre warp) — each with its own intensity and decay rate.
| Prop | Type | Default | Notes |
|---|---|---|---|
| borderRadius | number (px) | 16 | Rounded corners. A circle is borderRadius = halfMin, a pill is borderRadius = halfHeight with width > height. |
| refraction | number | 0.06 | Edge layer intensity. The dominant refraction term — bend at the rim. Fraction of element size. |
| edgeDistance | number | 0.04 | Decay rate of the edge layer. Higher = bend is concentrated tighter against the rim. |
| rimIntensity | number | 0.025 | Rim layer intensity — a thin extra refraction just inside the edge that reads as a glassy lip. |
| rimDistance | number | 0.12 | Decay rate of the rim layer. |
| baseIntensity | number | 0.012 | Centre layer intensity — a soft broad bend that reads as glass thickness. |
| baseDistance | number | 0.008 | Decay rate of the centre layer. |
| cornerBoost | number | 0.02 | Extra refraction near rectangle corners. No visible effect on circles or pills. Set to 0 to disable. |
| rippleEffect | number | 0 | Tangential rim shimmer — a subtle wavy-glass effect. Try 0.005–0.02. |
| blur | number (px) | 4 | Background Gaussian blur radius in pixels. Sampled in-shader as a 13-tap circular kernel; mipmap LOD is auto-selected for large values. |
| chromaticAberration | number | 0.4 | RGB split along the refraction direction. Try 0.3–0.6 for a subtle prismatic edge. |
| tint | string (CSS color) | '#ffffff' | Solid tint colour mixed in lightly over the refracted background. |
| tintOpacity | number (0–1) | 0.06 | Strength of the tint composition. Drives the vertical-gradient tint, the ambient pickup, and the solid-tint mix. |
| ambientTint | boolean | true | Sample wide horizontal bands of the captured background above and below the element and use them as a vertical gradient tint. This is what gives Apple's glass its "I picked up the colour of the room" feel. |
| specular | number | 0.6 | Strength of the directional rim highlight + the always-on inner rim sheen. Set to 0 to disable both. |
| lightDirection | [number, number] | [0.5, -0.7] | 2D direction of the implied light, in canvas space (positive Y points down). |
| dynamic | boolean | false | If true, recaptures the background every frame for this element. Use only when content behind the glass actually animates. Throttled by the provider's captureCooldownMs (default 50ms = ~20 captures/sec). |
Performance
Three things drive cost, in order:
1. DOM rasterization (the big one). html-to-image is the only realistic way to get the page into a texture, and it takes 30–200ms on a typical viewport. Everything in this library is built around making it run as rarely as possible.
- The capture is explicit by default: it only fires when a MutationObserver detects a real DOM change, when the root scrolls, when the root resizes, or when you call
invalidate(). - A unified capture cooldown (default 50ms) coalesces every source — scroll, mutation, dynamic mode — into one dial. Bursts collapse into a single capture.
- A mutation-only debounce (default 120ms) gives React reconciliation time to settle before capturing. Scrolls don't pay this delay; they only pay the cooldown.
- A fast path skips
html-to-imageentirely when the root contains a single<img>/<video>/<canvas>covering ≥95% of the area — common for hero images and video backgrounds. - Off-screen pause: when
pauseWhenOffscreenis on (default), anIntersectionObserverhalts the rAF loop entirely while the provider is scrolled out of view. Long pages pay nothing for off-screen glass sections. captureScalelets you trade sharpness for speed on the rasterization pass.captureScale={0.7}cuts the texture pixel count in half; the result is invisible after blur.dynamic={true}is opt-in per element, throttled bycaptureCooldownMs. Still a last resort — if only one small region behind the glass is animating, repaint that region into a<canvas>yourself and use the fast path.
2. Shader cost. ~1ms at typical sizes. Three-layer refraction + 13-tap Gaussian (×3 with chromatic aberration) + ambient sampler + specular fits in ~50 texture samples per pixel.
- Blur is a 13-tap circular Gaussian sampled in-shader, with the mipmap LOD chosen by the requested sigma — large blurs keep their sample count fixed and fall back to the prefiltered mip chain for the wide support.
- Refraction normals come from a single SDF gradient (3 SDF samples). The same SDF mathematically reduces to the analytical capsule / circle SDF when
borderRadiusmatches the geometry, so it gives correct shape-aware normals for all three shapes without branching. - Ambient tint samples 24 taps at LOD 5 — independent of element size or blur strength.
- Texture re-uploads use
texSubImage2Dwhen capture dimensions are unchanged, avoiding GPU memory reallocation. - Mipmap regeneration is skipped entirely if no registered element actually needs a mip chain (no blur, no ambient tint).
3. Per-element overhead. One uniform update + one drawArrays call per glass element per frame. Resize bursts on N elements are coalesced into a single root-rect read per frame. Negligible up to a few dozen elements.
Things you can do to keep it fast
- Wrap a constrained area, not the whole page. The provider sizes its canvas to its own bounding rect. Wrapping a 4000px-tall scrolling page costs you a 4000px-tall texture, and
html-to-imagehas to rasterize all of it. Wrap a hero section, a card grid, a modal — not the body. - Drop
captureScaleto0.7or0.5for any glass that uses real blur. The texture gets softer, but blur was already softening it. Visual difference is near-zero, capture cost drops by 2× or 4×. - Avoid putting
<LiquidGlass>in tight loops. Every element costs an extra draw call and aResizeObserver. A header full of glass icons is fine; a virtualized list of 200 glass rows is not. - Use the fast-path layout when you can. If your background is just an image, give it >95% of the root's area and the rasterization step is skipped entirely.
- Set
autoInvalidate={false}and callinvalidate()yourself if you know your background only changes at specific moments. The MutationObserver is cheap but not free, and it can fire on benign style updates from CSS-in-JS. - Don't pass fresh
lightDirection={[x, y]}array literals every render unless you actually want it animated. The component compares it element-wise so a stable value won't cause spurious re-syncs, but the value-equality check still runs.
Constraints to know about
- Glass elements are forced to
z-index: 1. The shared canvas sits atz-index: 1too, painted first via DOM order. Page content at defaultz-index: autosits below; glass children float naturally on top viaisolation: isolate. Other elements with explicitz-index > 1will sit above the canvas — set them higher if you need them above the glass too. - The provider's
positionis forced torelativeif it'sstatic. Otherwise the canvas can't position itself. - Glass element backgrounds are forced transparent. The whole point is for the WebGL canvas underneath to show through.
- No SSR rendering. The provider sets up GL on mount in a
useEffect, so the canvas only exists client-side. The component is marked"use client"for app-router compatibility. - One WebGL context per provider. Browsers cap WebGL contexts per page (~16 in Chrome, fewer in Safari). Don't nest providers; one per "scene" is the intended model.
- Cross-origin images won't capture unless served with
crossorigin="anonymous"and proper CORS headers. This is anhtml-to-imageconstraint.
Low-level API
If you don't want React, the renderer is exported separately:
import { GlassRenderer } from '@el-gladiador/liquid-glass-react/core';
const renderer = new GlassRenderer({ root: document.querySelector('#scene')! });
const handle = renderer.add(document.querySelector('#card')!, {
borderRadius: 24,
refraction: 0.05,
});
handle.setOptions({ blur: 3 });
handle.invalidate();
const stop = renderer.registerDynamic(document.querySelector('#card')!);
stop();
handle.destroy();
renderer.destroy();License
MIT
