Wood Chen

iOS 26-Style Liquid Glass on the Web—with Real Refraction

0 comments309 views1.3k words

This post was translated from Chinese by AI. If anything reads oddly, the Chinese original is authoritative. 中文原文

In iOS 26, Apple upgraded "frosted glass" to "Liquid Glass". The most obvious difference is that the edges actually bend and refract the background, rather than applying a uniform frosted effect across the whole surface. Web implementations quickly appeared in the community, but most still rely on backdrop-filter: blur with a few gradient highlights, leaving them looking rather plastic.

This post shares a true refraction approach that runs on the web and looks close to iOS 26: chain an SVG feDisplacementMap into CSS backdrop-filter, and generate the displacement map on the fly using <canvas> and an SDF. The whole thing takes fewer than 200 lines.

First, compare the effects

Regular frosted glass:

  • Uniform blur across the entire surface
  • The center and edges look the same
  • Highlights come from layered static gradients

True refractive glass:

  • The center is clear and transparent, letting background colors show through directly
  • Near the edges, the background is "pushed" outward, creating visible curvature
  • A thin highlight band runs around the glass edge (caused by light concentrating through refraction itself)

The core idea

CSS backdrop-filter supports more than functions like blur and saturate: it can also chain in SVG filters:

backdrop-filter: url(#my-filter) blur(0.5px) saturate(180%);

url(#my-filter) takes the content behind the element as input, runs it through the SVG filter chain, and returns the result. What we need is SVG's <feDisplacementMap>: it shifts each pixel in a specified direction based on the R/G channels of a "displacement map".

<filter id="my-filter">
  <feImage href="data:image/png;base64,..." />
  <feDisplacementMap in="SourceGraphic" in2="map"
                     xChannelSelector="R" yChannelSelector="G" />
</filter>

The R channel value minus 128 = displacement along X; the G channel value minus 128 = displacement along Y. The displacement map determines how the background is warped.

That leaves one question: how do we generate the displacement map?

The displacement map: describe rounded rectangle edges with an SDF

Here are the displacement rules we want:

  • Element center: displacement = 0 (the background stays still)
  • A band near the edges: displacement = radially outward (the background is "pushed" outward)
  • Outside the element: displacement = 0 (no effect)

For a continuous transition from "center → edge", use an SDF (Signed Distance Field) — the distance from each pixel to the shape's boundary, negative inside, 0 at the edge, and positive outside.

The SDF formula for a rounded rectangle:

function roundedRectSDF(x, y, halfW, halfH, radius) {
  const qx = Math.abs(x) - halfW + radius;
  const qy = Math.abs(y) - halfH + radius;
  return Math.min(Math.max(qx, qy), 0)
       + Math.hypot(Math.max(qx, 0), Math.max(qy, 0))
       - radius;
}

You don't need to understand every detail. This is Inigo Quilez's standard implementation; just copy it.

With the SDF in place, generating the displacement map becomes straightforward:

for (每个像素 (x, y)) {
  const dist = roundedRectSDF(x, y, ...);

  // Apply displacement only within [-band, 0]: from a point inside to the edge
  const t = smoothStep(-band, 0, dist);

  // Displacement direction: radially outward
  const len = Math.hypot(x, y);
  const dirX = x / len;
  const dirY = y / len;
  const mag = t * strength;

  位移图.R = dirX * mag * 128 + 128;
  位移图.G = dirY * mag * 128 + 128;
}

smoothStep is a smooth interpolation function commonly used in shaders. Its derivative is 0 at both ends of the 0 → 1 transition, preventing hard edges in the refraction band.

band is the width of the refraction band, and strength is the maximum displacement. I use band = 12% × 长边 and strength = 6% × 长边; these two parameters determine how "thick" the glass looks.

Encoding into RGB channels

Each channel in the displacement map received by feDisplacementMap is an 8 bit integer (0-255), representing a displacement range of -128 to 127 pixels. So we need to:

  1. Find the maximum absolute displacement across all pixels, maxScale
  2. Normalize each dx/dy to [-1, 1]: dx / maxScale
  3. Map it to [0, 255]: (dx / maxScale / 2 + 0.5) × 255
  4. Set the scale attribute on feDisplacementMap to tell SVG this maximum value in px
data[p]     = (dx / maxScale / 2 + 0.5) * 255;  // R
data[p + 1] = (dy / maxScale / 2 + 0.5) * 255;  // G
data[p + 2] = 0;
data[p + 3] = 255;
// ...
feDisp.setAttribute("scale", String(maxScale * 2));

We use scale × 2 because the 8 bit encoding range is [-128, 127], corresponding to the [-1, 1] radius, so the maximum displacement magnitude is 2 × maxScale.

One detail: DPR

Generating the canvas directly in CSS pixels produces visible pixelation and jittery transitions on Retina screens (DPR=2/3). The fix is to render the canvas at devicePixelRatio resolution, then set preserveAspectRatio="none" on <feImage> so SVG automatically scales it down to the CSS dimensions. The scale value of feDisplacementMap also needs to be divided by dpr to convert it back to CSS pixels.

const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = width * dpr;
canvas.height = height * dpr;

// ...

return {
  url: canvas.toDataURL(),
  scale: (maxScale * 2) / dpr,
};

Math.min(2, ...) caps the resolution on mobile; processing 9 times as many pixels on a 3x screen is too expensive.

Another detail: rectangular elements

In my first version, cards with equal width and height looked natural, but tall cards showed obvious distortion at the edges. The reason was that each coordinate axis was normalized independently to [-0.5, 0.5], making the "radial direction" calculated by len = sqrt(u² + v²) inaccurate for rectangles.

The correct approach is to normalize by the longer side and scale the shorter side proportionally:

const longSide = Math.max(width, height);
const halfW = width / longSide / 2;
const halfH = height / longSide / 2;
// When iterating over pixels:
const u = (x / canvasW - 0.5) * (width / longSide);
const v = (y / canvasH - 0.5) * (height / longSide);

This makes the refraction strength fall off along the true radial direction, regardless of aspect ratio.

Making it work in any React app

All the math is now done. What's left is the integration: automatically applying this effect to any element with the .liquid-glass class.

"use client";
import { useEffect } from "react";

export default function LiquidGlassEffect() {
  useEffect(() => {
    // Fall back immediately in browsers that don't support backdrop-filter: url()
    if (!CSS.supports?.("backdrop-filter", "url(#x)")) return;

    const elements = new Set<HTMLElement>();
    const resizeObserver = new ResizeObserver(entries => {
      for (const e of entries) attachFilter(e.target as HTMLElement);
    });

    const collect = () => {
      document.querySelectorAll<HTMLElement>(".liquid-glass").forEach(el => {
        if (elements.has(el)) return;
        elements.add(el);
        resizeObserver.observe(el);
        attachFilter(el);
      });
    };

    collect();

    // Watch for nodes inserted by subsequent React renders
    const mo = new MutationObserver(collect);
    mo.observe(document.body, { childList: true, subtree: true });

    return () => {
      mo.disconnect();
      resizeObserver.disconnect();
    };
  }, []);

  return null;
}

attachFilter does the following:

  1. Reads the element's getBoundingClientRect() to get width/height
  2. Reads getComputedStyle().borderRadius to get the corner radius
  3. Checks the cache (no regeneration for the same dimensions)
  4. Creates (or updates) an SVG filter and inserts the displacement map
  5. Sets backdrop-filter: url(#filter-id) blur(0.5px) saturate(180%) brightness(1.05) on the element's inline style

ResizeObserver handles size changes, and MutationObserver handles dynamically added nodes. Rendering <LiquidGlassEffect /> once at the top level of the layout is enough.

The CSS Part

JS handles refraction; CSS handles the rest of the "glass":

.liquid-glass {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  background: rgba(255, 255, 255, 0.04);

  /* Fallback: at least provide frosted glass even without the injected filter */
  backdrop-filter: blur(12px) saturate(180%);
  -webkit-backdrop-filter: blur(12px) saturate(180%);

  /* Three edge-lighting layers: thin bright outer edge + top highlight + bottom shadow + drop shadows */
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.16),
    inset 0 1px 0 rgba(255, 255, 255, 0.28),
    inset 0 -1px 0 rgba(0, 0, 0, 0.12),
    0 6px 20px rgba(0, 0, 0, 0.22),
    0 18px 40px rgba(0, 0, 0, 0.16);

  border: none;
  transition: box-shadow 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.liquid-glass:hover {
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.22),
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 0 rgba(0, 0, 0, 0.14),
    0 8px 24px rgba(0, 0, 0, 0.26),
    0 20px 44px rgba(0, 0, 0, 0.18);
}

isolation: isolate creates a compositing layer to prevent interference from parent blend modes; the three inset shadow layers simulate the optical thickness of the glass edges — the thin bright outer edge reflects light off the glass rim, the top highlight reflects ambient light off the top edge, and the bottom shadow represents shading on the bottom inner surface.

Usage

<div className="liquid-glass rounded-2xl p-6">内容</div>

Refraction becomes visible as long as the element sits over a colorful background. The more vivid and textured the background, the more noticeable the effect. On a solid white or black background, the refractive distortion is almost invisible because there's nothing to "warp".

Complete Code

A consolidated version you can copy directly:

liquid-glass.css

.liquid-glass {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  background: rgba(255, 255, 255, 0.04);
  backdrop-filter: blur(12px) saturate(180%);
  -webkit-backdrop-filter: blur(12px) saturate(180%);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.16),
    inset 0 1px 0 rgba(255, 255, 255, 0.28),
    inset 0 -1px 0 rgba(0, 0, 0, 0.12),
    0 6px 20px rgba(0, 0, 0, 0.22),
    0 18px 40px rgba(0, 0, 0, 0.16);
  border: none;
  transition: box-shadow 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.liquid-glass > * {
  position: relative;
  z-index: 1;
}

.liquid-glass:hover {
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.22),
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 0 rgba(0, 0, 0, 0.14),
    0 8px 24px rgba(0, 0, 0, 0.26),
    0 20px 44px rgba(0, 0, 0, 0.18);
}

@media (prefers-reduced-motion: reduce) {
  .liquid-glass,
  .liquid-glass:hover {
    transition: none;
  }
}

LiquidGlassEffect.tsx

"use client";

import { useEffect } from "react";

const SVG_NS = "http://www.w3.org/2000/svg";
const XLINK_NS = "http://www.w3.org/1999/xlink";

interface GlassMeta {
  id: string;
  width: number;
  height: number;
  radius: number;
}

const glassMap = new WeakMap<HTMLElement, GlassMeta>();
let svgContainer: SVGSVGElement | null = null;
let idCounter = 0;

function ensureSvgContainer(): SVGSVGElement {
  if (svgContainer && document.body.contains(svgContainer)) return svgContainer;
  const svg = document.createElementNS(SVG_NS, "svg");
  svg.setAttribute("xmlns", SVG_NS);
  svg.setAttribute("width", "0");
  svg.setAttribute("height", "0");
  svg.style.cssText =
    "position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;overflow:hidden;";
  const defs = document.createElementNS(SVG_NS, "defs");
  svg.appendChild(defs);
  document.body.appendChild(svg);
  svgContainer = svg;
  return svg;
}

function smoothStep(a: number, b: number, t: number): number {
  const v = Math.max(0, Math.min(1, (t - a) / (b - a)));
  return v * v * (3 - 2 * v);
}

function roundedRectSDF(
  x: number,
  y: number,
  halfW: number,
  halfH: number,
  r: number
) {
  const qx = Math.abs(x) - halfW + r;
  const qy = Math.abs(y) - halfH + r;
  return (
    Math.min(Math.max(qx, qy), 0) +
    Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) -
    r
  );
}

