DEV Community

Pavel
Pavel

Posted on

Liquid Glass on the Web: 6 Ways to Build It with CSS and SVG

Apple shipped Liquid Glass across iOS 26 and macOS, and suddenly every product I look at has a frosted panel floating over something. I spent a few weeks rebuilding the effect properly for a project, and most of what I found online stops at one line:

backdrop-filter: blur(16px);
Enter fullscreen mode Exit fullscreen mode

Which gives you a gray rectangle. That's not what makes Apple's version look like glass, and figuring out the difference took me longer than it should have. So here are the six techniques I ended up with, roughly in order of how well they're supported, along with the things that wasted my time.


1. The plain glassmorphism card

Everyone knows this one, but there are three parts to it and most implementations ship only the first.

.glass-card {
  position: absolute;
  inset: 20%;
  border-radius: 16px;

  backdrop-filter: blur(16px) saturate(180%);
  -webkit-backdrop-filter: blur(16px) saturate(180%);
  background-color: rgba(255, 255, 255, 0.08);

  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  pointer-events: none;
}
Enter fullscreen mode Exit fullscreen mode

The saturate(180%) is the part I kept forgetting, and it turns out to be the whole trick. Blurring averages colors together, and averaging colors drains saturation out of them — so a pure blur comes out looking like dirty plastic rather than glass. Pushing saturation back up compensates. Drag it down to 100% in the pen above and you'll see the effect just die.

The background tint matters for a similar reason. With a fully transparent background you get a blur but no surface — nothing reads as a physical pane sitting there. Something around 8% white is enough to suggest one without washing out whatever is behind it.

Wrapped in React, so the numbers are adjustable:

"use client";

type GlassCardProps = {
  blur?: number;
  saturate?: number;
  opacity?: number;
  radius?: number;
};

