Build guide · CSS and web

Liquid glass in CSS, with real refraction

Blur with a white border is glassmorphism. Liquid glass bends the backdrop at its edge, and on the web that takes an SVG displacement map applied through backdrop-filter: url(). Chromium browsers render it; Safari and Firefox fall back to frost, so ship both.

Below is the whole web recipe the Meniscus MCP server hands to coding agents: the displacement map in framework-free TypeScript, the markup and CSS, a React component, the rules that make or break it, the frost fallback and a WebGL shader for hero visuals.

Updated · the recipe the Meniscus MCP server returns for this medium

In short: Tier A — an SVG displacement map applied through backdrop-filter: url() — with the CSS frost fallback; the WebGL shader for hero and effect visuals.

Limits: the tier A bend renders only in Chromium browsers; Safari and Firefox get tier B frost with no bend, so screenshot both. The WebGL tier bends only a backdrop it can sample (an image, video or drawn scene), not the live DOM.

Tiers

TierWhereWhat you get
A. SVG displacement backdropChromium browsers (Chrome, Edge, Arc, Opera, Brave; also headless Chrome for screenshots)real bezel refraction of the live page
B. CSS frost fallbackSafari, Firefox, and when A is disabledblur + saturation + fill + rim; no bending
C. WebGL / canvas shaderall browsers, when the backdrop is known (an image, video or a scene you draw)refraction, dispersion, frost noise, caustics, iridescence, morph

Ship A with B as the automatic fallback for UI chrome. Use C for hero visuals and effect panels. Never ship B alone and call it liquid glass.

A1. Displacement map (framework-free TypeScript)

ts
export type Profile = "bevel" | "convex" | "rim" | "flat";

// Backdrop shift across the bezel, s = 0 at the outer edge .. 1 at its inner end, edge = 1. Paraxial refraction
// (IOR 1.5) through a rounded edge follows the surface slope; a finite slope at the silhouette spreads the bend over the
// whole bezel. bevel: edge 1 − (1 − s)³ → shift (1 − s)². convex: a dome over a circle (bezel = radius), the whole
// face magnifies. rim: the bevel in the outer 35 % only.
const BEND: Record<Profile, (s: number) => number> = {
  bevel: (s) => (1 - s) ** 2,
  convex: (s) => ((1 - s) * (2 - s)) / 2,
  rim: (s) => (s < 0.35 ? (1 - s / 0.35) ** 2 : 0),
  flat: () => 0,
};
// Fold limit: displacement × steepest slope must stay under the bezel (bevel 0.5 × bezel, convex 0.67, rim 0.17).
const SLOPE: Record<Profile, number> = { bevel: 2, convex: 1.5, rim: 2 / 0.35, flat: 0 };
export const maxDisplacement = (bezel: number, profile: Profile) => (SLOPE[profile] ? bezel / SLOPE[profile] : 0);

function sdRoundRect(x: number, y: number, w: number, h: number, r: number) {
  const qx = Math.abs(x - w / 2) - (w / 2 - r), qy = Math.abs(y - h / 2) - (h / 2 - r);
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}

const maps = new Map<string, string>();
export function displacementMap(w: number, h: number, radius: number, bezel: number, profile: Profile): string {
  const key = `${w}x${h}:${radius}:${bezel}:${profile}`;
  const hit = maps.get(key);
  if (hit) return hit;
  const canvas = document.createElement("canvas");
  canvas.width = w; canvas.height = h;
  const ctx = canvas.getContext("2d")!, img = ctx.createImageData(w, h), d = img.data;
  const bend = BEND[profile], r = Math.min(radius, w / 2, h / 2);
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
    const px = x + 0.5, py = y + 0.5, depth = -sdRoundRect(px, py, w, h, r);
    let dx = 0, dy = 0;
    if (depth > 0 && depth < bezel) {
      const nx = sdRoundRect(px + 0.5, py, w, h, r) - sdRoundRect(px - 0.5, py, w, h, r);
      const ny = sdRoundRect(px, py + 0.5, w, h, r) - sdRoundRect(px, py - 0.5, w, h, r);
      const len = Math.hypot(nx, ny) || 1, m = bend(depth / bezel);
      dx = (-nx / len) * m; dy = (-ny / len) * m; // inward: the rim shows backdrop from further inside
    }
    const i = (y * w + x) * 4;
    d[i] = 128 + dx * 127; d[i + 1] = 128 + dy * 127; d[i + 2] = 128; d[i + 3] = 255;
  }
  ctx.putImageData(img, 0, 0);
  const url = canvas.toDataURL();
  maps.set(key, url);
  return url;
}