function buildDisplacementMap(
  width: number,
  height: number,
  cornerRadiusPx: number
) {
  const dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
  const cw = Math.round(width * dpr);
  const ch = Math.round(height * dpr);
  const canvas = document.createElement("canvas");
  canvas.width = cw;
  canvas.height = ch;
  const ctx = canvas.getContext("2d");
  if (!ctx) return { url: "", scale: 0 };

  const image = ctx.createImageData(cw, ch);
  const data = image.data;

  const longSide = Math.max(width, height);
  const halfW = width / longSide / 2;
  const halfH = height / longSide / 2;
  const radius = Math.min(
    cornerRadiusPx / longSide,
    Math.min(halfW, halfH) - 0.001
  );

  const refractionBand = 0.12;
  const refractionStrength = 0.06;

  let maxScale = 0;
  const buf = new Float32Array(cw * ch * 2);
  let bi = 0;

  for (let y = 0; y < ch; y++) {
    const v = (y / ch - 0.5) * (height / longSide);
    for (let x = 0; x < cw; x++) {
      const u = (x / cw - 0.5) * (width / longSide);
      const dist = roundedRectSDF(u, v, halfW, halfH, radius);
      const t = smoothStep(-refractionBand, 0, dist);
      const len = Math.hypot(u, v) || 1;
      const mag = t * refractionStrength * longSide;
      const dx = (u / len) * mag;
      const dy = (v / len) * mag;
      maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy));
      buf[bi++] = dx;
      buf[bi++] = dy;
    }
  }

  if (maxScale === 0) maxScale = 1;

  let i = 0;
  for (let p = 0; p < data.length; p += 4) {
    const r = buf[i++] / maxScale / 2 + 0.5;
    const g = buf[i++] / maxScale / 2 + 0.5;
    data[p] = r * 255;
    data[p + 1] = g * 255;
    data[p + 2] = 0;
    data[p + 3] = 255;
  }

  ctx.putImageData(image, 0, 0);
  return { url: canvas.toDataURL(), scale: (maxScale * 2) / dpr };
}

