← All posts
CSSTutorial

Glassmorphism in CSS: a practical guide

July 29, 2026 · 12 min read

Frosted glass is one of those effects that looks like magic and turns out to be four CSS properties in a trench coat. This is the complete recipe — the properties that matter, why each one is there, the fallback you must ship, the performance ceiling, and the mistakes that make glass look cheap. Every value below is one we ship in Farvist, so you can check our work.

The core recipe

A glass surface is a translucent fill, a backdrop blur, a light border, and a shadow. All four, or it doesn't read as glass:

.glass {
  background-color: rgba(255, 255, 255, 0.06);   /* 1 — translucent fill */
  backdrop-filter: blur(16px) saturate(140%);    /* 2 — the frost        */
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  border: 1px solid rgba(255, 255, 255, 0.14);   /* 3 — the catch-light  */
  border-radius: 0.875rem;
  box-shadow:
    0 18px 44px rgba(0, 0, 0, 0.45),             /* 4 — lift off the page */
    inset 0 1px 0 rgba(255, 255, 255, 0.10);     /* 5 — the top rim       */
}

That is not a simplified illustration. Every declaration except the radius is exactly what Farvist's .glass utility resolves to on the default dark theme, with the custom properties inlined — same blur, same fill, same shadow stack. .glass itself sets no border-radius, so it can be dropped onto a component that already has one.

What each part does:

This box is the recipe above, live — the page's gradient backdrop is blurring through it right now.

Why saturate() is not optional

A blur is an averaging filter. It replaces each pixel with a weighted mean of its neighbours, and averaging distinct hues pulls them toward each other — the result is lower chroma than what you started with. Blur a vivid violet-to-cyan gradient and you don't get a soft violet-to-cyan gradient, you get a soft gray-lavender one. That washed-out, frosted-plastic look people complain about isn't the blur radius being wrong; it's the missing saturation.

saturate(140%) in the same backdrop-filter chain buys the chroma back. Farvist ships 140% as the $glass-saturate token, and it is the single line most tutorials leave out. The filter functions apply in order, so blur() saturate() saturates the blurred result — which is what you want.

Glass needs something to blur

The number-one reason glass "doesn't work" in someone's project: the page behind it is a flat color. Blurring a solid gives you… the same solid. A blur of a constant function is that constant. You have built a translucent card with an expensive no-op filter on it.

Glass only reads over a rich backdrop — a gradient, an image, or color "orbs" painted behind the page. Here is the one Farvist paints on body, with the variables inlined:

body {
  background-color: #060912;
  background-image:
    radial-gradient(48rem 48rem at 82% -12%, rgba(109, 74, 245, 0.28), transparent 60%),
    radial-gradient(36rem 36rem at -8% 18%, rgba(34, 211, 238, 0.20), transparent 58%),
    radial-gradient(40rem 40rem at 50% 118%, rgba(232, 121, 249, 0.18), transparent 58%);
  background-attachment: fixed;   /* orbs stay in the viewport on long pages */
  background-repeat: no-repeat;
}

Three notes on that block. The orbs are positioned partly off-canvas (82% -12%, -8% 18%, 50% 118%) so you get the edge of a glow rather than a visible circle. The opacities are low — 18–28% — because the backdrop's job is to give the blur something to chew on, not to be the design. And background-attachment: fixed matters on long pages: without it the color lives only near the top and your glass turns gray as the user scrolls past the gradient's extent.

If your product's brand background is a flat corporate color and you can't change it, that is a real reason to skip glass rather than a problem to solve with a bigger blur radius.

Light glass is a different recipe

The most common porting mistake is taking a dark-theme glass surface and expecting the same numbers to work on a light page. They don't, and not by a little. Here are the two ends of Farvist's theme swap:

:root {                          /* dark */
  --fv-glass-bg:      rgba(255, 255, 255, 0.06);
  --fv-glass-border:  rgba(255, 255, 255, 0.14);
  --fv-surface-solid: #121a2e;
}
[data-theme="light"] {
  --fv-glass-bg:      rgba(255, 255, 255, 0.55);
  --fv-glass-border:  rgba(255, 255, 255, 0.80);
  --fv-surface-solid: #ffffff;
}