// SVG filter markup for one element size. displacement = max shift in px at the outer edge.
// Mount it inside an <svg><defs> (defs.insertAdjacentHTML("beforeend", markup)) so it parses as SVG; see A4 rule 2b.
// The bezel is capped at the corner radius (deeper, the corner creases on its diagonal) and the displacement at the
// profile's fold limit.
export function glassFilterSvg(id: string, o: { w: number; h: number; radius: number; bezel: number;
  profile: Profile; displacement: number; blur: number; saturate?: number }): string {
  const bezel = Math.min(o.bezel, o.radius, o.w / 2, o.h / 2);
  const displacement = Math.min(o.displacement, maxDisplacement(bezel, o.profile));
  const map = displacementMap(o.w, o.h, o.radius, bezel, o.profile);
  return `<filter id="${id}" x="0" y="0" width="${o.w}" height="${o.h}" filterUnits="userSpaceOnUse"
      color-interpolation-filters="sRGB">
    <feImage href="${map}" x="0" y="0" width="${o.w}" height="${o.h}" preserveAspectRatio="none" result="map"/>
    <feDisplacementMap in="SourceGraphic" in2="map" scale="${displacement * 2}"
      xChannelSelector="R" yChannelSelector="G" result="bent"/>
    <feGaussianBlur in="bent" stdDeviation="${Math.max(o.blur, o.profile === "convex" ? 1 : 0.5)}" result="frost"/>
    <feColorMatrix in="frost" type="saturate" values="${o.saturate ?? 1.4}"/>
  </filter>`;
}

// Chromium is the only engine that renders SVG filters inside backdrop-filter. Keep the userAgentData test: headless
// Chrome reports "HeadlessChrome/…", which the regex alone misses, so screenshot QA would only see the fallback.
export const supportsSvgBackdrop = typeof navigator !== "undefined" &&
  ("userAgentData" in navigator || /\bChrome\/\d+/.test(navigator.userAgent)) &&
  !/Firefox|FxiOS|CriOS|EdgiOS/.test(navigator.userAgent);

A2. Markup and CSS

One glass body = a positioned element with four children: the optic layer (backdrop filter + fill), the glow, the rim, and the content. Keeping the optics on a child keeps content out of the filter.

