Recreate Apple's Liquid Glass Effect on the Web
Liquid Glass is the translucent material Apple introduced across iOS,
iPadOS, and macOS in 2025: interface surfaces that behave like real
glass — they blur and refract what's behind them, catch specular light
at the edges, and subtly warp in motion. On the web you can approximate
it with CSS backdrop-filter or actually
simulate it with a WebGL refraction shader. This page
covers both, with working code from two live demos.
What makes liquid glass "liquid"
The effect is a stack of five optical ingredients:
- Translucency + blur — the content behind shows through, softened.
- Refraction — the glass acts as a lens, displacing the backdrop rather than just blurring it.
- Chromatic aberration — red, green, and blue bend slightly differently at the lens edge, producing color fringing.
- Specular light — a directional highlight, a brighter fresnel edge, and a thin rim where the glass boundary catches light.
- Motion — organic ripples and warp that make the surface read as liquid instead of frosted plastic.
CSS gives you ingredient 1 cheaply and universally. Ingredients 2–5 are per-pixel effects, which is shader territory.
CSS vs WebGL at a glance
| CSS backdrop-filter | WebGL shader | |
|---|---|---|
| Fidelity | Frosted-glass look: blur, saturation, borrowed highlights via borders and inset shadows. No true refraction or aberration | Full simulation — lens displacement, chromatic fringing, specular, fresnel, animated ripples, all per pixel |
| Browser support | All modern browsers (with -webkit- prefix for older Safari) | WebGL is universal; feeding it live HTML as a texture needs the HTML-in-Canvas dev trial (Chromium flag) |
| Cost | One declaration; compositor-accelerated | A render loop, a texture upload per frame, and ~100 lines of GLSL |
| Interactivity under the glass | Native — the backdrop is regular DOM | Preserved via layoutsubtree + drawElementImage(): the HTML under the shader stays clickable |
The CSS-only approach
For most UI — nav bars, cards, control panels — the CSS version is the
right call. The core is a translucent background plus
backdrop-filter, with the "glass edge" faked using a
semi-opaque border and an inset top highlight:
.liquid-glass {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(18px) saturate(1.6);
-webkit-backdrop-filter: blur(18px) saturate(1.6);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 20px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.25), /* top specular line */
inset 0 -1px 0 rgba(255, 255, 255, 0.06),
0 8px 32px rgba(0, 0, 0, 0.35);
} That gets you frosted glass, not liquid glass — there's no lensing. You can push refraction into CSS with an SVG displacement filter:
<svg width="0" height="0" aria-hidden="true">
<filter id="liquid-lens">
<feTurbulence type="fractalNoise" baseFrequency="0.008"
numOctaves="2" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="60"
xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
<style>
.liquid-glass {
/* Chromium-only: url() filters inside backdrop-filter are not
supported in Safari or Firefox — keep a blur-only fallback. */
backdrop-filter: blur(4px) url(#liquid-lens);
}
</style> Honest limits of the CSS route:
-
url()filters inbackdrop-filterare Chromium-only. Safari and Firefox ignore the displacement and fall back to plain blur, so design for the blur-only case. - The distortion is static.
feTurbulencegives one fixed warp field; a lens that follows the pointer, ripples, or eases in and out is not expressible. - No chromatic aberration or physically plausible specular — the highlights are hand-placed gradients, not derived from lens geometry.
The WebGL shader approach
The Liquid Glass Distortion demo does
the real thing. Its pipeline has two canvases: a 2D canvas that holds a
live HTML profile card as a
layoutsubtree child and paints
it with drawElementImage(), and a WebGL canvas overlaid on
top that treats those pixels as a texture:
<!-- 2D source canvas: the HTML card is a real, live child -->
<canvas id="source-canvas" layoutsubtree>
<div class="card" id="card">…fully styled, interactive HTML…</div>
</canvas>
<!-- WebGL overlay: the refraction shader draws on top -->
<canvas id="glass-canvas" style="pointer-events: none"></canvas>
<script>
// 1. Draw the live DOM into the 2D canvas whenever it changes
sourceCanvas.onpaint = () => {
ctx2d.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height);
ctx2d.drawElementImage(card, 0, 0,
sourceCanvas.width, sourceCanvas.height);
};
// 2. Every animation frame, feed it to the shader as a texture
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
gl.UNSIGNED_BYTE, sourceCanvas);
</script>
All the optics live in the fragment shader. A dome term —
a smoothstep falloff around the mouse position — masks every effect to
a circular lens, so the same code fades everything in and out as the
pointer moves. Refraction is an inward displacement scaled by the dome;
liquidity comes from two drifting simplex-noise fields plus a ripple
term; and chromatic aberration falls out of sampling the texture three
times with per-channel offsets:
/* --- Glass lens geometry: a dome that follows the mouse --- */
float radius = 0.30 * u_hover;
float dome = smoothstep(radius, radius * 0.08, dist);
/* --- Refraction displacement (convex lens, pushes inward) --- */
float refractStr = 0.06 * dome;
vec2 refractOffset = -dir * refractStr;
/* --- Organic liquid noise: two simplex fields drifting over time --- */
float n1 = simplex(uv * 6.0 + vec2(t, -t * 0.7));
float n2 = simplex(uv * 8.0 + vec2(-t * 0.5, t * 0.8));
vec2 organicWarp = vec2(n1, n2) * 0.012 * dome;
/* --- Ripple rings radiating from the lens center --- */
float ripple = sin(dist * 35.0 - u_time * 2.5) * 0.003 * dome;
/* --- Chromatic aberration: sample R/G/B at offset UVs --- */
float edge = smoothstep(radius * 0.2, radius, dist) * dome;
float aberr = 0.003 * dome + 0.007 * edge;
vec2 uvR = clamp(uv + offset * 1.07 + dir * aberr, 0.0, 1.0);
vec2 uvG = clamp(uv + offset, 0.0, 1.0);
vec2 uvB = clamp(uv + offset * 0.93 - dir * aberr, 0.0, 1.0);
vec3 color = vec3(texture2D(u_texture, uvR).r,
texture2D(u_texture, uvG).g,
texture2D(u_texture, uvB).b); Lighting is layered on the same dome mask: a tight specular highlight (exponent 28) from a fixed light direction, a fresnel term that brightens the lens rim, a thin ring at the glass boundary, a faint caustic shimmer, and the blue-shifted tint that gives Apple's material its cool cast:
/* --- Specular highlight (light from upper-right) --- */
vec2 lightDir = normalize(vec2(0.6, -0.45));
float specAngle = dot(dir, lightDir);
float spec1 = pow(max(specAngle, 0.0), 28.0) * dome * 0.40;
/* --- Fresnel: lens edges catch more light --- */
float fresnel = pow(smoothstep(radius * 0.25, radius, dist), 0.55)
* dome * 0.10;
/* --- Thin bright ring at the glass boundary --- */
float ring = smoothstep(radius, radius * 0.91, dist)
* smoothstep(radius * 0.82, radius * 0.91, dist) * 0.22;
/* --- Cool glass tint (Apple-inspired blue-shift) --- */
vec3 tint = vec3(0.93, 0.96, 1.06);
color = mix(color, color * tint, dome * 0.20);
color += spec1 + spec2 + fresnel + ring + caustic; The differentiator: live HTML under the glass
Any WebGL tutorial can distort a static image. What makes this
technique different is that the texture is live DOM. The
profile card under the shader is real HTML — its buttons work, its text
is selectable in the accessibility tree, and clicking "Follow" through
the distortion updates the card, which fires the canvas's
paint event, which re-captures the card via
drawElementImage(), which the shader picks up on the next
frame. The WebGL overlay sets pointer-events: none, so
input passes straight through the glass to the elements beneath it.
That loop — interact, repaint, re-texture — is the piece screenshot libraries and prerendered textures can't do, and it's the core of the native drawElementImage() approach to rendering HTML to canvas. The API is a Chromium dev trial: the flag setup guide takes about two minutes.
Frosted glass: going beyond backdrop-filter
The companion Frosted Glass
Backdrop demo attacks the blur half of the material.
backdrop-filter only offers a standard gaussian blur; the
demo composites between HTML layers instead, so the kernel is
whatever you write — gaussian, directional (motion-blur style), or
tilt-shift with a focus band. Per paint, it draws the background HTML,
extracts the region behind a draggable panel, blurs it with a two-pass
separable gaussian in WebGL, composites tint and highlights, then draws
the panel's own HTML on top:
canvas.onpaint = () => {
/* 1. Draw the background card grid (live HTML) */
ctx.drawElementImage(bgLayer, 0, 0, cssW, cssH);
/* 2. Copy the pixels behind the panel into a scratch canvas */
tempCtx.drawImage(canvas, sx, sy, sw, sh, sx - dpx, sy - dpy, sw, sh);
/* 3. Blur them in WebGL — two-pass separable gaussian,
or directional / tilt-shift kernels */
processBlur(dpw, dph);
/* 4. Composite blurred backdrop + tint + gradient highlight
+ border inside a rounded clip */
ctx.drawImage(blurCanvas, 0, 0, dpw, dph, px, py, pw, ph);
/* 5. Draw the panel's own HTML content on top */
ctx.drawElementImage(frostPanel, px, py, pw, ph);
}; Try both demos
- Liquid Glass Distortion — the WebGL refraction shader over a live HTML card. Move the mouse to bend the glass; click the buttons through it.
- Frosted Glass Backdrop — draggable frosted panel with gaussian, directional, and tilt-shift blur composited between HTML layers.
Both ship complete annotated source. For more techniques, browse the full gallery of HTML canvas examples or start with the spec overview.