The fill goes from 6% to 55% — roughly nine times as much white — and the border from 14% to 80%. The reason is that on a dark page a white veil is instantly visible against near-black, so 6% is plenty of separation; on a light page white-on-near-white has almost no contrast to work with, so the fill has to carry the surface and the blur becomes decoration on top of it.

The practical consequence: light glass is closer to "slightly translucent white card" than to "window". If the see-through quality is load-bearing in your design, dark backgrounds are simply the easier medium. That is a design constraint, not a bug you can tune away.

Note also what does not change across the swap: the blur radius and the saturation stay at 16px/140% in both themes. Only the fill, the border and the opaque fallback color are theme-dependent.

Put the values in custom properties

Once you have more than one glass surface — a card, a navbar, a dropdown, a toast — hard-coded rgba values become the thing you can never change. Farvist's glass lives in a single Sass mixin that reads runtime variables:

@mixin glass($strong: false) {
  background-color: var(--fv-glass-bg);
  @if $strong { background-color: var(--fv-glass-bg-strong); }
  backdrop-filter: blur(var(--fv-glass-blur)) saturate(var(--fv-glass-saturate));
  -webkit-backdrop-filter: blur(var(--fv-glass-blur)) saturate(var(--fv-glass-saturate));
  border: 1px solid var(--fv-glass-border);
  box-shadow: var(--fv-shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.10);
}

Seventeen rules call that mixin — .card, .navbar, .modal, .dropdown-menu, .toast, .btn-glass, the AI-kit components, and the .glass / .glass-strong utilities themselves. Swapping four custom properties (--fv-glass-bg, -bg-strong, -border, --fv-surface-solid) re-skins all seventeen at runtime — that is the entire light theme, no rebuild. The $strong flag is the only variation: a slightly heavier fill (10% instead of 6%) for surfaces that float above content, like the navbar and modals.

Three glassy things don't go through it: alerts, form inputs and tooltips declare backdrop-filter themselves, reading the same --fv-glass-* variables. Alerts have to — their fill is a color-mix of the variant color, not the neutral one. The other two are duplication we haven't collapsed, which is the normal fate of a mixin that isn't quite general enough.

You do not need Sass for this. The same structure works as one plain CSS class plus a variable block.

The fallback you must ship

backdrop-filter is well-supported now — Chrome 76, Safari 9 behind -webkit-, Firefox 103 — but "well-supported" is not "universal", and some users turn effects off. When it's missing, a translucent panel doesn't gracefully become opaque. It stays see-through, and your text is now floating on the raw background. That failure is silent and it is the worst one on the list.

Two ways to write the guard. Progressive enhancement — opaque by default, frost only where it works — is the safest:

.glass {
  background: #121a2e;                     /* opaque — always readable */
}
@supports (backdrop-filter: blur(1px)) {
  .glass {
    background: rgba(255, 255, 255, 0.06); /* the real glass */
    backdrop-filter: blur(16px) saturate(140%);
  }
}

Farvist ships the inverted form, because the glass declaration lives in a mixin called from seventeen rules and duplicating it inside an @supports block would double all of them:

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass, .glass-strong, .navbar, .card, .form-control, .form-select {
    background-color: var(--fv-surface-solid);
  }
}

Test both the prefixed and unprefixed property in the query. A Safari version that supports only -webkit-backdrop-filter would fail a bare @supports (backdrop-filter: …) test and get needlessly downgraded to the solid surface — which is safe, but wasteful.

One honest note on that selector list: alerts are deliberately excluded. Their tint is a semantic color at 13% opacity with a colored border and a 3px left rail, and it stays legible without a blur. Forcing them all to --fv-surface-solid would flatten every variant to one gray panel and erase the meaning. The fallback rule is "keep it readable", not "make everything opaque".

