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:
- The fill is barely there — 6% white on a dark page. Too opaque and it's just a gray card; too transparent and text becomes unreadable. On a light page this number changes enormously; more on that below.
backdrop-filterblurs whatever is behind the element — that's the entire trick. Note the property is on the glass element, not the background: it samples the composited pixels underneath and draws the blurred result as the element's own backdrop.- The border fakes the bright edge where light hits a pane's rim. A 1px semi-white border does more for the illusion than any shadow.
- The outer shadow separates the pane from the page. Keep it large, soft and low-opacity — ours is a 44px blur at 45% black, which reads as depth rather than as a drop shadow.
- The inset highlight is the part most hand-rolled glass skips.
inset 0 1px 0 rgba(255,255,255,0.10)paints a single bright pixel row along the inside top edge. Real glass catches more light on its top rim than on its sides, and a uniform border alone reads flat. It costs one shadow layer and it is the difference between "translucent div" and "pane".
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:
- Full-viewport blurs get the smallest radius that still reads. Farvist's panel blur is 16px, but the modal and command-palette
::backdropuseblur(4px)— a quarter of it — over a 60%-black scrim. At that size the scrim is doing the visual work and the blur is only a hint, which is the trade you want on the one surface that covers the whole viewport. - Never animate the blur radius. Every frame of
backdrop-filter: blur(0px → 16px)is a fresh full-area blur, and the compositor can't shortcut it the way it can a transform. Nothing in Farvist animates a filter:.hover-lifttransitionstransformandbox-shadow,.cardaddsborder-color, and.bg-driftand.bg-animatedmovebackground-position. If you want a glass surface to "appear", animate opacity and transform on the already-blurred element. - Don't stack blurs you can't see. A glass card inside a glass panel inside a glass shell gives you three composited layers to produce one visual result nobody can distinguish from two. Make the inner ones solid.
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
- Too much blur. 30–40px turns frost into fog. 12–20px keeps the backdrop legible as shapes, which is what sells the effect. If you can't tell there's anything behind the pane, you've built a gray card the slow way.
- No saturation boost. Plain
blur()desaturates; pair it withsaturate(130–150%). - Skipping the border. Without the catch-light edge the panel reads as a smudge, not a pane.
- Glass on glass on glass. One glass layer per region; make nested elements solid.
- Unreadable text. Low-opacity muted text on a low-opacity surface over a moving gradient is three variables of contrast stacked against you. Body copy on glass should be at full opacity in a near-neutral color.
- Blurring huge areas. Prefer cards, bars and menus — not entire viewports.
When not to use glass
An honest list of cases where we'd tell you to reach for an opaque surface instead:
- Dense, content-heavy panels. Long-form reading, data tables, spreadsheets, code. A backdrop shifting under body text is a small constant tax on comprehension, paid on every line. Farvist ships
.card-solidfor exactly this — it swaps the translucent fill for--fv-surface-solidand setsbackdrop-filter: none. (Its neighbour.glass-flatonly drops the blur and leaves the panel translucent, which is a different tool: cheaper compositing, not more readable text.) - Backdrops you don't control. User-uploaded hero images, avatar-derived colors, embedded third-party content. Contrast on glass is a function of what's behind it, and if that's arbitrary you cannot guarantee readability. Use an opaque scrim.
- Flat brand backgrounds. No gradient, no image, nothing to sample — no glass.
- Print, and forced-colors mode. Farvist ships neither a print stylesheet nor
forced-colorsoverrides for its glass surfaces. If your product needs either, you write them yourself — and both are cases where the honest answer is a solid background, because a blur has nothing to sample. - Low-end devices as your primary target. If your median user is on a budget Android, spend the compositing budget on something they'll notice more than a frosted card.
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.