function attachFilter(el: HTMLElement) {
  const rect = el.getBoundingClientRect();
  const width = Math.round(rect.width);
  const height = Math.round(rect.height);
  if (width < 8 || height < 8) return;

  const radius = parseFloat(getComputedStyle(el).borderRadius) || 16;
  const existing = glassMap.get(el);
  if (
    existing &&
    existing.width === width &&
    existing.height === height &&
    existing.radius === radius
  ) {
    return;
  }

  const svg = ensureSvgContainer();
  const defs = svg.querySelector("defs")!;
  const id = existing?.id ?? `lg-filter-${++idCounter}`;
  let filter = defs.querySelector<SVGFilterElement>(`#${id}`);
  let feImage: SVGFEImageElement;
  let feDisp: SVGFEDisplacementMapElement;

  if (!filter) {
    filter = document.createElementNS(SVG_NS, "filter");
    filter.setAttribute("id", id);
    filter.setAttribute("filterUnits", "userSpaceOnUse");
    filter.setAttribute("colorInterpolationFilters", "sRGB");
    filter.setAttribute("x", "0");
    filter.setAttribute("y", "0");

    feImage = document.createElementNS(SVG_NS, "feImage");
    feImage.setAttribute("id", `${id}-img`);
    feImage.setAttribute("x", "0");
    feImage.setAttribute("y", "0");

    feDisp = document.createElementNS(SVG_NS, "feDisplacementMap");
    feDisp.setAttribute("in", "SourceGraphic");
    feDisp.setAttribute("in2", `${id}-img`);
    feDisp.setAttribute("xChannelSelector", "R");
    feDisp.setAttribute("yChannelSelector", "G");

    filter.appendChild(feImage);
    filter.appendChild(feDisp);
    defs.appendChild(filter);
  } else {
    feImage = filter.querySelector("feImage") as SVGFEImageElement;
    feDisp = filter.querySelector(
      "feDisplacementMap"
    ) as SVGFEDisplacementMapElement;
  }

  filter.setAttribute("width", String(width));
  filter.setAttribute("height", String(height));
  feImage.setAttribute("width", String(width));
  feImage.setAttribute("height", String(height));
  feImage.setAttribute("preserveAspectRatio", "none");

  const { url, scale } = buildDisplacementMap(width, height, radius);
  feImage.setAttributeNS(XLINK_NS, "href", url);
  feImage.setAttribute("href", url);
  feDisp.setAttribute("scale", String(scale));

  const filterChain = `url(#${id}) blur(0.5px) saturate(180%) brightness(1.05)`;
  el.style.setProperty("backdrop-filter", filterChain);
  el.style.setProperty("-webkit-backdrop-filter", filterChain);

  glassMap.set(el, { id, width, height, radius });
}

