Glassmorphism in CSS: build a frosted glass card

Frosted glass is four CSS lines on a card and something colourful behind it. The generator below writes the lines for you; the rest of the guide keeps the text readable.

Glassmorphism in CSS is a card with a see-through background and backdrop-filter: blur(), placed over something colourful. A light border and a soft shadow finish the look. Here is the whole recipe:

.glass {
  background: rgb(255 255 255 / 0.2);                  /* see-through tint */
  -webkit-backdrop-filter: blur(12px) saturate(160%);  /* older Safari */
  backdrop-filter: blur(12px) saturate(160%);
  border: 1px solid rgb(255 255 255 / 0.35);
  border-radius: 16px;
  box-shadow: 0 8px 32px rgb(0 0 0 / 0.18);
}

Try it as a generator. Each slider changes one value, and the CSS under the preview updates so you can copy it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glassmorphism CSS generator</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  /* the colourful layer behind the glass: a gradient plus blurred SVG blobs */
  .stage {
    position: relative; height: 220px; border-radius: 14px; overflow: hidden;
    display: grid; place-items: center;
    background: linear-gradient(135deg, #4f46e5, #0ea5e9 55%, #22c55e);
  }
  .stage svg { position: absolute; inset: 0; width: 100%; height: 100%; }
  .glass {
    position: relative; width: min(260px, 80%); padding: 20px 22px; color: #fff;
    /* these five lines are what the sliders change */
    background: rgb(255 255 255 / 0.2);
    -webkit-backdrop-filter: blur(12px) saturate(160%);  /* older Safari */
    backdrop-filter: blur(12px) saturate(160%);
    border: 1px solid rgb(255 255 255 / 0.35);
    border-radius: 16px;
    box-shadow: 0 8px 32px rgb(0 0 0 / 0.18);
  }
  .glass b { font-size: 18px; }
  .glass p { margin: 6px 0 0; font-size: 14px; }
  .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px 16px; margin: 14px 2px 10px; }
  label { font-size: 13px; display: grid; gap: 2px; }
  label span { color: #5b6270; }
  input[type=range] { width: 100%; }
  pre {
    margin: 0; padding: 12px 14px; border-radius: 10px; background: #111827; color: #d1fae5;
    font: 12.5px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap;
  }
</style>
</head>
<body>
<div class="stage">
  <svg viewBox="0 0 400 220" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
    <filter id="soft"><feGaussianBlur stdDeviation="18"/></filter>
    <g filter="url(#soft)">
      <circle cx="90" cy="60" r="60" fill="#f472b6"/>
      <circle cx="320" cy="170" r="70" fill="#facc15"/>
      <circle cx="250" cy="40" r="40" fill="#fb923c"/>
    </g>
  </svg>
  <div class="glass" id="glass"><b>Frosted glass</b><p>Move the sliders. The CSS below updates.</p></div>
</div>

<div class="controls">
  <label>Blur <span id="blurV"></span><input type="range" id="blur" min="0" max="30" value="12"></label>
  <label>Transparency (background alpha) <span id="alphaV"></span><input type="range" id="alpha" min="0" max="100" value="20"></label>
  <label>Saturation <span id="satV"></span><input type="range" id="sat" min="100" max="200" value="160"></label>
  <label>Border opacity <span id="bordV"></span><input type="range" id="bord" min="0" max="100" value="35"></label>
  <label>Corner radius <span id="radV"></span><input type="range" id="rad" min="0" max="40" value="16"></label>
</div>
<pre id="out"></pre>

<script>
  const $ = (id) => document.getElementById(id);
  const glass = $('glass');

  function update() {
    const blur = $('blur').value, alpha = $('alpha').value / 100, sat = $('sat').value;
    const bord = $('bord').value / 100, rad = $('rad').value;
    const filter = `blur(${blur}px) saturate(${sat}%)`;
    // apply to the card
    glass.style.background = `rgb(255 255 255 / ${alpha})`;
    glass.style.webkitBackdropFilter = filter;
    glass.style.backdropFilter = filter;
    glass.style.borderColor = `rgb(255 255 255 / ${bord})`;
    glass.style.borderRadius = rad + 'px';
    // show the values next to the sliders
    $('blurV').textContent = blur + 'px'; $('alphaV').textContent = alpha;
    $('satV').textContent = sat + '%'; $('bordV').textContent = bord; $('radV').textContent = rad + 'px';
    // the CSS to copy
    $('out').textContent =
`.glass {
  background: rgb(255 255 255 / ${alpha});
  -webkit-backdrop-filter: ${filter};
  backdrop-filter: ${filter};
  border: 1px solid rgb(255 255 255 / ${bord});
  border-radius: ${rad}px;
  box-shadow: 0 8px 32px rgb(0 0 0 / 0.18);
}`;
  }

  document.querySelectorAll('input[type=range]').forEach((r) => r.addEventListener('input', update));
  update();
</script>
</body>
</html>
A glassmorphism generator: blur, transparency, saturation, border opacity and radius over a gradient with blurred shapes.

The four layers of frosted glass

Each line in the recipe does one job. Taken away one at a time, the card stops looking like glass.

Tint, frost, edge and lift: the four CSS layers that make a card look like frosted glass.
Tint, frost, edge and lift: the four CSS layers that make a card look like frosted glass.
  1. Tint: a background colour with alpha below 1. rgba(255, 255, 255, .2), rgb(255 255 255 / .2) and hsl(0 0% 100% / .2) are the same colour. CSS background color covers the colour formats.
  2. Frost: backdrop-filter: blur() blurs whatever is behind the card. saturate() above 100% makes the blurred colour richer, which keeps glass from looking grey.
  3. Edge: a 1px border in white at partial opacity reads as the rim of a glass sheet. Round the corners with border-radius.
  4. Lift: a wide, soft box-shadow separates the card from the scene.
Property What it does Starting value
background Lets the backdrop show through rgb(255 255 255 / 0.2)
backdrop-filter Blurs and boosts what is behind blur(12px) saturate(160%)
border Draws the glass edge 1px solid rgb(255 255 255 / 0.35)
border-radius Rounds the sheet 16px
box-shadow Lifts the card 0 8px 32px rgb(0 0 0 / 0.18)

backdrop-filter accepts every function that filter does. CSS filter walks through each one and explains the difference between the two properties.

The glass needs something behind it

backdrop-filter only changes what is already behind the element. On a plain white page, the blurred result is the same white, and the card looks like a faint grey box.

The same CSS over a flat page and over colour. Only the second one looks like glass.
The same CSS over a flat page and over colour. Only the second one looks like glass.

You do not need a photo. A CSS gradient plus a few soft shapes is enough, and it stays inside the page:

<svg class="blobs" viewBox="0 0 400 220" aria-hidden="true">
  <filter id="soft"><feGaussianBlur stdDeviation="18"/></filter>
  <g filter="url(#soft)">
    <circle cx="90" cy="60" r="60" fill="#f472b6"/>
    <circle cx="320" cy="170" r="70" fill="#facc15"/>
  </g>
</svg>

Position the SVG absolutely behind the card, under a gradient on the container. The generator above uses exactly this.

Keep the text readable

Glass puts text over a background you do not fully control. Over calm colour, a light tint is enough. Over stripes, photos or strong dark shapes, the same tint leaves the text hard to read.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glass card readability check</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; margin-bottom: 12px; font-size: 14px; }
  .bar button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
  .bar button[aria-pressed=true] { background: #1d2330; color: #fff; border-color: #1d2330; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
  .scene { position: relative; height: 190px; border-radius: 12px; overflow: hidden; display: grid; place-items: center; }
  .scene h3 { position: absolute; left: 10px; top: 8px; margin: 0; font-size: 12px; padding: 3px 7px; border-radius: 6px; background: #fff; }
  /* busy: hard stripes, bright and dark blobs */
  .busy { background:
      radial-gradient(circle at 20% 70%, #1e1b4b 0 45px, transparent 46px),
      radial-gradient(circle at 80% 30%, #f43f5e 0 50px, transparent 51px),
      repeating-linear-gradient(45deg, #fde047 0 14px, #22d3ee 14px 28px); }
  /* calm: one soft gradient */
  .calm { background: linear-gradient(135deg, #c7d2fe, #fbcfe8); }

  .glass {
    width: 78%; padding: 14px 16px; border-radius: 14px; color: #111827; font-size: 14px; line-height: 1.4;
    background: rgb(255 255 255 / var(--a));     /* --a is the tint strength */
    -webkit-backdrop-filter: blur(6px) saturate(140%);
    backdrop-filter: blur(6px) saturate(140%);
    border: 1px solid rgb(255 255 255 / 0.5);
  }
  .glass small { display: block; margin-top: 6px; font-weight: 600; }

  /* browsers without backdrop-filter get a near-solid card instead */
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .glass { background: rgb(255 255 255 / 0.9); }
  }
  /* the same rule, switched on by the checkbox so you can preview it */
  .fallback .glass { -webkit-backdrop-filter: none; backdrop-filter: none; background: rgb(255 255 255 / 0.9); }
</style>
</head>
<body>
<div class="bar">
  <span>Tint:</span>
  <button id="low" aria-pressed="true">Low (0.15)</button>
  <button id="high" aria-pressed="false">High (0.6)</button>
  <label><input type="checkbox" id="fb"> Preview the @supports fallback</label>
</div>
<div class="grid" id="grid">
  <div class="scene busy"><h3>Busy background</h3>
    <div class="glass">Your balance is ready. Check the new statement.<small class="ratio"></small></div></div>
  <div class="scene calm"><h3>Calm background</h3>
    <div class="glass">Your balance is ready. Check the new statement.<small class="ratio"></small></div></div>
</div>

<script>
  // darkest colour in each background: the worst place for dark text
  const darkest = { busy: [30, 27, 75], calm: [199, 210, 254] };
  const text = [17, 24, 39];

  const lum = (c) => {
    const [r, g, b] = c.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };
  const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };

  function setTint(a) {
    document.body.style.setProperty('--a', a);
    document.getElementById('low').setAttribute('aria-pressed', a < 0.5);
    document.getElementById('high').setAttribute('aria-pressed', a >= 0.5);
    const fallback = document.getElementById('grid').classList.contains('fallback');
    const tint = fallback ? 0.9 : a;
    document.querySelectorAll('.scene').forEach((s) => {
      const bg = darkest[s.classList.contains('busy') ? 'busy' : 'calm'];
      // white tint laid over the darkest spot (ignores the blur, so it is the worst case)
      const mixed = bg.map((v) => v * (1 - tint) + 255 * tint);
      const r = contrast(mixed, text);
      s.querySelector('.ratio').textContent =
        `Worst-case contrast ${r.toFixed(1)}:1 ${r >= 4.5 ? '✓ readable' : '✗ below 4.5:1'}`;
    });
  }

  let tint = 0.15;
  document.getElementById('low').addEventListener('click', () => setTint(tint = 0.15));
  document.getElementById('high').addEventListener('click', () => setTint(tint = 0.6));
  document.getElementById('fb').addEventListener('change', (e) => {
    document.getElementById('grid').classList.toggle('fallback', e.target.checked);
    setTint(tint);
  });
  setTint(tint);
</script>
</body>
</html>
The same glass card at low and high tint, over a busy and a calm background. The fallback checkbox previews the @supports rule.

The contrast line in the example blends the tint over the darkest colour in each background and ignores the blur, so it shows the worst case. The WCAG guideline asks for at least 4.5:1 for normal text.

  • Raise the opacity. Moving the tint from 0.15 to 0.6 is the simplest fix.
  • Tint toward the text's opposite. Dark text wants a white tint, and light text wants a dark one.
  • Calm the backdrop. Blurred shapes are easier to read over than hard stripes or a busy photo.

A fallback for browsers without backdrop-filter

A browser that does not support backdrop-filter ignores the line. The card then keeps only its thin tint, which is often too see-through to read. Older Safari versions only understand the -webkit- form, so write both.

Use @supports to give those browsers a near-solid card instead:

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass { background: rgb(255 255 255 / 0.9); }
}

The checkbox in the example above applies the same background by hand, so you can see what those visitors get.

Glass inside glass

A glass menu inside a glass header often shows sharp stripes instead of blur.

backdrop-filter only sees content up to the nearest ancestor that has its own backdrop-filter, filter, opacity below 1, mask or clip-path. The Filter Effects spec calls that ancestor the backdrop root.

A glass menu nested in a glass bar cannot see the page. As a sibling, it blurs the page.
A glass menu nested in a glass bar cannot see the page. As a sibling, it blurs the page.

Move the menu out of the header so they are siblings, or put the header's blur on a ::before layer so the header element itself is not the backdrop root.

backdrop-filter also creates a stacking context, the same side effect CSS filter describes for filter.

Dark mode glass

In a dark theme, flip the tint: a dark, slightly transparent fill such as rgb(17 24 39 / 0.45), a faint white edge, and light text. Keep the colours in custom properties and switch them with a class or prefers-color-scheme.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glass login and dashboard cards</title>
<style>
  /* light theme values */
  body {
    --bg: linear-gradient(135deg, #a5b4fc, #f9a8d4 60%, #fde68a);
    --glass: rgb(255 255 255 / 0.35);
    --edge: rgb(255 255 255 / 0.6);
    --text: #111827; --muted: #374151;
    --field: rgb(255 255 255 / 0.55); --btn: #111827; --btn-text: #fff;
    --blob1: #8b5cf6; --blob2: #ec4899; --blob3: #f59e0b;
  }
  /* dark theme: darker tint over a darker scene, lighter text */
  body.dark {
    --bg: linear-gradient(135deg, #0f172a, #1e1b4b 60%, #312e81);
    --glass: rgb(17 24 39 / 0.45);
    --edge: rgb(255 255 255 / 0.12);
    --text: #f3f4f6; --muted: #cbd5e1;
    --field: rgb(255 255 255 / 0.08); --btn: #e0e7ff; --btn-text: #111827;
    --blob1: #7c3aed; --blob2: #db2777; --blob3: #0891b2;
  }
  body {
    margin: 0; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--text);
    background: var(--bg); position: relative; overflow-x: hidden;
  }
  .blobs { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; }
  .wrap { position: relative; z-index: 1; max-width: 640px; margin: 0 auto; padding: 14px; display: grid; gap: 12px; }

  .glass {
    background: var(--glass);
    -webkit-backdrop-filter: blur(16px) saturate(170%);
    backdrop-filter: blur(16px) saturate(170%);
    border: 1px solid var(--edge);
    border-radius: 16px;
    box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
  }
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .glass { background: rgb(255 255 255 / 0.9); }
    body.dark .glass { background: rgb(17 24 39 / 0.92); }
  }

  .top { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; }
  .top b { font-size: 16px; }
  button { font: inherit; cursor: pointer; }
  #theme { padding: 6px 12px; border-radius: 99px; border: 1px solid var(--edge); background: var(--field); color: var(--text); }

  .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
  form { padding: 18px; display: grid; gap: 10px; }
  form h2 { margin: 0; font-size: 18px; }
  form label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
  form input { font: inherit; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--edge); background: var(--field); color: var(--text); }
  form button { padding: 10px; border: 0; border-radius: 10px; background: var(--btn); color: var(--btn-text); font-weight: 600; }
  #result { margin: 0; font-size: 13px; color: var(--muted); min-height: 1.2em; }

  .stats { display: grid; gap: 12px; }
  .stat { padding: 14px 16px; }
  .stat span { font-size: 13px; color: var(--muted); }
  .stat strong { display: block; font-size: 24px; margin-top: 2px; }
</style>
</head>
<body>
<svg class="blobs" viewBox="0 0 600 600" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
  <filter id="soft"><feGaussianBlur stdDeviation="40"/></filter>
  <g filter="url(#soft)">
    <circle cx="120" cy="160" r="110" style="fill: var(--blob1)"/>
    <circle cx="480" cy="120" r="90" style="fill: var(--blob2)"/>
    <circle cx="380" cy="470" r="130" style="fill: var(--blob3)"/>
  </g>
</svg>

<div class="wrap">
  <div class="glass top"><b>Northwind</b><button id="theme" type="button">Dark mode</button></div>
  <div class="cols">
    <form class="glass" id="login">
      <h2>Sign in</h2>
      <label>Email <input name="email" type="email" required value="sam@example.com"></label>
      <label>Password <input name="password" type="password" required value="glass123"></label>
      <button type="submit">Sign in</button>
      <p id="result" aria-live="polite"></p>
    </form>
    <div class="stats">
      <div class="glass stat"><span>Visitors today</span><strong>1,284</strong></div>
      <div class="glass stat"><span>Sign-ups</span><strong>37</strong></div>
      <div class="glass stat"><span>Open tickets</span><strong>5</strong></div>
    </div>
  </div>
</div>

<script>
  const themeBtn = document.getElementById('theme');
  // start in the visitor's system theme, then let the button switch it
  if (matchMedia('(prefers-color-scheme: dark)').matches) document.body.classList.add('dark');
  const label = () => themeBtn.textContent = document.body.classList.contains('dark') ? 'Light mode' : 'Dark mode';
  label();
  themeBtn.addEventListener('click', () => { document.body.classList.toggle('dark'); label(); });

  // demo only: show what would be sent instead of sending it
  document.getElementById('login').addEventListener('submit', (e) => {
    e.preventDefault();
    const data = new FormData(e.target);
    document.getElementById('result').textContent =
      `Would send email=${data.get('email')}, password=${'•'.repeat(data.get('password').length)}`;
  });
</script>
</body>
</html>
A glass login card and dashboard cards. It starts in your system theme; the button switches light and dark.
body       { --glass: rgb(255 255 255 / 0.35); --edge: rgb(255 255 255 / 0.6); }
body.dark  { --glass: rgb(17 24 39 / 0.45);    --edge: rgb(255 255 255 / 0.12); }
.glass     { background: var(--glass); border: 1px solid var(--edge); }

Dark mode in CSS covers the theme switch itself. The sign-in form in the example stops at preventDefault() and shows what it would send.

Performance

The browser has to blur the area behind each glass element, and redo it when what is behind changes. A larger glass area means more work, so the cost grows with full-screen panels and scrolling or animated backgrounds.

  • Use glass for cards, bars and menus rather than whole pages.
  • Avoid animating the blur value on large elements.
  • A few glass cards on a page are a normal use.

When it does not work

What you see Cause Fix
Nothing blurs; a solid box The card's background is opaque Use an alpha colour below 1
A faint grey box, no glass Only a plain colour behind the card Put a gradient, image or shapes behind it
Glass in Chrome, clear box in older Safari Only the unprefixed property is set Add -webkit-backdrop-filter with the same value
Text is hard to read Tint too thin over a busy backdrop Raise the opacity or add a tint; check 4.5:1
Square blur shows past rounded corners The blur is on a child or pseudo-element of the rounded box Put border-radius on the blurred element, or overflow: hidden on the parent
A nested glass menu shows sharp content An ancestor has backdrop-filter, filter, opacity, mask or clip-path Make it a sibling, or move the ancestor's effect to ::before
Scrolling feels heavy Large blurred areas over moving content Shrink the glass area; do not animate the blur

Glass is hard to judge from a screenshot, because the effect depends on what moves behind it. Send the page itself instead of an image or an .html attachment.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can drag the sliders and switch the theme themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is glassmorphism?

It is a design style where panels look like frosted glass: you see blurred colour through them, with a light edge and a soft shadow. In CSS it is a semi-transparent background plus backdrop-filter: blur() on the panel, placed over a colourful background.

Why does my backdrop-filter do nothing?

Usually one of two things. The element's own background is fully opaque, so it hides whatever was blurred behind it, or there is only a plain colour behind the element, and a blurred plain colour looks the same as before. Use an alpha colour below 1 and put the card over a gradient, image or shapes.

Does glassmorphism work in Safari?

Yes, but older Safari versions only understand the prefixed -webkit-backdrop-filter. Write both lines with the same value. For browsers with no support at all, use @supports to give the card a more opaque background.

Is frosted glass bad for accessibility?

It can be, because the text sits over a changing background. The WCAG guideline asks for a contrast ratio of at least 4.5:1 for normal text. Raise the background opacity, add a tint, or keep busy details away from the text until it reaches that ratio.

Is there a glassmorphism generator?

The first live example on this page is one. Move the sliders for blur, transparency, saturation, border opacity and corner radius, and copy the CSS it prints below the preview.

Keep reading