export default function GlassCard({
  blur = 16,
  saturate = 180,
  opacity = 0.08,
  radius = 16,
}: GlassCardProps) {
  return (
    <div
      className={styles.card}
      style={{
        borderRadius: radius,
        backdropFilter: `blur(${blur}px) saturate(${saturate}%)`,
        WebkitBackdropFilter: `blur(${blur}px) saturate(${saturate}%)`,
        backgroundColor: `rgba(255, 255, 255, ${opacity})`,
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things that bit me here. An ancestor with overflow: hidden can clip the backdrop region, which produces a panel that looks like it's blurring nothing — I chased that for an hour before checking the parent. And backdrop-filter creates a containing block, so anything position: fixed inside it starts behaving oddly. Safari still wants the -webkit- prefix, but support is otherwise fine everywhere in 2026.


2. Making the edges fade

Actual frosted glass doesn't stop at a crisp border, and once you notice that, hard-edged panels start looking cheap.

The fix is mask-image. What makes it work is that the mask applies to the element including its backdrop filter — so the blur itself fades out, rather than you covering a blurred rectangle with a gradient and hoping nobody looks closely.

const inner = 100 - feather; // feather: 0–80
const maskImage =
  `radial-gradient(ellipse at center, black ${inner}%, transparent 100%)`;

<div
  style={{
    borderRadius: radius,
    backdropFilter: `blur(${blur}px) saturate(180%)`,
    WebkitBackdropFilter: `blur(${blur}px) saturate(180%)`,
    maskImage,
    WebkitMaskImage: maskImage,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

One property, and the thing goes from "blurred rectangle" to something that looks like frost spreading across a surface. The catch is that the mask eats your border along with everything else, so if you want a visible edge you either keep the feather low or draw the border on a sibling element that isn't masked.


3. Blur that ramps up instead of starting all at once

A single backdrop-filter is uniform across the element, which is wrong for the most common case: content passing under a sticky header, or fading out at the bottom of a hero image. There you want the blur to build, so text dissolves gradually rather than hitting a wall.

CSS still has no gradient blur. What works is stacking several layers, each blurred slightly more than the last, each masked with a linear gradient that exposes only its own slice:

const increment = 100 / divCount;
const direction = position === "top" ? "to top" : "to bottom";

for (let i = 1; i <= divCount; i++) {
  const blur = 0.0625 * i * strength;

  const p1 = Math.round((increment * i - increment) * 10) / 10;
  const p2 = Math.round(increment * i * 10) / 10;
  const p3 = Math.round((increment * i + increment) * 10) / 10;
  const p4 = Math.round((increment * i + increment * 2) * 10) / 10;

  let gradient = `transparent ${p1}%, black ${p2}%`;
  if (p3 <= 100) gradient += `, black ${p3}%`;
  if (p4 <= 100) gradient += `, transparent ${p4}%`;

  const mask = `linear-gradient(${direction}, ${gradient})`;

  divs.push(
    <div
      key={i}
      className={styles.layer}
      style={{
        maskImage: mask,
        WebkitMaskImage: mask,
        backdropFilter: `blur(${blur.toFixed(3)}rem)`,
        WebkitBackdropFilter: `blur(${blur.toFixed(3)}rem)`,
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The overlapping is the important bit — each layer's mask extends one increment into its neighbors', so the seams cross-fade instead of banding. My first version had them meeting exactly edge to edge and you could count the steps.

Seven layers turned out to be where I stopped seeing steps. Going higher just costs compositing nobody perceives, and it does cost: this is N stacked backdrop-filter layers, each one a separate composited pass. On a small fixed header it's a non-issue. Full-screen at 20 layers on a mid-range Android, it very much is, and you won't find that out on your laptop.


4. Actual refraction, using SVG

Blur can't refract. Refraction means displacing pixels, and blur averages them — different operations, and no amount of blur will get you the bending you see at the edge of Apple's glass.

SVG filters have done displacement for over a decade, and no WebGL is involved:

<svg aria-hidden="true" style={{ position: "absolute", width: 0, height: 0 }}>
  <defs>
    <filter id={filterId} x="-10%" y="-10%" width="120%" height="120%">
      <feTurbulence
        type="fractalNoise"
        baseFrequency={`${frequency} ${frequency}`}
        numOctaves={3}
        result="noise"
      >
        <animate
          attributeName="baseFrequency"
          values={`${frequency} ${frequency};${frequency * 1.4} ${frequency * 1.2};${frequency} ${frequency}`}
          dur="6s"
          repeatCount="indefinite"
        />
      </feTurbulence>
      <feDisplacementMap
        in="SourceGraphic"
        in2="noise"
        scale={scale}
        xChannelSelector="R"
        yChannelSelector="G"
      />
    </filter>
  </defs>
</svg>

<div style={{ filter: `url(#${filterId})` }}>{children}</div>
Enter fullscreen mode Exit fullscreen mode

feTurbulence generates Perlin-ish noise, and feDisplacementMap reads the red and green channels of that noise to decide how far to shift each source pixel. Animating baseFrequency through SMIL makes the whole surface breathe, with no JavaScript at all — which still feels like cheating to me.

Two things I'd tell my past self. First, generate the filter ID per component instance; useId() is fine if you strip the colons out, since they're awkward inside url(). I had two of these on one page sharing an ID and spent a while wondering why the second one was ignoring its props. Second, grow the filter region — x="-10%" width="120%" gives displaced pixels somewhere to land. At the default region they get clipped and you get a hard edge in the middle of an organic effect.

This works everywhere, which makes it the most practical way to get real distortion on the web.


5. SVG filters inside backdrop-filter

This is the one that finally looked like Apple's glass to me, and also the one I can't ship.

backdrop-filter accepts url(#filter), which means you can run a whole filter chain against the backdrop rather than against the element's own content. Turbulence, displacement, blur:

<filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
  <feTurbulence
    type="fractalNoise"
    baseFrequency="0.012 0.012"
    numOctaves={3}
    seed={2}
    result="noise"
  />
  <feDisplacementMap
    in="SourceGraphic"
    in2="noise"
    scale={scale}
    xChannelSelector="R"
    yChannelSelector="G"
    result="displaced"
  />
  <feGaussianBlur in="displaced" stdDeviation={blur} result="blurred" />
</filter>
Enter fullscreen mode Exit fullscreen mode
<div
  style={{
    position: "absolute",
    inset: -20, // overscan
    backdropFilter: `url(#${filterId})`,
    WebkitBackdropFilter: `url(#${filterId})`,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Content passing behind the panel genuinely bends at the edges. The photo smears the way it does through a real lens, and the text crossing the boundary visibly warps.

The problem is that only Chromium supports SVG filters in backdrop-filter. Safari and Firefox render nothing at all — not a degraded version, nothing — so this only makes sense as progressive enhancement with a plain blur() saturate() fallback underneath. The pen above ships exactly that fallback, which is why it still looks like glass if you're reading this in Safari; you're just not seeing the displacement. Open it in Chrome for the real thing.

Worth knowing that @supports can't help you here. Safari parses backdrop-filter: url(#f) perfectly happily and then renders nothing, so the feature query reports success and lies to you. I lost an afternoon to that before giving up and detecting the engine.

That inset: -20px is load-bearing, by the way. Displacement pulls pixels inward from the edges, and without the overscan you get transparent gutters around the panel.


6. Apple's actual material

iOS 26 Safari exposes the real system material to the web through a vendor-prefixed property:

.apple-glass {
  border-radius: 20px;
  border: 1px solid rgba(255, 255, 255, 0.15);
  -apple-visual-effect: -apple-system-glass-material;
}

@supports not (-apple-visual-effect: -apple-system-glass-material) {
  .apple-glass {
    backdrop-filter: blur(12px) saturate(180%);
    -webkit-backdrop-filter: blur(12px) saturate(180%);
    background: rgba(255, 255, 255, 0.08);
  }
}
Enter fullscreen mode Exit fullscreen mode

This isn't an approximation of the OS material, it is the OS material — same specular highlights, same adaptive tinting, same dark mode behavior, none of which you have to implement. And because the @supports not (...) block catches everyone else, adopting it costs you nothing. Two rules, and on the platform that defined the look, your glass looks native.


Bonus: the version where you give up and use WebGL

Everything above is CSS and SVG, which is the point — it ships anywhere and costs you no dependencies. But there is a ceiling, and it's this: none of those techniques compute where light actually goes. feDisplacementMap shifts pixels by a noise value, which looks organic but isn't physics.

If you want real refraction, you need a shader:

vec3 normal = normalize(vec3(delta / radius, max(nz, 0.1)));
vec3 refracted = refract(-viewDir, normal, 1.0 / uIOR);
vec2 lensUV = center + delta + refracted.xy * glassDepth / abs(refracted.z);
Enter fullscreen mode Exit fullscreen mode

That's Snell's law, one line, built into GLSL. Feed it a hemisphere normal and an index of refraction and you get a lens rather than a smear — drag the IOR slider in the pen and watch the sphere go from window glass to something closer to a marble.

No Three.js in there — one full-screen quad and about 120 lines of GLSL, because a sphere that's decided entirely in the fragment shader doesn't need a geometry library. The trade is the obvious one: a WebGL context, a canvas that isn't part of your layout, and a GPU cost that's real on low-end hardware. I cap it at 30fps and pause it off-screen, which is enough. Worth it for a hero element, absurd for a nav bar.


What I'd actually keep in mind

The thing that surprised me most is how much of this is a legibility problem rather than a rendering problem. Glass is a variable background, and if your panel floats over user content or a scrolling page, you don't control what ends up behind your text. I designed over a dark evenly-lit photo, white text looked great, and then a bright sky scrolled underneath and the text vanished. The mockup was never the worst case — test against the worst case.

Performance is more nuanced than "blur is slow." backdrop-filter makes the compositor read back and re-filter the region behind the element on every frame that region changes. A static panel on a static page costs nothing measurable. A blurred sticky header over a scrolling page costs on every scroll frame, on every device your users own, and stacked layers multiply that. Static blur is basically free; blur over moving content needs a real measurement on real hardware.

And if you use the animated distortion, honor prefers-reduced-motion. Slow continuous movement across a refracting surface is exactly the pattern that makes people queasy. Freezing the animation and keeping the material loses nothing worth having.

Last thing: all of these are a function of what's behind them. They look fantastic over a saturated photo and nearly invisible over flat gray, so evaluate them on your real backgrounds, not on a gradient you picked because it made the demo pop.


I put live demos of all of these with sliders for every parameter — plus a Three.js MeshTransmissionMaterial torus I couldn't fit in here — at https://devpavel.com/docs/glass-effects, with copy-paste code in React TS, React JS and vanilla.

Curious where the performance wall actually is for other people. If you've shipped blur over scrolling content, did it cost you anything real on mobile?

Top comments (0)