html
<div class="glass" style="--radius:22px">
  <div class="glass__optic"></div>   <!-- backdrop-filter: url(#id) in Chromium, blur() elsewhere -->
  <div class="glass__glow"></div>
  <div class="glass__rim"></div>
  <div class="glass__content">Button</div>
</div>
css
:root {
  --glass-fill: rgba(255,255,255,.15);
  --glass-rim-key: rgba(255,255,255,.96);
  --glass-rim-side: rgba(255,255,255,.24);
  --glass-rim-counter: rgba(255,255,255,.56);
  --glass-edge: rgba(0,0,0,.12);
  --glass-ink: #1d1d1f;
}
@media (prefers-color-scheme: dark) {
  :root { --glass-fill: rgba(18,18,24,.30); --glass-rim-key: rgba(255,255,255,.68); --glass-rim-side: rgba(255,255,255,.14);
          --glass-rim-counter: rgba(255,255,255,.35); --glass-edge: rgba(0,0,0,.32); --glass-ink: #fff; }
}
/* The edge line is a 1 px ring outside the silhouette, drawn with the shadow so it follows the radius. */
.glass { position: relative; isolation: isolate; border-radius: var(--radius);
  box-shadow: 0 0 0 1px var(--glass-edge), 0 8px 28px rgba(0,0,0,.14), 0 1px 3px rgba(0,0,0,.08); color: var(--glass-ink);
  /* the host must paint nothing: a <button>/<input> host otherwise brings the UA face colour
     (grey rgb(107,107,107) under color-scheme: dark) and the optic refracts that instead of the page */
  background: transparent; border: 0; -webkit-appearance: none; appearance: none; font: inherit; }
.glass > * { position: absolute; inset: 0; border-radius: inherit; }
.glass__optic { background: var(--glass-fill);
  -webkit-backdrop-filter: blur(var(--blur-fallback, 12px)) saturate(1.5);
  backdrop-filter: blur(var(--blur-fallback, 12px)) saturate(1.5); }
.glass__glow { pointer-events: none;
  box-shadow: inset 2px 3px 8px -4px rgba(255,255,255,.8),
              inset -2px -3px 8px -4px rgba(255,255,255,.35); }
/* Offset inset highlights follow the silhouette: the lit band is widest where the edge faces the light,
   so a wide capsule is lit along its whole top edge (a 135° gradient border would light only its left end). */
.glass__rim { pointer-events: none;
  box-shadow: inset 1.5px 1.5px 0 -.5px var(--glass-rim-key),
              inset -1.5px -1.5px 0 -.5px var(--glass-rim-counter),
              inset 0 0 0 .5px var(--glass-rim-side); }
.glass__content { position: relative; inset: auto; }
@media (prefers-reduced-transparency: reduce) {
  .glass__optic { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(245,245,247,.92); }
}

In Chromium set backdropFilter = "url(#id)" on .glass__optic (the filter already contains blur and saturation). Do not also set -webkit-backdrop-filter inline: in Chromium it is an alias and the later value wins. --blur-fallback is the Tier B blur per material: lens 6 px, regular 14 px, frost 24 px, tint 14 px, rim 8 px, flat 0 (no backdrop filter at all).

A3. React component

tsx
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { glassFilterSvg, supportsSvgBackdrop, type Profile } from "./liquidGlass";

type GlassProps = { radius: number; bezel: number; displacement: number; blur: number; profile?: Profile;
  fill?: string; className?: string; style?: CSSProperties; children?: ReactNode };

export function Glass({ radius, bezel, displacement, blur, profile = "bevel", fill, className, style, children }: GlassProps) {
  const ref = useRef<HTMLDivElement>(null);
  const id = "lg" + useId().replace(/[^a-zA-Z0-9]/g, "");
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);
  useLayoutEffect(() => {
    const el = ref.current!;
    const ro = new ResizeObserver(() => {
      const w = Math.round(el.offsetWidth), h = Math.round(el.offsetHeight);   // layout size, unaffected by transforms
      setSize((s) => (s && s.w === w && s.h === h ? s : { w, h }));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const svg = supportsSvgBackdrop && size && size.w > 0 && size.h > 0
    ? glassFilterSvg(id, { w: size.w, h: size.h, radius, bezel: Math.min(bezel, size.w / 2, size.h / 2),
        profile, displacement, blur }) : null;
  return (
    <div ref={ref} className={"glass " + (className ?? "")} style={{ ...style, ["--radius" as string]: radius + "px" }}>
      {svg && <svg width="0" height="0" style={{ position: "absolute", width: 0, height: 0, pointerEvents: "none" }} aria-hidden dangerouslySetInnerHTML={{ __html: `<defs>${svg}</defs>` }} />}
      <div className="glass__optic" style={{ ...(svg ? { backdropFilter: `url(#${id})` } : {}), ...(fill ? { background: fill } : {}) }} />
      <div className="glass__glow" />
      <div className="glass__rim" />
      <div className="glass__content">{children}</div>
    </div>
  );
}

Usage (displacement = 0.4 × bezel for bevel bodies, 0.5 × radius for convex circles, design language §4): button <Glass radius={22} bezel={22} displacement={9} blur={0.8}>Button</Glass>; panel <Glass radius={28} bezel={28} displacement={11} blur={3}>…</Glass>; avatar <Glass radius={32} bezel={32} displacement={16} blur={0} profile="convex" />.

A4. Rules that make or break the web version

  1. color-interpolation-filters="sRGB" on the filter. Without it the neutral value 128 is converted to linear light and the whole backdrop slides sideways.
  2. filterUnits="userSpaceOnUse", x=0 y=0 width/height = the element's integer px size, the same size on feImage. Regenerate the map when the size changes (ResizeObserver) and cache maps by size. 2b. Mount the filter as SVG. Insert the markup into an <svg><defs> element (defs.insertAdjacentHTML, or the <svg>'s innerHTML). Parsed through an HTML <template>, a <div> or DOMParser with text/html, it becomes an HTML element named filter: Chrome then drops the whole backdrop-filter without an error — no blur, no bend, the glass turns fully clear. Check document.getElementById(id) instanceof SVGFilterElement.
  3. Order displacement → blur → saturate. feDisplacementMap samples nearest-neighbour; blurring after it removes the jaggies. Minimum blur 0.5 px, and 1 px on convex lenses: they magnify their whole face (up to 4× at the rim), and below 1 px the magnified backdrop shows 2–3 px stair steps. Blurring before the bend does not remove them.
  4. scale on feDisplacementMap = 2 × displacement (it shifts by scale × (channel − 0.5)). 4b. Profile and share, not bigger numbers. Use the BEND curves above with displacement 0.4 × bezel (bevel) or 0.5 × radius (convex); glassFilterSvg holds it to the fold limit. An edge that stands vertical at the silhouette (Snell's law through a squircle or quarter-circle surface) packs nearly all of the bend into the outer 1–2 px, so small values show nothing and large ones flip into mirrored rings: the glass reads as blur-only either way. Above an 80 px bezel the 8-bit map quantises; keep those bodies at ≤ 0.4 × bezel or use tier C.
  5. Encode the inward direction: R = 128 + 127·dx, G = 128 + 127·dy, alpha 255.
  6. Map corner radius = CSS border-radius; backdrop-filter is clipped to the border radius. 6b. The glass host paints nothing. Whatever the host element (or any wrapper between it and the page) paints is what the optic refracts. <button>, <input>, <select> hosts get a UA background — a flat grey under color-scheme: dark — so the control turns into grey plastic. Reset background: transparent; border: 0; appearance: none on every glass host (the .glass rule above does it) and keep fills on .glass__optic only.
  7. Backdrop root: an ancestor with filter, opacity < 1, mask, clip-path, mix-blend-mode, backdrop-filter or will-change of those makes nested glass see only that ancestor's content, not the page. This is one more reason children of glass are flat. Animate opacity on the content layer, not on a glass ancestor.
  8. Budget: every visible glass body is one filter pass per frame. Keep ~10–15 bodies on screen at once; a long gallery page can hold more because off-screen bodies are not painted (drop the filter with an IntersectionObserver if a page has 30+). Animate with transform (not width/height) and quantise animated sizes if you must resize.
  9. Backdrop under every body: nothing opaque between the page backdrop and the glass (no dark demo tiles, no section fills), and no background-attachment: fixed / single fixed full-screen image on long pages — give each section its own backdrop that scrolls with it. Otherwise the filter bends a flat colour and the glass looks like grey plastic.
  10. Visual QA: headless Chrome renders these filters, so screenshot tests are valid. Screenshot every section over a grid and over the real backdrop; lines must bend inside each bezel and nowhere be mirrored.

Grid test backdrop (CSS, no asset needed):

css
.grid-backdrop {
  background:
    linear-gradient(rgba(255,255,255,.55) 1px, transparent 1px) 0 0 / 32px 32px,
    linear-gradient(90deg, rgba(255,255,255,.55) 1px, transparent 1px) 0 0 / 32px 32px,
    radial-gradient(60% 80% at 20% 20%, #e0435e, transparent 60%),
    radial-gradient(60% 80% at 80% 30%, #2b4de0, transparent 60%),
    radial-gradient(60% 80% at 60% 100%, #2fb57a, transparent 60%), #10131f;
}

B. CSS frost fallback

The same markup without the SVG: .glass__optic keeps backdrop-filter: blur(var(--blur-fallback)) saturate(1.5). Raise the fill a little (+4 %) because there is no bend to separate the glass from the backdrop.

C. WebGL fragment shader (known backdrop)

Draw the backdrop texture and the glass in one full-screen pass. Uniforms: uBackdrop texture, uRes canvas px, uRect glass rect (x, y, w, h in px, y down), uRadius, uBezel, uDisp (px), uFrost (0–1), uProfile (0 bevel, 1 convex, 2 rim), uTime. Panels and capsules: uBezel = corner radius, uDisp = 0.4 × uBezel. For a circle lens: uRadius = uBezel = min(w, h) / 2, uProfile = 1, uDisp = 0.5 × radius. Keep uDisp under the fold limit (0.5 × bezel bevel, 0.67 × radius convex, 0.17 × bezel rim); the same values hold over photos, text and grids. For several glass bodies loop over an array of rects (or draw one quad per body). This shader was compiled and checked in Chrome (ANGLE/SwiftShader) over a photo backdrop.

glsl
precision highp float;
uniform sampler2D uBackdrop; uniform vec2 uRes; uniform vec4 uRect;
uniform float uRadius, uBezel, uDisp, uFrost, uTime;
uniform int uProfile;                      // 0 bevel, 1 convex, 2 rim
varying vec2 vUv;                          // 0..1, y down (upload the texture without FLIP_Y)

float sdRoundBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
vec3 tex(vec2 px) { return texture2D(uBackdrop, clamp(px / uRes, 0.0, 1.0)).rgb; }

// Backdrop shift across the bezel; s = depth into the bezel (0 edge .. 1 inner end); edge = 1.
// Same curves as the TypeScript BEND table: bevel (1-s)^2, convex dome, rim = the bevel over the outer 35 %.
float bendAt(float s) {
  float a = 1.0 - s;
  if (uProfile == 1) return a * (1.0 + a) * 0.5;
  if (uProfile == 2) { float u = max(1.0 - s / 0.35, 0.0); return u * u; }
  return a * a;
}

void main() {
  vec2 px = vUv * uRes;
  vec2 c = uRect.xy + uRect.zw * 0.5, hb = uRect.zw * 0.5;
  float d = sdRoundBox(px - c, hb, uRadius);                    // < 0 inside
  vec3 back = tex(px), col = back;
  if (d < 1.0) {
    float depth = clamp(-d / uBezel, 0.0, 1.0);
    vec2 e = vec2(0.5, 0.0);
    vec2 n = normalize(vec2(sdRoundBox(px - c + e.xy, hb, uRadius) - sdRoundBox(px - c - e.xy, hb, uRadius),
                            sdRoundBox(px - c + e.yx, hb, uRadius) - sdRoundBox(px - c - e.yx, hb, uRadius)));
    float bend = depth < 1.0 ? bendAt(depth) : 0.0;
    vec2 off = -n * bend * uDisp;                                  // inward
    off += (vec2(hash(px), hash(px + 17.0)) - 0.5) * 1.5 * uFrost; // frost grain (uFrost 0 = clear lens, 1 = frost)
    float k = 0.02 * bend * bend;                                  // dispersion: the split grows as bend³, so it stays at the rim
    vec3 bent = vec3(tex(px + off * (1.0 - k)).r, tex(px + off).g, tex(px + off * (1.0 + k)).b);
    vec3 blur = (tex(px + off + vec2(2.0, 0.0)) + tex(px + off - vec2(2.0, 0.0)) +
                 tex(px + off + vec2(0.0, 2.0)) + tex(px + off - vec2(0.0, 2.0))) * 0.25;
    vec3 g = mix(bent, blur, 0.35 * uFrost);
    g = mix(g, vec3(dot(g, vec3(0.299, 0.587, 0.114))), -0.4);     // saturation x1.4
    g = mix(g, vec3(1.0), 0.10);                                   // fill (white 10 %)
    vec2 L = normalize(vec2(-1.0, -1.0));                          // key light top-left (y down)
    float facing = dot(n, L);
    float rim = 1.0 - smoothstep(0.0, 1.5, -d);                    // 1 px rim
    g += rim * mix(0.5, 0.9, max(facing, 0.0)) * (facing > 0.0 ? 1.0 : 0.55);
    g += (1.0 - smoothstep(0.0, 0.35, depth)) * max(facing, 0.0) * 0.18; // inner glow
    col = mix(back, g, clamp(0.5 - d, 0.0, 1.0));                  // anti-aliased silhouette
  }
  gl_FragColor = vec4(col, 1.0);
}

Effect building blocks for the same shader (add, don't replace, the glass above):

glsl
// Caustics: animated Worley cell borders, two layers multiplied.
vec2 hash2(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return fract(sin(p) * 43758.5453); }
float cells(vec2 uv, float t) {
  vec2 g = floor(uv), f = fract(uv); float d1 = 8.0, d2 = 8.0;
  for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) {
    vec2 o = vec2(float(x), float(y)), h = hash2(g + o);
    vec2 r = o + 0.5 + 0.45 * sin(t + 6.2831 * h) - f; float dd = dot(r, r);
    if (dd < d1) { d2 = d1; d1 = dd; } else if (dd < d2) { d2 = dd; }
  }
  return pow(1.0 - smoothstep(0.0, 0.3, sqrt(d2) - sqrt(d1)), 3.0);
}
float caustics(vec2 uv, float t) { return cells(uv * 4.0, t) * cells(uv * 6.5 + 3.1, t * 1.3) * 2.5; }

// Iridescence (thin film): hue shifts with viewing angle / surface normal.
vec3 thinFilm(float cosTheta, float thickness) {
  return 0.5 + 0.5 * cos(6.2831 * (thickness * (1.0 - cosTheta) + vec3(0.0, 0.33, 0.67)));
}

// Morph: smooth union of two glass shapes (merge radius k in px).
float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }

Use smin(sdA, sdB, 24.0) as the glass distance to get liquid merging when two bodies approach; derive the normal from the merged distance exactly as above, but scale the offset by the raw gradient instead of the normalised normal (vec2 g = vec2(dx, dy); n = g / max(length(g), 1e-4); off = -g * bend * uDisp;). The gradient's length is 1 on a single shape and falls to 0 at the neck, where the two lobes' normals cancel; normalised, the offset flips there in one pixel and the backdrop tears into a vertical step.

D. Liquid metal accent (WebGL, not glass)

Opaque chrome for the one metal element in a view (liquid-metal effect spec). Nothing behind is sampled, so it is a separate pass: a transparent canvas laid over the element (pointer-events: none, sized in device px), blend gl.ONE, gl.ONE_MINUS_SRC_ALPHA, one draw per metal shape with the same full-screen quad and vUv as the glass shader; labels stay in the DOM above it. uRing 2–4 px (× devicePixelRatio) draws only the rim of a button or the ring of a selected tile; uRing = 0 fills an icon or logo mark. uBands 2–2.5 for rims, 1.2–1.6 for fills. Checked in headless Chrome on dark and light pages.

glsl
// Liquid metal accent: opaque chrome bands drawn over the page; nothing behind is sampled.
precision highp float;
uniform vec2 uRes; uniform vec4 uRect;     // canvas px; shape rect x, y, w, h in px (y down)
uniform float uRadius;                     // corner radius px (h / 2 for a capsule or circle)
uniform float uRing;                       // rim width px; 0 fills the whole shape (icon, logo mark)
uniform float uBands;                      // reflection bands across the shape: 2–2.5 rim, 1.2–1.6 fill
uniform float uShift;                      // red/blue channel shift: 0 none, 0.2–0.35 UI, up to 0.6 logo marks
uniform float uAngle;                      // band direction in radians (1.2 ≈ 70°)
uniform float uTime;                       // seconds; keep it fixed under reduced motion
varying vec2 vUv;                          // 0..1, y down

float sdRoundBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
// One reflection band: thin white strip, thin dark strip, white again, then a long soft ramp from dark to light.
float band(float x) {
  const float s = 0.025;                   // band-edge softness (fraction of a band)
  float v = 0.98;
  v = mix(v, 0.07, smoothstep(0.10, 0.10 + s, x));
  v = mix(v, 0.98, smoothstep(0.16, 0.16 + s, x));
  return mix(v, mix(0.10, 0.85, smoothstep(0.22, 1.0, x)), smoothstep(0.22, 0.22 + s, x));
}

void main() {
  vec2 px = vUv * uRes;
  vec2 c = uRect.xy + uRect.zw * 0.5, hb = uRect.zw * 0.5, p = px - c;
  float d = sdRoundBox(p, hb, uRadius);                          // < 0 inside
  float a = clamp(0.5 - d, 0.0, 1.0);
  if (uRing > 0.0) a *= clamp(0.5 + d + uRing, 0.0, 1.0);        // keep only the rim band
  if (a <= 0.0) discard;
  float size = min(hb.x, hb.y);
  vec2 q = p / size;
  float edge = uRing > 0.0 ? 1.0 : 1.0 - smoothstep(0.0, 0.5 * size, -d); // 1 at the silhouette
  float s = dot(q, vec2(cos(uAngle), sin(uAngle))) * 0.5;       // bands run across the shape
  s += 0.35 * edge * edge;                                       // and bend where the surface turns away
  s += 0.08 * noise(q * 1.2 + uTime * 0.12);                     // slow liquid wobble
  s = s * uBands - uTime * 0.08;                                 // drift: about 12 s per band
  float k = uShift * 0.05 * (0.4 + edge);                        // fringes grow toward the silhouette
  vec3 v = vec3(band(fract(s + k)), band(fract(s)), band(fract(s - 1.3 * k)));
  vec3 col = mix(vec3(0.08, 0.085, 0.10), vec3(0.97, 0.975, 1.0), v);
  col *= 0.92 + 0.12 * clamp(-q.y, -1.0, 1.0);                   // a little brighter toward the top
  gl_FragColor = vec4(col * a, a);                               // premultiplied alpha
}

The Paper Shaders LiquidMetal React component (@paper-design/shaders-react) renders the same material from a built-in shape or a transparent logo image: repetition 2, softness 0.1, shiftRed / shiftBlue 0.3, distortion 0.07, contour 0.4; speed 0 plus a fixed frame for a still.

Static fallback without WebGL (no fringes):

css
.metal-rim { position: relative; border-radius: 999px; }
.metal-rim::before {
  content: ""; position: absolute; inset: 0; padding: 3px; border-radius: inherit; pointer-events: none;
  background: conic-gradient(from 200deg, #14151a 0 7%, #f7f8fc 10% 21%, #8a8d96 30%, #fff 43% 47%,
    #2a2b30 51% 59%, #d9dbe2 70%, #14151a 81%, #f7f8fc 92%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
}

E. Border beam (animated rim light)

One beamed element per view (border-beam effect spec). The beam is a sibling layer between the rim and the content of the glass body from A2: the optic never refracts it, and it is no ancestor of the optic, so it creates no backdrop root (A4 rule 7). Checked in headless Chrome on the A2 glass with the SVG refraction, over photos and the grid.

html
<div class="glass" style="--radius:30px">
  <div class="glass__optic"></div>
  <div class="glass__glow"></div>
  <div class="glass__rim"></div>
  <div class="glass__bloom" aria-hidden="true"><div class="glass__beam"><i></i></div></div>  <!-- optional glow -->
  <div class="glass__beam" aria-hidden="true"><i></i></div>  <!-- twin beams: add <i style="--beam-start:.5"></i> -->
  <div class="glass__content">Get started</div>
</div>
css
.glass { --beam-width: 1.5px; --beam-length: 84px; --beam-lap: 5s;
         --beam-head: #fff; --beam-mid: rgba(255,255,255,.5); }
/* A ring as wide as the beam: the layer minus its content box. */
.glass__beam { pointer-events: none; padding: var(--beam-width);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }
/* One streak rides the outline at constant speed. offset-rotate: auto (the default) turns it with the path, so the
   bright end of its gradient always leads. */
.glass__beam > i { position: absolute; left: 0; top: 0; width: var(--beam-length); aspect-ratio: 1;
  background: linear-gradient(to left, var(--beam-head), var(--beam-mid) 30%, transparent);
  offset-path: inset(0 round var(--radius));
  animation: glass-beam var(--beam-lap) linear infinite;
  animation-delay: calc(var(--beam-lap) * var(--beam-start, 0) * -1); }
@keyframes glass-beam { from { offset-distance: 0%; } to { offset-distance: 100%; } }
/* Bloom: the same beam 3 px wide, blurred by a wrapper. A filter on the masked layer itself runs before the mask
   and is cut into a hard grey band. */
.glass__bloom { pointer-events: none; filter: blur(4px); opacity: .8; }
.glass__bloom > .glass__beam { position: absolute; inset: 0; border-radius: inherit; --beam-width: 3px; }
@media (prefers-reduced-motion: reduce) { .glass__beam { display: none; } }
  • Light glass: --beam-head: #2b6bff; --beam-mid: rgba(43,107,255,.45); --beam-width: 2px. A white beam disappears into a light rim.
  • Sizes: buttons --beam-length ≈ 1.5 × the height and a 4–6 s lap; cards 120–200 px and 6–10 s; a working state 2–3 s. Keep --beam-length ≤ 1.5 × the shorter side: a longer square reaches across a thin capsule and lights the opposite edge as well.
  • Longer tails on thin shapes (a comet over 20–35 % of a capsule): replace the single streak with n = 10–16 lagging segments, <i style="--k:0"></i> … <i style="--k:15"></i> in <div class="glass__beam glass__beam--comet" style="--n:16; --beam-tail:.3; --beam-seg:24px">, where --beam-seg ≈ 2 × the tail in px / n:
css
.glass__beam--comet > i { width: var(--beam-seg); background: var(--beam-head);
  opacity: calc((1 - var(--k) / var(--n)) * (1 - var(--k) / var(--n)));
  animation-delay: calc(var(--beam-lap) * (var(--k) / var(--n) * var(--beam-tail) - 1 - var(--beam-start, 0))); }
  • Distance, not angle. A conic-gradient(from var(--angle)) spun through @property is the common shortcut, but it is driven by angle: on a capsule the beam crawls mid-edge and races, stretched, round the caps (reference border-beam-speed-by-distance-vs-angle). Use it only on shapes up to about 1.5 : 1.
  • inset(0 round R) must use the body's border-radius. For another outline (squircle, corner-shape), use offset-path: path("…") of that outline.
  • Browser support: offset-path basic shapes need Chrome 116, Safari 18, Firefox 122. Where it is missing the streak sits still in the top-left corner; hide .glass__beam behind @supports (offset-path: inset(0)) if that matters.

Shader version (Tier C): add to the glass shader of section C. rimPos turns a pixel into its position along the outline, so the beam keeps one speed and length on any rounded box. Compiled and checked in Chrome (ANGLE/SwiftShader) on a 800 × 120 capsule at seven lap phases.

glsl
// Border beam: arc-length position (0-1, clockwise from the top-left end of the top edge, y down) of the
// outline point nearest to p, for a rounded box centred at the origin with half size b and corner radius r.
float rimPos(vec2 p, vec2 b, float r) {
  vec2 s = max(b - r, 0.0), q = clamp(p, -s, s), v = p - q;      // v: from the straight skeleton out to p
  float a = atan(v.y, v.x), h = 1.5707963 * r, d;                // a: clockwise from +x (y down)
  // Pick the quarter by the signs of v, never by comparing a: on straight edges a is exactly +-pi/2 or pi.
  if (v.x >= 0.0 && v.y <= 0.0) d = q.x + s.x + r * (a + 1.5707963) + q.y + s.y;                   // top, top-right, right
  else if (v.x >= 0.0) d = 2.0 * (s.x + s.y) + h + r * a + s.x - q.x;                             // bottom-right, bottom
  else if (v.y >= 0.0) d = 4.0 * s.x + 2.0 * s.y + 2.0 * h + r * (a - 1.5707963) + s.y - q.y;    // bottom-left, left
  else d = 4.0 * (s.x + s.y) + 3.0 * h + r * (a + 3.1415927);                                     // top-left
  return d / (4.0 * (s.x + s.y) + 4.0 * h);
}
// 0..1 beam intensity: `width` px just inside the edge (sd < 0 inside), bright head at lap phase t, fading over
// `tail` (fraction of the perimeter) behind it. Constant speed along the outline, whatever the aspect ratio.
float borderBeam(vec2 p, vec2 b, float r, float sd, float t, float tail, float width) {
  float behind = fract(t - rimPos(p, b, r));
  float along = 1.0 - clamp(behind / tail, 0.0, 1.0);
  float across = smoothstep(-width - 0.75, -width + 0.75, sd) * (1.0 - smoothstep(-0.75, 0.75, sd));
  return along * along * across;
}

In main(), after the rim and glow lines and before the silhouette mix: g += borderBeam(px - c, hb, uRadius, d, fract(uTime / 5.0), 0.25, 1.5) * vec3(1.0); (the accent colour on light glass).

<!-- medium: swiftui -->

Questions

Can CSS alone make liquid glass?

Not the bend. backdrop-filter: blur() only frosts the backdrop. The bend needs an SVG filter with a displacement map, applied with backdrop-filter: url(#id), which only Chromium browsers render inside backdrop-filter.

Does it work in Safari and Firefox?

They show the frost fallback (blur, saturation, fill and rim) with no bend. For a bend in every browser, draw the glass in WebGL over a known backdrop, as in section C.

Why not feTurbulence noise?

Noise wobbles the whole surface like water. Glass is still in the middle and bends only within its bezel; the displacement map here is computed from the shape's edge, so straight lines curve once, at the rim.

How do I keep it fast?

Cache one map per size, round animated sizes to a few steps, and drop the filter from bodies that are off screen. Rules A4 cover the details.

The rules behind it

The code is half of it. Where glass belongs, which material each body gets, how it stays legible and what to reject are in the liquid glass guide and the 34 component specs. Other platforms: SwiftUI, Flutter, React Native, Figma.