← All posts
AccessibilityCSS

Glassmorphism, done accessibly

July 16, 2026 · 14 min read

Frosted glass is the most seductive UI trend in years — and the one most likely to quietly fail a real user. Translucent panels, blurred backdrops and soft gradients photograph beautifully in a portfolio and then, in production, put pale text on a background whose brightness nobody controls. The look isn't the problem. Shipping it without a few guardrails is.

Here are the four places glassmorphism breaks accessibility, and the concrete fix for each — the same decisions baked into Farvist so you get them for free. Along the way: the numbers we measured, the automated check we had to switch off, and the gaps we haven't closed.

1. Contrast on a moving target

The core tension of glass is that the surface is translucent: whatever is behind it bleeds through. That's the whole effect, and it is also why contrast stops being a property of the component. A contrast ratio is a function of exactly two colors, and on a glass panel the second color does not exist as a single value — it is whatever the backdrop happens to be at that point on screen, blurred and saturated by backdrop-filter, then composited underneath the panel's own semi-transparent fill. Scroll the page, move the panel, switch the mesh, and the number moves with it. There is no such thing as "the contrast ratio of this card" — there's a range.

Here is the surface, verbatim from scss/abstracts/_mixins.scss:

@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: $border-width solid var(--fv-glass-border);
  // Outer depth + a 1px inner top highlight that sells the "pane of glass" look.
  box-shadow:
    var(--fv-shadow-lg),
    inset 0 1px 0 rgba(255, 255, 255, 0.10);
}

On the dark theme those tokens resolve to rgba(255,255,255,0.06) for the fill, 0.10 for the elevated variant, 16px of blur and 140% saturation. Six percent white is not much of an anchor, and that's the trade: enough to lift text off the backdrop, not enough to kill the effect.

You can put a number on the range. This page's own <body> uses .bg-mesh-ocean — three radial gradients over --fv-body-bg (#060912), the brightest of them a cyan orb at 32% --fv-info. Alpha-composite the glass fill over each and you get two very different surfaces:

Now measure the same text token against both. --fv-muted (#95a3c4) reads 7.04:1 on the dark patch and 3.30:1 on the bright one. --fv-primary-text goes from 5.56:1 to 2.61:1. Identical markup, identical class, identical token — one comfortably passes AA, one fails it outright, and the only difference is where on the page the panel landed.

Be careful with those figures: they're computed by compositing the token values, at the exact center of the orb, ignoring the blur's local averaging and the inset highlight. Real layouts land between the two extremes. The point isn't the precise digits — it's that a spread that wide exists at all.

So the fix is two-part. First, the glass fill needs enough opacity that text sits on a reasonably stable base — the 6–10% white above is that floor, and dropping it further is where most hand-rolled glass goes wrong. Second, and more important, brand colors that look fine as a button fill are usually too dark to use as text. Farvist's electric-violet primary (#6d4af5) measures 3.74:1 as text on the dark surface — below the WCAG AA threshold of 4.5:1. So the framework exposes a separate, lighter token, --fv-primary-text, derived in scss/base/_root.scss as color.scale($primary, $lightness: 28%) and compiling to about #967df8, which measures 6.22:1. That one is used for links and text while the raw primary stays for fills. One color for painting, a readable sibling for reading.

/* fills use the brand color… */
.btn-primary { --fv-btn-bg: var(--fv-primary); }
/* …but text uses the readable sibling */
a { color: var(--fv-primary-text); }

On the light theme the same token is simply aliased back — --fv-primary-text: var(--fv-primary) — because the brand violet clears AA on the light theme's #eef1f8 surface at 4.70:1. Not by much, but the indirection costs one variable and buys a theme-aware answer.

Why axe can't check any of this

Automated checkers compute contrast from resolved styles. axe reads an element's color, walks up the tree for the nearest non-transparent background-color, and does the arithmetic. Two things about glass break that model, and neither is a bug in axe:

Both produce false positives in headless Chrome, and a check that cries wolf gets ignored. So we turned the rule off — and, because switching off an accessibility rule is exactly the kind of decision that rots into a lie, wrote the reasoning into the config next to the switch. From .pa11yci.json:

"comment_ignore": "color-contrast is excluded: axe cannot evaluate it
  through gradient text (.text-gradient uses transparent fill) or
  translucent glass over gradient backgrounds in headless, producing
  false positives. …",
"ignore": ["color-contrast"],

Everything else axe checks at WCAG2AA still runs in CI over every page on the site — home, docs, every blog post, every example template, the free blocks, the theme builder and the legal pages (it started as six pages; the list is in .pa11yci.json). Those catch the regressions that slip in between careful reviews — a missing label, a broken heading order, an unlabelled control.

The honest cost: contrast is now our problem, not the robot's. Any framework that claims to be "axe clean" including contrast while shipping translucent surfaces is either not running the rule or not really shipping glass. Read the config before you believe the badge — ours included.

What we gate instead

If the composited number can't be pinned down, gate the thing that can: the tokens. scripts/check-skins.mjs reads dist/farvist.css — the compiled stylesheet, not the Sass source — pulls out every [data-theme='…'] block, and enforces a 4.5:1 floor on four pairs per skin:

const checks = [
  ['primary-text on body-bg',     tokens['primary-text'], bg],
  ['muted on body-bg',            tokens['muted']      || DEFAULTS['muted'], bg],
  ['body-color on body-bg',       tokens['body-color'] || DEFAULTS['body-color'], bg],
  ['primary-contrast on primary', tokens['primary-contrast'], tokens['primary']],
];
for (const [label, fgc, bgc] of checks) {
  if (!fgc || !bgc) continue;
  const r = ratio(fgc, bgc);
  if (r < 4.5) failures.push(`${name}: ${label} = ${r.toFixed(2)}:1 …`);
}

Parsing the compiled CSS rather than the Sass map is deliberate. The source is a map of maps with inheritance and defaults; the compiled block is what a browser actually resolves. If a refactor stops emitting a token, a source-level check happily passes and the compiled check fails — which is the direction you want the failure to point. The script also errors out if it finds zero skin blocks, so a broken build can't quietly pass the gate by producing nothing to check.

Most skins clear the floor with room to spare: noir's readable-text token measures 15.98:1 on its own background, cyber's 13.68:1, forest's 12.38:1. The interesting one is dawn, the warm light skin. One of its readable-text tokens measured 4.40:1 and failed the build before release — the catch is written into the v1.4.0 changelog entry. The shipped --fv-primary-text, #c2410c on a #faf6f1 background, measures 4.81:1. That's still the tightest margin of any skin, and it's why nobody gets to nudge dawn's background a shade darker without re-running the check.

Three things this gate does not do, stated plainly:

2. When the blur isn't there

The property that makes glass, backdrop-filter, isn't universally supported, and some users disable it. When it fails, a translucent panel doesn't gracefully become opaque — it stays see-through, and now your text really is floating on raw background. That's the worst-case readability failure, and it happens silently.

The answer is progressive enhancement in the negative: assume the surface must be readable without blur, and swap in an opaque fill when the browser says it can't blur. The real rule, from scss/utilities/_effects.scss:

@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);
  }
}

