TL;DR

  • Glass is four layers: tint, blur with saturation, hairline border plus inset highlight, and static grain.
  • Blur only reads as glass when there is contrast behind it. Flat backgrounds make flat panels, whatever the blur radius.
  • Judge text contrast against the brightest pixel behind the panel, not the average.

Frosted glass is the most reliably disappointing effect in front-end design. It gets added in one line, it looks acceptable in the mockup, and on the deployed page it reads as a grey rectangle with a glow. The fix is not a bigger blur. It is accepting that glass is a material, and materials have more than one property.

Why the usual recipe looks like plastic

The default tutorial version is essentially this:

.card {
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 16px;
}

Three things go wrong.

The tint is too weak to do anything. A 10% white fill over a mid-tone backdrop barely shifts the colour, so all the visual weight lands on the border.

The background has no structure. backdrop-filter samples what is behind the element. Over a flat #f5f5f5 section, there is nothing to blur, so the panel looks like a slightly lighter rectangle. This is the single biggest cause of “glass looks cheap” — the effect is not broken, the backdrop is empty.

The surface is perfectly smooth. Real glass has micro-texture and light scatter. A perfectly smooth gradient with a 1px border is what injection-moulded plastic looks like.

The four layers

Here is the version I ship, with each layer doing a job:

.glass {
  position: relative;
  /* 1. tint — carries legibility */
  background: rgba(255, 255, 255, 0.08);

  /* 2. blur + saturation — the frosted part */
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  backdrop-filter: blur(16px) saturate(140%);

  /* 3. hairline + inset highlight — the edge catches light */
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 20px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26);
}

/* 4. grain — the surface */
.glass::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0.12;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}

Saturation is doing more than it looks like. Blur averages colours toward grey; pushing saturation back up restores the colour that blur just stole. 130–150% is the useful range. Above about 180% the panel starts to look like a filter, not a surface.

Grain: the cheapest layer and the one everyone skips

The feTurbulence trick gives you a static, tileable noise texture with no image file. Three details matter:

Make it static. Animating noise by shifting background-position is tempting, costs real battery on mobile, and produces a shimmering panel that reads as a rendering bug.

Scale it with background-size. The filter’s baseFrequency sets the noise at the SVG’s own resolution; changing background-size changes how large the grain appears. 120–200px is the sane range. At 60px it looks like dithering, at 400px it looks like dirt.

Match the blend mode to the scene. overlay on dark glass adds grain without greying it. On light glass, multiply at low opacity (0.06–0.12) keeps the panel from turning milky.

Under CSS-only constraints this is my favourite four lines in the file. It is also the difference between a panel that looks rendered and a panel that looks printed.

The contrast trap

Text on glass fails accessibility audits for a boring reason: people measure contrast against the panel’s average colour, and users read it against the brightest thing behind it. A white logo scrolling under a panel at 4.5:1 average can drop to 2:1 for the half-second it passes behind the text.

Test it properly:

  • Put a pure white rectangle behind the panel’s text area and check the ratio again.
  • Aim for 4.5:1 on body copy and 3:1 on text above 24px (or 19px bold).
  • If the ratio fails, raise the tint alpha before raising the blur. Blur diffuses the bright area, it does not remove it.
  • Dark glass over photography usually needs a tint of 0.18–0.30, which is far above the tutorial’s 0.1.

The failure mode here is subtle: the panel looks fine in a screenshot and becomes unreadable during scroll, which is exactly the state no one tests.

Performance: an honest budget

backdrop-filter forces the compositor to re-sample everything behind the element, every frame it changes. Rules that keep it honest:

  • One or two glass surfaces per viewport. Eight cards in a scrolling feed is a repaint budget problem on mid-range Android, even though it looks fine on your laptop.
  • Never on a large sticky header above video. The blur has to re-run every frame of playback.
  • Avoid nesting. A glass panel inside a glass panel compounds the sampling cost and usually destroys the contrast.
  • Know that it creates a containing block. backdrop-filter on an element makes it the containing block for fixed-position descendants, which is a fun way to break a dropdown that worked yesterday.

And always ship the fallback, because a transparent panel with light text is unreadable, not degraded:

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass {
    background: rgba(24, 22, 20, 0.88);
  }
}

Light glass is a different recipe

The same values do not transfer between dark and light. On a dark scene, white tints and overlay grain work. On a light scene:

  • raise the white tint to 0.35–0.55 so the panel actually separates from the page;
  • switch text to near-black, and expect the border to disappear unless it is darker than the fill;
  • use multiply grain at half the opacity;
  • reduce saturation to around 110%, because a pale backdrop plus 140% saturation produces a colour cast that looks like a printing error.

I build these values by dragging sliders rather than guessing, which is why the glass and grain generator exists: it puts a real backdrop behind the panel, so you can see the contrast fail before the design review does.

A short checklist

Before shipping a glass surface:

  1. Is there something with structure behind it? If not, add contrast to the backdrop or drop the effect.
  2. Does the text hold 4.5:1 against the brightest thing that can pass behind it?
  3. Is there grain, and is it static?
  4. Is the border doing less work than the tint?
  5. Is there a @supports fallback?
  6. How many surfaces does one viewport render?

Six questions, thirty seconds. It is the difference between a panel that looks designed and a panel that looks generated.