export default function LiquidGlassEffect() {
  useEffect(() => {
    if (typeof window === "undefined") return;
    if (!CSS.supports?.("backdrop-filter", "url(#x)")) return;

    const elements = new Set<HTMLElement>();
    const resizeObserver = new ResizeObserver(entries => {
      for (const e of entries) attachFilter(e.target as HTMLElement);
    });

    const collect = () => {
      document.querySelectorAll<HTMLElement>(".liquid-glass").forEach(el => {
        if (elements.has(el)) return;
        elements.add(el);
        resizeObserver.observe(el);
        attachFilter(el);
      });
    };

    collect();
    const mo = new MutationObserver(collect);
    mo.observe(document.body, { childList: true, subtree: true });

    const onResize = () =>
      elements.forEach(el => {
        if (document.body.contains(el)) attachFilter(el);
        else elements.delete(el);
      });
    window.addEventListener("resize", onResize);

    return () => {
      mo.disconnect();
      resizeObserver.disconnect();
      window.removeEventListener("resize", onResize);
    };
  }, []);

  return null;
}

Known Limitations

Safari: backdrop-filter: url() is not supported in Safari ≤17. I check with CSS.supports() and skip it, keeping the standard frosted-glass CSS fallback. Edge highlights still work, just without true refraction.