--fv-surface-solid is the token that exists for exactly this: #121a2e on the dark theme, #ffffff on light, #fffaf5 on dawn. It's opaque, and every skin has to define it.

Now the correction, because the tidy version of this story isn't true. That block names six selectors. 22 rules in the compiled stylesheet apply a blur. Modals, dropdowns, toasts, accordions, diffs, the command palette and most of the AI-kit surfaces are not on the fallback list — on an engine without backdrop-filter they stay translucent. In practice the worst of those sit above a scrim (.modal::backdrop and .command::backdrop are rgba(6, 9, 18, 0.6)) or inside a card that has already gone opaque, so the outcome is usually fine. But "every glass surface in Farvist has a solid fallback" would be an overclaim. Six do; the rest inherit their luck.

One exclusion is on purpose, and the reasoning is in the source: alerts are left out because their translucent colored tint is legible without a blur, and forcing them all to --fv-surface-solid would flatten every variant to one surface and erase the semantic color. A danger alert that stops looking like a danger alert is a worse accessibility outcome than a slightly washed-out one.

What users actually get: Chromium, Safari and Firefox have all shipped backdrop-filter for years, so this branch fires for older engines, for some embedded webviews, and for users who have turned the feature off. They get a flat, opaque panel with the same border, shadow, radius and layout. The effect degrades; the content doesn't.

3. Losing the user's place

Soft, low-contrast surfaces make it easy for keyboard focus to disappear. If your focus ring is a subtle glow that blends into a blurred panel, keyboard and switch users can't tell where they are. Glass UI needs focus states that are more assertive than usual, not less. The base rule, from scss/base/_reset.scss:

:focus-visible {
  outline: 2px solid var(--fv-primary);
  outline-offset: 2px;
}

Two details in there are doing the work. It's an outline, not a box-shadow, so it doesn't have to compete for the one box-shadow property every glass surface is already spending on var(--fv-shadow-lg) plus its inset highlight; and the 2px offset pushes the ring clear of the glass edge, so it lands on the page background instead of fighting the fill. It's also --fv-primary, the saturated brand color — not the frosted border. That distinction matters: --fv-glass-border is rgba(255,255,255,0.14), roughly 1.52:1 against the panel it outlines. It is a decorative edge. It is not an indicator, and it must never be the only sign that something has focus.

The numbers on the ring itself: 2px of #6d4af5 measures 3.74:1 against the dark page background and 3.34:1 against a glass surface — both clear the 3:1 that WCAG 2.2 asks of a non-text indicator under SC 1.4.11.