About the prefix itself: you still need -webkit-backdrop-filter, and it has to sit next to the standard property in every rule that blurs. We do it twice over. The Sass source writes both lines by hand, but only in six places — the glass mixin, plus the five rules that blur without going through it (alerts, inputs, tooltips, and the two ::backdrop scrims) — and the shipped dist/*.css is then run through Autoprefixer against the browserslist in package.json, which emits the same prefix anyway. Belt and braces, because it's the kind of line that gets dropped in a refactor and nobody notices until someone opens the page on an older iPhone.

The performance budget

backdrop-filter is the most expensive property in this article by a wide margin. It forces the element onto its own composited layer, and the browser has to read back the pixels behind it, blur them, and composite the result — repeated whenever anything in that region changes. The cost scales with the painted area, not with the number of elements. Five small glass chips are cheap; one full-viewport glass overlay is not.

Three rules we actually follow:

Being honest about what we don't know: we don't publish a frame-time benchmark, and "how many glass surfaces is too many" genuinely depends on the backdrop's complexity, the device, and whether the region repaints on scroll. There is no number we could give you that would survive contact with your app. Profile it on the cheapest phone you support — that's the only measurement that means anything.

The shorthand that will bite you

This one cost us a real bug, and it's not something you'd guess. The glass recipe contains a border shorthand, and shorthands reset every longhand they cover. So does box-shadow — there is no "add a shadow layer" syntax, only "replace the whole stack".

Farvist's toasts carry a 3px colored left rail to signal their variant. For several versions, every toast rendered with a plain 1px border instead, because the rail was declared before the glass mixin:

.toast {
  border-left: 3px solid var(--fv-primary);  /* declared first… */
  @include glass($strong: true);             /* …and its `border:` shorthand wipes it */
}

The fix is ordering, and the source now carries a comment saying so — glass first, longhand overrides after:

.toast {
  @include glass($strong: true);
  // AFTER the glass include — its `border` shorthand would reset this rail.
  border-left: 3px solid var(--fv-primary);
  box-shadow: var(--fv-shadow-xl);
}

The same pattern shows up three more times. .tool-call carries the identical comment above its own left rail. .navbar zeroes border-top, border-right and border-left after its glass include to get a bottom-edge-only bar. .btn-glass sets box-shadow: none after its include — which also drops the inset top highlight, a deliberate call for a control that small.

The box-shadow half of this is worth admitting to. .modal, .command and .toast all replace the mixin's shadow with var(--fv-shadow-xl) for a deeper lift — and because box-shadow has no additive syntax, they silently lose the inset top rim the recipe leans on. It's the same trap, just quieter: nobody files a bug about a highlight that was never there.

If you package glass as a mixin, class or @apply-style helper, assume every consumer will eventually want to override one edge of it. Document the ordering, or emit longhands instead of shorthands.

Text on glass

Because the surface is translucent, the effective background of your text shifts with whatever scrolls underneath. Two rules keep it readable: keep body text near-white (or near-black on light glass) rather than a brand color, and check contrast against the composited surface, not the fill's rgba value. A contrast checker fed rgba(255,255,255,0.06) will happily tell you something passes when the real background under that pixel is a bright violet orb.

Brand colors that pass as button fills usually fail as text. Farvist's primary is #6d4af5, which carries white text at 5.3:1 as a button — and is far too dark to be text on a dark glass surface. So there's a separate --fv-primary-text token, the same hue scaled 28% toward white, and that is what links, .btn-link and the AI-kit accents actually use. If you're hand-rolling glass, budget for two versions of every brand color: one for fills, one for text. (The full accessibility treatment — focus visibility, motion, the fallback's a11y angle — has its own article on glassmorphism accessibility.)

The mistakes that make glass look cheap

When not to use glass

An honest list of cases where we'd tell you to reach for an opaque surface instead:

Or take the shortcut

Everything above — plus the contrast math, the reduced-motion handling, dark/light themes, five named skins and 42 components on the same token set — is what Farvist packages into one stylesheet. Its CI size gate measures the shipped build at 21.3 KB gzipped against a 22 KB budget. The recipe is four properties; everything after that is the polish. If you'd rather not maintain it yourself:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/farvist/dist/farvist.min.css">
<body class="bg-mesh-aurora">
  <div class="card"><div class="card-body">…</div></div>

Hand-rolled or not, the difference between cheap glass and good glass is the backdrop, the saturation, the edge highlight, the fallback, and restraint with the blur radius.


← All posts Explore the framework →