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:

  1. Translucency + blur — the content behind shows through, softened.
  2. Refraction — the glass acts as a lens, displacing the backdrop rather than just blurring it.
  3. Chromatic aberration — red, green, and blue bend slightly differently at the lens edge, producing color fringing.
  4. Specular light — a directional highlight, a brighter fresnel edge, and a thin rim where the glass boundary catches light.
  5. 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:

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

Both ship complete annotated source. For more techniques, browse the full gallery of HTML canvas examples or start with the spec overview.