Where it gets weaker, honestly. Components that want a ring following their own border radius opt out of the outline and swap in a box-shadow — form fields, nav links, tabs, chips, switches, segmented controls, list-group items, pagination, dropdown items, the range thumb, the accordion summary and most of the AI-kit surfaces all do outline: 0 followed by box-shadow: var(--fv-focus-ring). Buttons take the same shape through their own --fv-btn-focus-ring, which holds only the color half of the same 45% mix. The shared token:

--fv-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fv-primary) 45%, transparent);

That's a 3px ring at 45% alpha. Composited over the dark page background it lands around 1.6:1 — it reads as a glow, and it is measurably softer than the solid outline it replaces. It's visible, and in a quiet layout it does the job, but it does not clear the 3:1 bar the base rule clears, and on a bright patch of mesh it's the weakest item in this whole article. If you want it stronger today, --fv-focus-ring is a single custom property and you can redefine it to a solid color in one line — and that's the fix we'd reach for first.

And because the whole aesthetic leans on color, nothing meaningful should be communicated by color alone: a form error is a red border and a message and an aria-invalid flag (the companion farvist.js wires the last two from .is-invalid), a status dot is a color and a text label.

4. Motion that doesn't ask permission

Glass tends to travel with motion — drifting gradient backdrops, floating cards, pulsing glows. For someone with vestibular sensitivity, that ambient movement ranges from distracting to nauseating. The global rule is non-negotiable and lives in the reset:

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

The 0.01ms rather than none is the detail worth stealing. An animation removed outright never fires animationend, so any JavaScript waiting on that event to clean up or advance a state machine hangs forever. A 0.01ms animation is imperceptible and still fires. Same for transitionend.

The blanket rule is the floor, not the whole answer, because "stop everything" is wrong for some components. The per-component overrides in the compiled sheet split into two groups:

That's the heuristic: kill decoration, preserve status. Reduced motion means less movement, not less information.

What glass still can't guarantee

Three gaps we haven't closed, one you own, and one we don't intend to close.

Forced colors / Windows High Contrast Mode. There is no forced-colors block anywhere in the framework — not in the Sass, not in the compiled CSS. In forced-colors mode the browser substitutes the user's system palette and ignores backdrop-filter and box-shadow, which mostly lands somewhere reasonable (flat, high-contrast panels). But "mostly lands somewhere reasonable" is not the same as designed, tested and gated. If you ship into a context where HCM matters, test it yourself.

prefers-contrast. Not honored. A user who has asked their OS for more contrast gets the same 6% glass as everyone else.

prefers-reduced-transparency. Also not honored — which stings a little, because this is the media query that exists precisely for this aesthetic. Both of these are a few lines you can add today, and the tokens are already in place to make them trivial:

@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
  :root {
    --fv-glass-bg:        var(--fv-surface-solid);
    --fv-glass-bg-strong: var(--fv-surface-solid);
  }
}

Every glass panel in the framework paints itself from one of those two tokens — card, navbar, modal, dropdown, toast, list-group, diff, command palette, the AI-kit surfaces, the .glass utilities — so redefining them to the opaque token de-glasses all of them at once while keeping the layout, borders and spacing identical. (An opaque fill also makes backdrop-filter a no-op visually, so there's nothing else to unwind.) Alerts are the exception again: they blur over a color-mix tint of their own semantic color rather than --fv-glass-bg, so they stay translucent — deliberately, for the reason above. That none of this is shipped in the framework is a gap, not a design position.

Your backdrop is your problem. Every number in this article assumes the framework's own mesh backgrounds — bounded, token-derived, predictable. Put a photograph behind a glass panel and all of it is void: a photo has no token, no bounded luminance and no gate. The .glass-flat utility deserves a specific warning here — it sets backdrop-filter: none without adding any opacity, so it gives you a plain translucent panel and hands readability entirely to you. If what you wanted was "no glass", .card-solid is the utility that also swaps in the opaque fill.

And the one we don't plan to fix: there is no automated composited-contrast check, because we don't know how to write a good one. Sampling rendered pixels per viewport width, per scroll position, per skin, per theme is a different project with a large false-negative surface of its own. Manual review of real screens is the current answer, and we'd rather say that than ship a green check that means less than it looks like.

When not to reach for glass

The most useful accessibility decision is sometimes not to use the effect. Three cases where we'd tell you to skip it:

Small text takes the worst of the variance, too. Chips, badges and captions at 0.875rem and below don't get AA's large-text exemption, so they need the full 4.5:1 in the worst spot on the page, not the average one.

The checklist

If you're building frosted UI by hand, this is the whole list:

The point isn't that glassmorphism is dangerous. It's that "looks great in a screenshot" and "works for everyone" are two different bars, and the gap between them is a handful of decisions you can make once. Make them once — in tokens, in a mixin, in a CI script — and the beautiful version is also the accessible one. Make them never, and every new panel is a fresh coin flip.

Farvist bakes all of the above in — see the accessibility notes, or read how the theming architecture makes the readable-text token possible.


← All posts Read the docs →