Generation cost: Each element initially requires computing the SDF for width × height × dpr² pixels. A 1000×600 element at DPR=2 requires 4.8 million pixel calculations, blocking the main thread for 30-50ms. Keep glass elements around card size (≤500×400), and use requestIdleCallback to generate larger ones asynchronously.

dataURL size: Each element's displacement map is inlined as base64 in the SVG href, taking about 2MB for a 1000×600 element. If a page has dozens of glass elements, consider URL.createObjectURL(blob) + an LRU cache, deduplicating by size + corner radius.

No visible effect on solid-color backgrounds: Refraction essentially "distorts background textures"; a solid-color background has nothing to distort. If you want a glass effect on a solid background, stick with the traditional backdrop-filter: blur + gradient highlights approach rather than forcing refraction.

Tuning Guide

If you're unhappy with the look, adjust these three constants:

Parameter Default Effect of increasing Effect of decreasing
refractionBand 0.12 Wider refraction band, softer transition Refraction only at the outermost edge, sharper edges
refractionStrength 0.06 More dramatic bending, prone to tearing Closer to flat glass
blur(0.5px) 0.5px Smooths jagged edges, but blurs refraction details Sharper refraction, possible moiré patterns

band and strength are usually adjusted together: increase both for "thick glass" and decrease both for "thin glass".

References

Related posts

Comments 0