Skip to content
xscriptor-ai

xglassmorphism

Real glassmorphism for TypeScript + CSS web applications, with Tauri packaging where it applies. Covers the optics of frosted glass, backdrop-filter, layered surfaces, design tokens, typed components, accessibility, performance, browser support, fullstack architecture, and native window vibrancy (macOS, Windows, Linux fallback). Use when designing, building, reviewing, or debugging glass UI, frosted panels, translucent navigation, blur/backdrop effects, Liquid-Glass-style surfaces, or when packaging a web app with Tauri and deciding between CSS glass and native vibrancy.

projectv1.0.0web-fullstack/design/xglassmorphism
View source on GitHub

xglassmorphism

Glassmorphism built as a system, not as a one-liner. The naive recipe (background: rgba(255,255,255,.2) + backdrop-filter: blur()) produces the flat, gray, muddy panels that gave the trend a bad name. Real glass is optics: a stack of blur, tint, saturation, specular edge, noise, and shadow, arranged so that content on top stays legible on the worst possible backdrop.

This skill documents the technique end to end for TypeScript + CSS web applications (framework-agnostic: React 19, Vue 3.x, Svelte 5, vanilla, Web Components), including the fullstack surfaces around it (tokens pipeline, SSR, theme persistence, testing, deployment) and Tauri 2.x packaging where native window effects apply.

The reference implementation is the xglass system: a token contract, a small set of CSS primitives, typed wrappers per framework, and a platform matrix.

When to use this skill

  • Adding or restyling any glass surface: nav bars, sidebars, cards, sheets, modals, popovers, toasts, command palettes, toolbars, media overlays.
  • Reviewing an existing glass UI that looks muddy, gray, low-contrast, janky, or "Vista-like".
  • Designing a glass design system: tokens, levels, dark/light, motion.
  • Debugging backdrop-filter doing nothing, blurring the wrong content, clipping at radius, bleeding glow, or destroying scroll performance.
  • Packaging a TypeScript web app with Tauri and deciding between CSS glass and native window vibrancy (Acrylic, Mica, NSVisualEffectView, Liquid Glass).
  • Implementing accessibility for translucent UI: WCAG contrast, reduce transparency, increase contrast, forced colors, focus visibility.

Core doctrine

  1. Glass is layered optics, not transparency. Minimum viable glass has five layers: backdrop blur, tint, saturation/brightness lift, edge (specular rim + inner highlight), and shadow. Skipping tint or edge is why naive glass reads as gray plastic.
  2. Text never depends on the blur. Blur is decoration; the tint/scrim is what guarantees contrast. Evaluate text against the worst-case backdrop (lightest, darkest, busiest region, both themes), never the average.
  3. One backdrop pass per surface, few surfaces per view. The spec requires a separate rendering pass; nested backdrop filters degrade exponentially. Budget: at most 2-3 simultaneous blurred surfaces in a viewport.
  4. Animate opacity and transform, never backdrop-filter. Animating the blur chain runs on the main thread; fade/slide a pre-blurred layer instead.
  5. Progressive enhancement is mandatory. Ship a readable opaque base, add glass inside @supports, and honor prefers-reduced-transparency, prefers-contrast, and forced-colors.
  6. No glass on glass. A blurred layer over another blurred layer produces mud and double render passes. Use solid translucent fills for stacked surfaces.
  7. Tokens before components. Every blur radius, alpha, edge, and shadow is a token consumed by both CSS and TypeScript. No magic values in components.
  8. In Tauri, native vibrancy beats CSS for window-level glass (desktop, wallpaper) and CSS wins for in-page floating layers. Never blur over native vibrancy: it double-blurs and burns GPU for nothing.
  9. TypeScript is the contract. Tokens and component variants are typed and exported; CSS custom properties are the runtime channel between them.
  10. Every glass surface ships an audit line. Contrast worst case, cost class, fallback, and the platform it was verified on.

The canonical recipe

Minimum production glass (framework-agnostic CSS). This is the baseline the whole system scales from:

.xglass {
  --xglass-blur: 20px;
  --xglass-saturate: 180%;
  --xglass-tint: rgb(255 255 255 / 0.12);
  --xglass-edge: rgb(255 255 255 / 0.35);

  background: var(--xglass-tint);
  -webkit-backdrop-filter: blur(var(--xglass-blur)) saturate(var(--xglass-saturate));
  backdrop-filter: blur(var(--xglass-blur)) saturate(var(--xglass-saturate));
  border: 1px solid var(--xglass-edge);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.45),
    0 8px 32px rgb(0 0 0 / 0.18);
  border-radius: 16px;
}

What each layer does, and why removing it breaks the illusion, is documented in references/01-physics-of-glass.md. The browser mechanics (backdrop root, clipping, stacking, masks, fallbacks) live in references/02-css-implementation.md.

Glass levels

Three levels keep the system consistent. Names are normative; values are the default token set and may be retuned per product.

| Level | Blur | Tint alpha | Use for | Cost | |---------|------|------------|---------|------| | thin | 8-12px | 0.06-0.10 | full-width sticky bars, large panels, media overlays | low | | regular | 16-24px | 0.10-0.16 | cards, popovers, dropdowns, buttons | medium | | thick | 28-40px | 0.16-0.28 | modals, sheets, drawers, focused reading surfaces | high |

Rules: larger surfaces get thinner glass; smaller surfaces can afford thicker blur. A thin bar must still pass contrast because it spans the whole viewport. Details and the alpha ramp: references/03-design-tokens-and-theming.md.

Stack and scope

| Layer | Choices in scope | |-------|------------------| | Language | TypeScript 5.x (strict), CSS (custom properties, nesting, @layer) | | UI frameworks | React 19, Vue 3.x, Svelte 5, vanilla TS, Web Components | | Styling | plain CSS / CSS Modules / Tailwind CSS 4 / vanilla-extract | | Rendering | SPA, SSR (Next.js, Nuxt, SvelteKit), static export, islands | | Packaging | npm libraries, monorepo packages, Tauri 2.x desktop apps | | Native glass | window-vibrancy 0.8.x, Tauri windowEffects (macOS/Windows) |

Out of scope: React Native, native mobile UIKit/SwiftUI code, and WebGL lensing engines. The web technique is the source of truth; Tauri adapts it.

Tauri decision matrix (summary)

Full details, config shapes, and per-platform behavior: references/08-tauri-packaging.md.

| Case | Use | Fallback | |------|-----|----------| | Window depth over desktop/wallpaper (macOS, Windows) | Native: windowEffects / window-vibrancy (underWindowBackground, mica, acrylic, macOS 26 liquidGlassRegular) | Opaque themed window background | | Glass panels/cards over your own in-page content (all OS) | CSS backdrop-filter with @supports guard | Translucent solid color without blur | | Hybrid (most common) | Native vibrancy for the window chrome + CSS glass only for floating layers over in-page content | Drop CSS blur where native vibrancy is active | | Linux | CSS glass only; native vibrancy unsupported | Compositor blur (KWin/Hyprland) if user enabled it | | macOS 26+ Liquid Glass | Effect.liquidGlassRegular/liquidGlassClear + fallback material for macOS 15- | underWindowBackground | | App Store distribution | Public APIs only: windowEffects, no macOSPrivateApi | Opaque or CSS-only glass |

Anti-patterns (reject on sight)

  • rgba(...) + blur() with no tint, edge, or shadow (flat gray glass).
  • Text over glass with no scrim and no worst-case check.
  • Glass surfaces nested inside glass surfaces.
  • Animating backdrop-filter, filter, or background on scroll.
  • will-change: backdrop-filter or will-change: filter left on permanently (it creates a backdrop root and can silently disable the blur behind it).
  • overflow: hidden on a glass parent to trim an oversized backdrop layer (works in Firefox/Safari, fails in Chrome; use mask-image).
  • Full-page glass walls with dozens of blurred cards ("frosted everything").
  • Blur over native Tauri vibrancy (double blur, wasted GPU).
  • Glass only in dark mode with no light-mode token set.
  • Treating blur as a contrast device and skipping WCAG evaluation.

Companion references

Load only what the task needs; each reference is the detailed source of truth.

| Reference | Covers | Load when | |-----------|--------|-----------| | references/01-physics-of-glass.md | Optics of real glass: blur, tint, saturation, refraction, specularity, noise, depth; the five-layer model; Liquid Glass principles; intensity decision tables | Designing a glass look, tuning effect strength, deciding what "real" means | | references/02-css-implementation.md | backdrop-filter API and functions, backdrop root, clipping, stacking/containing block, mask tricks, SVG filters, fallback CSS, prefixing, @supports | Writing or debugging the actual CSS | | references/03-design-tokens-and-theming.md | Token model, alpha ramps, blur scale, light/dark, color-mix(), relative colors, oklch, light-dark(), Tailwind 4 @theme, token generation in TypeScript | Building the token layer or theming glass | | references/04-typescript-components.md | Typed primitives, polymorphic components, variants, React/Vue/Svelte/vanilla patterns, popover/dialog/sheet recipes, focus management, Tauri drag regions | Implementing components that use glass | | references/05-accessibility-and-legibility.md | WCAG 1.4.3/1.4.6/1.4.11/2.4.11, worst-case evaluation, scrim math, prefers-* and forced-colors, focus visibility, test checklist | Auditing or shipping accessible glass | | references/06-performance-and-browser-support.md | Rendering cost model, budgets, animation rules, engine bugs (Chrome/Firefox/Safari/WebKitGTK), support matrix 2026, SSR/static export notes, print | Fixing jank or deciding support policy | | references/07-fullstack-architecture.md | Fullstack integration: monorepo layout, tokens pipeline, SSR/streaming, theme API and persistence schema, admin preview, visual testing, CI, deploy | Wiring glass into a real app end to end | | references/08-tauri-packaging.md | Tauri 2.x config and APIs, window-vibrancy effects, macOS/Windows/Linux behavior, detection, drag regions, packaging, performance, App Store constraints | Packaging the app with Tauri or choosing native vs CSS glass |

Fast checklist before calling a glass surface done

  • [ ] All five layers present (blur, tint, saturation, edge, shadow).
  • [ ] Contrast verified at worst-case backdrop, both themes, 200% zoom.
  • [ ] @supports fallback is readable and shipped in the base CSS.
  • [ ] prefers-reduced-transparency, prefers-contrast, forced-colors handled.
  • [ ] No animating of the blur chain; only opacity/transform.
  • [ ] Blur radius and surface area within budget; no nested glass.
  • [ ] Tokens used; no hard-coded blur/alpha in components.
  • [ ] Keyboard focus visible and not obscured by sticky glass.
  • [ ] Verified in Chromium, Firefox, and WebKit (Safari or Tauri webview).
  • [ ] If Tauri: native effect or CSS chosen deliberately, not both stacked.

References (8)

01-physics-of-glass

01 - Physics of real glass

Glass looks like glass because light does several things at once when it passes through a physical pane. Web glass is an approximation, but knowing which physical effect each CSS layer stands in for is what separates a convincing surface from a gray rectangle.

This document defines the five-layer model, how each layer maps to CSS, how to tune intensities, and what is not reproducible on the web today.

1. What real glass does

| Physical effect | What happens | Web approximation | |-----------------|--------------|-------------------| | Scattering / diffusion | Transmitted light is spread by surface texture and internal structure | backdrop-filter: blur() | | Absorption / tinting | Material absorbs part of the spectrum, coloring transmitted light | translucent background-color / gradient tint | | Chroma shift | Thick glass deepens and shifts color; blur mutes chroma | saturate() and brightness() in the backdrop filter chain | | Refraction / lensing | Light bends at curved or beveled edges; content near edges warps | Not reproducible in CSS; approximated with SVG displacement (Chromium only) | | Specular reflection | Light sources reflect on the surface, following viewing geometry | inset highlights, gradient borders, box-shadow rims | | Internal reflection / caustics | Light bounces inside the pane, creating edge glow | second backdrop layer on the edge with different filters | | Surface grain | Micro-imperfections scatter highlights | fine noise overlay (feTurbulence or data-URI texture) | | Cast shadow / depth | The pane floats above the scene and darkens what is behind | box-shadow ambient + direct | | Thickness | Thicker glass distorts and darkens more | larger blur radius + higher tint alpha + stronger shadow |

Apple's Liquid Glass (announced 2025-06-09 at WWDC25, shipped with iOS 26 on 2025-09-15) formalizes this: it lenses and concentrates light, draws specular highlights that respond to device motion, and adapts its tint to the content behind it. It comes in Regular (adaptive) and Clear (always transparent, requires a dimming layer) variants. iOS 26.1 (2025-11-03) added a user-facing Clear/Tinted toggle after beta feedback about legibility. On the web you can reproduce the specular rim, the grain, the layered tint, and (in Chromium only) coarse edge distortion; you cannot reproduce real per-pixel lensing or the adaptive material system. See ./08-tauri-packaging.md for the native Tauri side and Section 7 below for the honest limits.

2. The five-layer model

Every glass surface in this system is composed of these layers. A surface missing any of them is incomplete; a surface adding more usually needs a design reason.

  1. Backdrop - the blur chain that processes what is behind the element.
  2. Tint - the translucent fill that sets the color and does the contrast work.
  3. Edge - the specular rim and inner highlight that suggest a bevel.
  4. Depth - the cast shadow that lifts the pane off the scene.
  5. Texture - optional fine grain that kills the "digital gradient" look.

Layer 1: backdrop

backdrop-filter: blur(20px) saturate(180%) brightness(1.05);
  • blur(len) applies a Gaussian blur whose length maps to the standard deviation (same as SVG feGaussianBlur stdDeviation). Do not confuse it with box-shadow blur radius, where the visible falloff is roughly twice the standard deviation. If a shadow and a blur look mismatched at equal numbers, this is why.
  • saturate(150-200%) compensates for the chroma muting that blur causes. Without it, colorful backdrops turn gray-beige behind the glass.
  • brightness() above 1 lifts dark backdrops (dark theme), below 1 calms bright ones (light theme over media). Keep it in the 0.9-1.15 band; larger values destroy contrast.
  • The blur considers only pixels directly behind the element. Content that is near but not behind contributes nothing, which is why a colorful blob sliding just under a sticky header does not tint it. The mask trick that fixes this is in ./02-css-implementation.md.

Layer 2: tint

background: rgb(255 255 255 / 0.12);          /* light glass on dark scene */
background: rgb(15 18 24 / 0.55);             /* dark glass on light scene */
background: color-mix(in oklab, var(--brand) 14%, transparent); /* branded */
  • The tint is the contrast guarantee. Blur is not a contrast device; text over blur alone fails WCAG on busy imagery.
  • Light glass uses white at low alpha and reads as "frosted". Dark glass uses the theme's surface color at higher alpha and reads as "smoked".
  • Tint alpha scales with the amount of text on the surface, not with taste. A nav bar with small links needs more tint than a decorative card.
  • For glass over photography, prefer a two-stop gradient tint (more opaque at the top and bottom edges, clearer in the middle) to preserve legibility where text sits while keeping the image visible at the center.

Layer 3: edge

border: 1px solid rgb(255 255 255 / 0.35);
box-shadow:
  inset 0 1px 0 rgb(255 255 255 / 0.45),   /* top inner highlight */
  inset 0 -1px 0 rgb(255 255 255 / 0.08);  /* bottom bounce */
  • Real glass catches light on the bevel. A 1px translucent border plus an inset top highlight creates the bevel with two paints.
  • The inner highlight is brighter on the side facing the assumed light source (top by convention). Inverting it makes the pane look pressed in.
  • Gradient borders (brighter at the top, fading at the bottom) are the next step up and can be done with a masked pseudo-element; see ./02-css-implementation.md.
  • Avoid rainbow or high-chroma borders: they read as "gamer UI", not glass. Keep the edge within the tint's hue family or pure white/black.

Layer 4: depth

box-shadow:
  0 1px 2px rgb(0 0 0 / 0.10),   /* direct, tight */
  0 8px 32px rgb(0 0 0 / 0.18);  /* ambient, soft */
  • Two shadows: a tight one for contact and a large soft one for lift. A single large shadow looks like a drop shadow sticker; a single tight one looks flat.
  • Glass depth is subtle. Opacities above ~0.25 on the ambient shadow make the pane look heavy and dated.
  • In dark themes, shadow still works but must be stronger to separate from dark scenes; consider a subtle top-edge highlight instead of pure shadow.

Layer 5: texture (optional)

.xglass::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0.035;
  background-image: url("data:image/svg+xml,..."); /* feTurbulence noise */
  mix-blend-mode: overlay;
}
  • Fine grain breaks the gradient banding that large blurs produce on 8-bit displays and makes glass feel physical.
  • Keep opacity in the 0.02-0.05 range. Above that it becomes a texture, not a material.
  • The noise layer must be pointer-events: none and inherit the radius, or it will intercept clicks and leak square corners over rounded glass.
  • Do not animate the noise; it costs a compositing layer for no visible gain.

3. Anatomy diagram (ASCII)

        specular rim (border 1px, white/35)
   +----------------------------------------------+
   |  inner top highlight (inset 0 1px, white/45) |
   |                                              |
   |      content: text/icons drawn ON TOP        |
   |      (never blurred, always scrim-backed)    |
   |                                              |
   |  tint: rgb(255 255 255 / 0.12)               |
   |  backdrop: blur(20px) saturate(180%)         |
   |  texture: noise 3%, overlay                  |
   +----------------------------------------------+
         ambient + direct shadow lifts the pane

4. Intensity decision table

Use these as starting points, then tune against real content.

| Context | Blur | Saturate | Tint (light/dark) | Edge | Noise | |---------|------|----------|-------------------|------|-------| | Sticky nav over hero imagery | 12-16px | 160-180% | 0.10 / 0.45 | white/30 | 0.03 | | Card on gradient background | 16-20px | 170-190% | 0.12 / 0.50 | white/35 | 0.03 | | Modal over busy app | 28-36px | 180-200% | 0.20 / 0.60 | white/40 | 0.04 | | Popover/menu over glass context | 16-20px | 170% | 0.16 / 0.55 | white/30 | 0 | | Input field on glass | 10-14px | 150% | 0.14 / 0.50 | white/25 | 0 | | Full-screen media overlay | 40-60px | 140-160% | 0.30 / 0.55 | none | 0.05 | | Decorative badge/chip | 8-10px | 160% | 0.10 / 0.45 | white/35 | 0 |

Rules of thumb:

  • Small text demands more tint, less blur.
  • Large surfaces (full-width bars) demand less blur for performance and more tint for legibility.
  • Bright scenes need darker or denser tint; dark scenes need a brightness lift and a visible edge to separate the pane from the background.

5. Glass and color: what blur does to hues

  • Gaussian blur averages neighbors, so high-frequency detail becomes mid-tone mush. A black-and-white checkerboard blurs to 50% gray; saturated red next to green blurs toward brown. This is why saturated backdrops look muddy behind glass without saturate().
  • White text on dark glass over a bright backdrop loses contrast locally. The fix is tint, not more blur.
  • Tinted glass shifts the perceived hue of everything behind it. Brand-tinted glass must be tested against brand-colored backgrounds, which is the worst case for separation.
  • Dark mode is not "invert the light tokens". Dark glass is a different material: denser tint, stronger edge highlight, weaker cast shadow. See ./03-design-tokens-and-theming.md.

6. Motion and specular response

Static glass already reads well. Dynamic response is the Liquid Glass layer that is partially reproducible:

  • Specular follow - move the inner highlight gradient origin with the pointer using @property-registered custom properties and a pointermove listener. Keep it subtle (a few degrees of gradient rotation) and disable it under prefers-reduced-motion.
  • Tint shift on scroll - interpolate tint alpha as content scrolls under a sticky bar. Animate a background layer's opacity, never the backdrop-filter value.
  • Edge glint - a narrow pseudo-element with background: linear-gradient and mix-blend-mode: screen that tracks the pointer along the rim.
  • Motion is a garnish. If the glass only works when it moves, it is not glass; it is a demo.

7. What is not reproducible on the web (2026)

| Effect | Status | |--------|--------| | True per-pixel refraction of arbitrary page content | Not possible in CSS. backdrop-filter: url(#svg) with feDisplacementMap approximates it in Chromium only; Safari and Firefox do not support SVG filters as backdrop filters reliably. | | Content-aware adaptive tint | No API; the backdrop pixels are not readable from JS. Approximate with scroll position + IntersectionObserver heuristics. | | Specular highlights reacting to device motion/light sensor | Device orientation is available but permission-gated and noisy; treat as optional garnish, not design requirement. | | Material morphing (gel-like transitions) | Approximate with two stacked layers cross-fading opacity + scale; the real lensing modulation is native-only. | | Cross-engine identical rendering | Impossible; plan the fallback hierarchy from the start. |

When a stakeholder asks for "exactly like iOS", the honest answer is: the specular rim, grain, tint, and fallback hierarchy yes; the lensing no. Deliver the first and set expectations about the second.

8. Anti-patterns

  • Gray plastic: blur with no tint/saturation. The backdrop's average color dominates and everything looks dirty.
  • Soap bubble: tint with no blur or edge. Reads as a translucent sticker.
  • Vista Aero: heavy specular gradients, visible bevels, strong shadows. Keep the rim at 1px and the highlight subtle.
  • Frosted everything: dozens of blurred cards. Muddy hierarchy and jank.
  • Invisible glass: glass whose background is so close to the page background that only the shadow shows. Glass needs contrast behind it to be perceived as glass.
  • Blur as a design smell: using blur to hide poor image quality or unreadable text. Fix the asset or the text.

9. Worked examples

Dark app shell with light accent

:root[data-theme="dark"] .xglass {
  --xglass-blur: 24px;
  --xglass-saturate: 180%;
  --xglass-tint: rgb(18 20 26 / 0.55);
  --xglass-edge: rgb(255 255 255 / 0.14);
}

Dark glass is denser and relies on the edge highlight for separation. Shadow values drop; the top rim does the lifting.

Light marketing page with colorful hero

:root[data-theme="light"] .xglass {
  --xglass-blur: 16px;
  --xglass-saturate: 190%;
  --xglass-tint: rgb(255 255 255 / 0.55);
  --xglass-edge: rgb(255 255 255 / 0.65);
}

Light glass over saturated imagery needs a high white alpha to keep dark text readable. The saturation boost keeps the hero colorful through the pane.

Compact toolbar (performance constrained)

.xglass-toolbar {
  --xglass-blur: 10px;
  --xglass-saturate: 150%;
  --xglass-tint: rgb(255 255 255 / 0.72);
  border: 1px solid rgb(255 255 255 / 0.4);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.12);
}

Small blur, high tint, no noise: the cheapest convincing glass. Toolbars that float for the lifetime of the app should stay in this band.

10. Related

  • ./02-css-implementation.md - how to express these layers in robust CSS.
  • ./03-design-tokens-and-theming.md - turning this document into tokens.
  • ./05-accessibility-and-legibility.md - the contrast math behind the tint.
  • ./08-tauri-packaging.md - when the glass is native instead of CSS.
02-css-implementation

02 - CSS implementation

The browser mechanics behind glass. This is the reference to open when the effect is not rendering, renders the wrong thing, or needs to be robust across engines.

1. backdrop-filter in one page

/* syntax */
backdrop-filter: none | <filter-value-list>;

/* one function */
backdrop-filter: blur(20px);

/* chained functions, applied left to right */
backdrop-filter: blur(20px) saturate(180%) brightness(1.05) contrast(1.02);

/* SVG filter reference */
backdrop-filter: url("filters.svg#glass");

Supported filter functions: blur(), brightness(), contrast(), drop-shadow(), grayscale(), hue-rotate(), invert(), opacity(), saturate(), sepia(), plus SVG filter references. The property applies the filter to the pixels painted behind the element, clipped to the element's border box (including border-radius), and only up to the nearest backdrop root.

Key facts:

  • The element must be transparent or partially transparent for the effect to be visible. An opaque background hides the blurred backdrop completely.
  • The element's own content is drawn after the filter and is never blurred. Text inside the glass stays crisp by design.
  • backdrop-filter is animatable as a filter function list, but you should not animate it (see ./06-performance-and-browser-support.md).
  • Computed value other than none creates a stacking context and a containing block for absolute/fixed descendants. Fixed-position children inside a glass element will position relative to the glass element, not the viewport.

2. Backdrop roots

An element becomes a backdrop root when it matches any of:

  • the root element (<html>);
  • filter other than none;
  • opacity less than 1;
  • mask, mask-image, mask-border, or clip-path other than none;
  • backdrop-filter other than none;
  • mix-blend-mode other than normal;
  • will-change naming any of the above properties.

Consequences:

  • A child's blur can only see content painted between the child and its nearest backdrop root, inclusive of the root's own background.
  • If an ancestor has opacity: 0.9, the child's backdrop-filter silently stops seeing the page background. This is the number one cause of "the blur did nothing".
  • Nested glass: the inner element's backdrop root is the outer glass element (because it has backdrop-filter), so the inner pane blurs only the outer pane's content, not the page. This is structural, not a bug.

Debugging rule: when blur looks wrong, walk up the DOM and list every ancestor with filter, opacity < 1, mask, clip-path, mix-blend-mode, or will-change. One of them is your backdrop root.

3. Clipping, radius, and paint order

Paint order for a filtered element, simplified:

  1. The element's backdrop is computed.
  2. The backdrop is clipped to the element's border box, including border-radius.
  3. The filter chain is applied to the clipped backdrop.
  4. The element's own background, border, and children are painted.

There is no way to make the backdrop blur spill outside the border box. For effects that must consider nearby content, the element itself is extended and then trimmed with a mask (Section 5).

4. Prefixes and support

| Engine | backdrop-filter | |--------|-------------------| | Chrome / Edge (Blink) | 76+ (2019-07-30), unprefixed | | Safari / WKWebView | -webkit- 9+; unprefixed 18+ (2024-09-16) | | Firefox | 103+ (2022-07-26), unprefixed | | WebKitGTK (Linux apps) | -webkit- in older 2.3x; unprefixed 2.46+ (approximate, verify on distro) | | Baseline status | Newly available since 2024-09-16; ~96% global usage (Aug 2026 StatCounter) |

Always ship both declarations, prefixed first:

.xglass {
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  backdrop-filter: blur(20px) saturate(180%);
}

CSS tooling (Lightning CSS, Autoprefixer with browserslist targets) adds the prefix automatically; do not rely on that silently, because Tauri and embedded webviews follow the OS engine, not your browserslist.

5. The mask tricks (nearby pixels, rounded corners, edges)

5.1 Consider nearby content

Because the blur only sees pixels directly behind the element, sticky bars do not pick up color from content that is about to pass under them. The fix is to extend a backdrop layer beyond the bar and trim it visually with a mask (masking runs after filtering in all engines):

.xglass-header {
  position: relative;
}

.xglass-header__backdrop {
  position: absolute;
  inset: 0;
  height: 200%;               /* extend downward to cover nearby content */
  pointer-events: none;       /* the extension must not block clicks */
  -webkit-backdrop-filter: blur(16px) saturate(180%);
  backdrop-filter: blur(16px) saturate(180%);
  -webkit-mask-image: linear-gradient(to bottom, black 0 50%, transparent 50% 100%);
  mask-image: linear-gradient(to bottom, black 0 50%, transparent 50% 100%);
}

Why a mask and not overflow: hidden:

  • Firefox and Safari trim before/after filters in a way that makes overflow: hidden work; Chrome applies overflow clipping before the filter, so the blur sees nothing extra. clip-path has the same problem.
  • mask-image is applied after filtering in every engine, so it is the portable way to trim.
  • The oversized layer must be pointer-events: none, or it will eat clicks and text selection on the content it overlaps.

5.2 Top flicker

When content scrolls out of the viewport at the top, its pixels stop being part of the backdrop and the blur visibly "goes goopy" at the edge. Cover it with a gradient that fades the top of the glass into the page background:

.xglass-header__backdrop {
  background: linear-gradient(
    to bottom,
    var(--surface-opaque) 0,
    transparent 50%
  );
}

Elements outside the viewport are never considered by backdrop-filter, in any engine. The gradient is the accepted mitigation.

5.3 Rounded corners with the extension trick

An extended layer normalized by height: 200% cannot be rounded with border-radius (the radius would apply to the hidden bottom edge). Use an SVG mask when the surface must be rounded and still see nearby pixels:

<svg class="xglass-mask" width="100%" height="100%" preserveAspectRatio="none">
  <mask id="xglass-rounded">
    <rect width="100%" height="100%" rx="16" ry="16" fill="white" />
  </mask>
</svg>
.xglass-header__backdrop {
  -webkit-mask-image: url(#xglass-rounded);
  mask-image: url(#xglass-rounded);
}

SVG <mask> references from HTML elements are not supported in all WebKit versions; test on your target. When SVG masks are not available, fall back to no extension (plain rounded glass with border-radius).

5.4 Glass edge (thickness)

A second, shorter layer under the bottom edge with a different filter creates the illusion of a thick pane:

.xglass-header__edge {
  --thickness: 6px;
  position: absolute;
  inset: 0;
  height: 100%;
  transform: translateY(100%);
  pointer-events: none;
  background: rgb(255 255 255 / 0.1);
  -webkit-backdrop-filter: blur(8px) brightness(1.2);
  backdrop-filter: blur(8px) brightness(1.2);
  -webkit-mask-image: linear-gradient(to bottom, black 0, black var(--thickness), transparent var(--thickness));
  mask-image: linear-gradient(to bottom, black 0, black var(--thickness), transparent var(--thickness));
}

Use a smaller blur and a brightness lift on the edge: the edge should catch light, not magnify the blur.

5.5 Gradient border rim

For a rim that is brighter on top and fades to the sides, use a masked pseudo-element instead of border:

.xglass {
  position: relative;
  border-radius: 16px;
}

.xglass::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    to bottom,
    rgb(255 255 255 / 0.6),
    rgb(255 255 255 / 0.08)
  );
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}

6. SVG filters

6.1 Historical blur fallback

Before Firefox 103, backdrop-filter was unavailable, and the workaround was to duplicate the background inside the element and blur it with an SVG feGaussianBlur. This is now historical: the opaque fallback in Section 7 is cheaper and safer. Keep this only if you must support very old Firefox.

6.2 Noise texture

<filter id="xglass-noise">
  <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" stitchTiles="stitch"/>
  <feColorMatrix type="saturate" values="0"/>
</filter>

Reference it as a background layer on a pseudo-element with low opacity. A feTurbulence data-URI avoids shipping an image and stays resolution independent.

6.3 Refraction / displacement

<filter id="xglass-refraction">
  <feImage href="data:image/png;base64,..." result="map"/>
  <feDisplacementMap in="SourceGraphic" in2="map" scale="12"
    xChannelSelector="R" yChannelSelector="G"/>
</filter>
.xglass-edge {
  backdrop-filter: url(#xglass-refraction);
}

Reality check (2026): SVG filters as backdrop-filter values work in Chromium; Safari and Firefox do not reliably support them (WebKit bugs 245510 and 297770 are open around url() filters). Treat refraction as a Chromium-only enhancement with a normal blur fallback. In Tauri, this means macOS/WKWebView and Linux/WebKitGTK do not get it.

7. Fallbacks and preference queries

Progressive enhancement contract:

  1. Base declaration: opaque-enough background that is readable with no blur support at all.
  2. Inside @supports, replace it with glass.
  3. Inside preference media queries, collapse back to a more opaque or solid version.
.xglass {
  /* 1. readable everywhere */
  background: color-mix(in oklab, var(--surface) 94%, var(--brand) 6%);
  border: 1px solid var(--edge);
  border-radius: 16px;
}

@supports ((backdrop-filter: blur(12px)) or (-webkit-backdrop-filter: blur(12px))) {
  .xglass {
    /* 2. glass */
    background: var(--xglass-tint);
    -webkit-backdrop-filter: blur(var(--xglass-blur)) saturate(180%);
    backdrop-filter: blur(var(--xglass-blur)) saturate(180%);
    box-shadow: var(--xglass-shadow);
  }
}

@media (prefers-reduced-transparency: reduce) {
  .xglass {
    background: var(--surface);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

@media (prefers-contrast: more) {
  .xglass {
    background: var(--surface);
    border-color: currentColor;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

@media (forced-colors: active) {
  .xglass {
    background: Canvas;
    color: CanvasText;
    border: 1px solid ButtonText;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    box-shadow: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .xglass,
  .xglass * {
    transition-duration: 0.01ms;
    animation-duration: 0.01ms;
  }
}

Support reality for prefers-reduced-transparency: Chrome/Edge 118+ (2023-10-10), Firefox 113 behind a flag (not default as of Sep 2026), Safari/WebKit no web query yet (WebKit bug 175497). Therefore the base declaration must always be readable; the query is an enhancement, not the safety net. Details in ./05-accessibility-and-legibility.md.

prefers-contrast values are no-preference | less | more | custom. Never use the bare @media (prefers-contrast) form for "more" behavior: it also matches less. Always qualify the value.

8. Traps

will-change

will-change: backdrop-filter (or filter, opacity, mask) makes the element a backdrop root, which can change what gets blurred, and holds GPU memory forever. Never pre-declare it on glass. If an animation needs it, add it transiently before the animation and remove it after.

Transform ancestors

A transformed ancestor does not create a backdrop root per spec, but engines have historically mishandled backdrop-filter inside 3D-transformed contexts (WebKit bug 252181, 201987). If glass inside a transformed container renders wrong, test moving the glass out of the transform.

isolation and stacking

isolation: isolate creates a stacking context but is not a spec backdrop root. Don't use it expecting to bound a blur; use an explicit filter or opacity ancestor only if you actually want a backdrop root, and document why.

Sticky + overflow + radius (Firefox)

Known bug: backdrop-filter stops rendering on position: sticky elements when an ancestor has both overflow and border-radius (Firefox bug 1803813). Workaround: apply the blur to an inner absolute layer instead of the sticky element itself.

Overscroll flicker

Community-reported flicker of blur while overscrolling; setting overscroll-behavior: contain on the scroll container has worked for several teams. Treat as empirical, test per engine.

Fixed backgrounds in Safari

background-attachment: fixed plus blur samples incorrectly in some Safari versions. Prefer position: fixed background layers to attachment: fixed.

Print

@media print {
  .xglass {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background: #fff;
    color: #000;
    box-shadow: none;
  }
}

Printers do not composite blur; force opaque.

9. Production primitive

The complete primitive used by this system. Copy into a design-system CSS layer, then theme via custom properties.

@layer xglass {
  .xglass {
    --xglass-blur: 20px;
    --xglass-saturate: 180%;
    --xglass-brightness: 1;
    --xglass-tint: rgb(255 255 255 / 0.12);
    --xglass-edge: rgb(255 255 255 / 0.35);
    --xglass-highlight: rgb(255 255 255 / 0.45);
    --xglass-radius: 16px;
    --xglass-shadow:
      0 1px 2px rgb(0 0 0 / 0.10),
      0 8px 32px rgb(0 0 0 / 0.18);

    position: relative;
    isolation: isolate;
    border-radius: var(--xglass-radius);
    border: 1px solid var(--xglass-edge);
    background: var(--xglass-tint);
    box-shadow: inset 0 1px 0 var(--xglass-highlight), var(--xglass-shadow);
  }

  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .xglass {
      -webkit-backdrop-filter:
        blur(var(--xglass-blur))
        saturate(var(--xglass-saturate))
        brightness(var(--xglass-brightness));
      backdrop-filter:
        blur(var(--xglass-blur))
        saturate(var(--xglass-saturate))
        brightness(var(--xglass-brightness));
    }
  }

  .xglass--thin   { --xglass-blur: 10px; --xglass-tint: rgb(255 255 255 / 0.08); }
  .xglass--thick  { --xglass-blur: 32px; --xglass-tint: rgb(255 255 255 / 0.20); }
}

Notes:

  • isolation: isolate keeps the element's children from blending with the backdrop unexpectedly; it does not affect the backdrop filter itself.
  • The base rule works without blur; the @supports block only adds the filter chain. This ordering is what makes the fallback automatic.
  • Components consume --xglass-* tokens; they never set blur() values directly.

10. Debugging checklist

  • Blur invisible?
    1. Is the element's background fully opaque?
    2. Is there a backdrop root between the element and the page background?
    3. Is the browser/webview actually supporting the property (check CSS.supports('backdrop-filter', 'blur(1px)'))?
    4. Is the element painted over the content you expect (z-index/stacking)?
  • Blur sees less than expected? Extend the layer and mask it (Section 5.1).
  • Blur extends beyond the radius? You added a mask or a pseudo-element with the wrong border-radius; verify border-radius: inherit and overflow on the right element.
  • Clicks blocked near the glass? The oversized backdrop layer is missing pointer-events: none.
  • Jank while scrolling? See ./06-performance-and-browser-support.md; reduce radius and area before anything else.
  • Works in Chrome, not Safari/Firefox? Check for url() filters, mask without -webkit-, or mask-composite syntax differences.

11. Related

  • ./01-physics-of-glass.md - why these layers exist.
  • ./03-design-tokens-and-theming.md - the token names used here.
  • ./06-performance-and-browser-support.md - costs and engine bug catalog.
  • ./08-tauri-packaging.md - webview differences that change these rules.
03-design-tokens-and-theming

03 - Design tokens and theming

Glass values are physics, not taste. They must be declared once, themed per mode, and shared between CSS and TypeScript. This document defines the token contract, the light/dark model, the generation pipeline, and the Tailwind 4 mapping.

1. Token architecture

Two tiers, strictly one-directional:

  1. Primitives - raw values: alpha steps, blur steps, radius steps. Not used by components directly.
  2. Semantic tokens - named by role: --xglass-tint, --xglass-edge, --xglass-blur. Components consume only these.
@layer tokens {
  :root {
    /* primitives */
    --alpha-04: 0.04;
    --alpha-06: 0.06;
    --alpha-08: 0.08;
    --alpha-10: 0.10;
    --alpha-12: 0.12;
    --alpha-16: 0.16;
    --alpha-20: 0.20;
    --alpha-28: 0.28;
    --alpha-45: 0.45;
    --alpha-55: 0.55;
    --blur-08: 8px;
    --blur-12: 12px;
    --blur-16: 16px;
    --blur-24: 24px;
    --blur-32: 32px;
    --blur-40: 40px;
    --radius-sm: 8px;
    --radius-md: 12px;
    --radius-lg: 16px;
    --radius-xl: 24px;
  }
}

Alpha primitives are shared with the rest of the design system; do not invent glass-only steps.

2. The glass token set

| Token | Meaning | Type | |-------|---------|------| | --xglass-blur | Backdrop Gaussian std deviation | length | | --xglass-saturate | Chroma compensation | percentage | | --xglass-brightness | Backdrop luminance lift/calm | number | | --xglass-tint | Translucent fill (contrast carrier) | color | | --xglass-edge | 1px border color | color | | --xglass-highlight | Inner top highlight | color | | --xglass-shadow | Cast shadow stack | shadow list | | --xglass-radius | Surface radius | length | | --xglass-noise-opacity | Grain strength, 0 disables | number | | --xglass-scrim | Overlay behind text when needed | color | | --xglass-text | Text color guaranteed on this surface | color | | --xglass-text-muted | Secondary text on this surface | color | | --xglass-transition | Hover/press transition | time + easing |

Naming is normative for this system. A component that needs a value not in this list either uses a level variant (--thin, --regular, --thick) or proposes a new semantic token, never a hard-coded blur(20px).

3. Levels

.xglass {
  --xglass-blur: var(--blur-24);
  --xglass-saturate: 180%;
  --xglass-brightness: 1;
  --xglass-tint: var(--glass-tint-regular);
  --xglass-edge: var(--glass-edge-regular);
  --xglass-highlight: var(--glass-highlight);
  --xglass-radius: var(--radius-lg);
  --xglass-shadow: var(--shadow-glass-regular);
  --xglass-noise-opacity: 0.03;
  --xglass-scrim: transparent;
}

.xglass--thin {
  --xglass-blur: var(--blur-12);
  --xglass-tint: var(--glass-tint-thin);
  --xglass-edge: var(--glass-edge-thin);
  --xglass-shadow: var(--shadow-glass-thin);
  --xglass-noise-opacity: 0.02;
}

.xglass--thick {
  --xglass-blur: var(--blur-32);
  --xglass-tint: var(--glass-tint-thick);
  --xglass-edge: var(--glass-edge-thick);
  --xglass-shadow: var(--shadow-glass-thick);
  --xglass-noise-opacity: 0.04;
}

Levels are semantic aliases, not new materials. A fourth level is a sign the design needs fewer surfaces, not more tokens.

4. Light and dark are different materials

Do not derive dark glass by inverting light tokens. Dark glass is denser, uses its edge for separation, and casts less shadow.

:root,
:root[data-theme="light"] {
  color-scheme: light;

  --surface: oklch(0.99 0.003 250);
  --glass-tint-thin: rgb(255 255 255 / 0.42);
  --glass-tint-regular: rgb(255 255 255 / 0.58);
  --glass-tint-thick: rgb(255 255 255 / 0.72);

  --glass-edge-thin: rgb(255 255 255 / 0.55);
  --glass-edge-regular: rgb(255 255 255 / 0.65);
  --glass-edge-thick: rgb(255 255 255 / 0.75);

  --glass-highlight: rgb(255 255 255 / 0.8);

  --shadow-glass-thin: 0 1px 2px rgb(15 18 24 / 0.08);
  --shadow-glass-regular:
    0 1px 2px rgb(15 18 24 / 0.08),
    0 8px 28px rgb(15 18 24 / 0.12);
  --shadow-glass-thick:
    0 2px 4px rgb(15 18 24 / 0.10),
    0 24px 64px rgb(15 18 24 / 0.22);

  --xglass-text: oklch(0.22 0.02 260);
  --xglass-text-muted: oklch(0.45 0.02 260);
  --xglass-scrim: rgb(255 255 255 / 0.35);
}

:root[data-theme="dark"] {
  color-scheme: dark;

  --surface: oklch(0.17 0.015 260);
  --glass-tint-thin: rgb(22 25 33 / 0.45);
  --glass-tint-regular: rgb(22 25 33 / 0.58);
  --glass-tint-thick: rgb(22 25 33 / 0.72);

  --glass-edge-thin: rgb(255 255 255 / 0.10);
  --glass-edge-regular: rgb(255 255 255 / 0.14);
  --glass-edge-thick: rgb(255 255 255 / 0.18);

  --glass-highlight: rgb(255 255 255 / 0.20);

  --shadow-glass-thin: 0 1px 2px rgb(0 0 0 / 0.25);
  --shadow-glass-regular:
    0 1px 2px rgb(0 0 0 / 0.30),
    0 8px 28px rgb(0 0 0 / 0.35);
  --shadow-glass-thick:
    0 2px 4px rgb(0 0 0 / 0.35),
    0 24px 64px rgb(0 0 0 / 0.50);

  --xglass-text: oklch(0.96 0.01 260);
  --xglass-text-muted: oklch(0.75 0.015 260);
  --xglass-scrim: rgb(10 12 16 / 0.35);
}

Observations:

  • Text tokens are per-material, because text contrast over glass depends on the tint, not only on the theme.
  • Dark glass barely uses cast shadow to separate; the highlight and edge do the work.
  • The scrim is never fully transparent; even 0.2-0.35 gives text a stable base on busy backdrops.

light-dark() shortens this when a token only flips value and no other logic changes:

:root {
  color-scheme: light dark;
  --xglass-tint: light-dark(rgb(255 255 255 / 0.58), rgb(22 25 33 / 0.58));
}

Support: light-dark() is Baseline since May 2024 (Chrome 123, Firefox 120, Safari 17.5). Safe for 2026 targets.

5. Deriving tints at runtime

When the brand color must tint the glass, derive instead of hard-coding:

:root {
  --brand: oklch(0.62 0.17 262);
}

.xglass--brand {
  --xglass-tint: color-mix(in oklab, var(--brand) 18%, transparent);
  --xglass-edge: color-mix(in oklab, var(--brand) 45%, white);
  --xglass-highlight: color-mix(in oklab, white 70%, var(--brand));
}

You can also shift an existing tint with relative color syntax:

.xglass--hover {
  --xglass-tint: rgb(from var(--xglass-tint) r g b / calc(alpha + 0.06));
}

Support: color-mix() Baseline May 2023; relative color syntax Baseline Jul 2024. Both are safe for 2026, but note that variadic color-mix() with more than two colors is newer (Safari 27, Firefox 150; Chrome pending as of Sep 2026) - stick to two-color mixes in shared CSS.

6. TypeScript token contract

The canonical tokens live in TypeScript so components, tests, and CSS share one source. The CSS custom properties remain the runtime channel; TypeScript is the build-time contract.

// src/design/tokens.ts
export const glassLevels = ["thin", "regular", "thick"] as const;
export type GlassLevel = (typeof glassLevels)[number];

export type GlassTokenName =
  | "blur"
  | "saturate"
  | "brightness"
  | "tint"
  | "edge"
  | "highlight"
  | "shadow"
  | "radius"
  | "noiseOpacity"
  | "scrim";

export type GlassTokenMap = Record<GlassTokenName, string>;

export const glassTokens: Record<GlassLevel, GlassTokenMap> = {
  thin: {
    blur: "var(--blur-12)",
    saturate: "160%",
    brightness: "1",
    tint: "var(--glass-tint-thin)",
    edge: "var(--glass-edge-thin)",
    highlight: "var(--glass-highlight)",
    shadow: "var(--shadow-glass-thin)",
    radius: "var(--radius-lg)",
    noiseOpacity: "0.02",
    scrim: "transparent",
  },
  regular: {
    blur: "var(--blur-24)",
    saturate: "180%",
    brightness: "1",
    tint: "var(--glass-tint-regular)",
    edge: "var(--glass-edge-regular)",
    highlight: "var(--glass-highlight)",
    shadow: "var(--shadow-glass-regular)",
    radius: "var(--radius-lg)",
    noiseOpacity: "0.03",
    scrim: "transparent",
  },
  thick: {
    blur: "var(--blur-32)",
    saturate: "190%",
    brightness: "1",
    tint: "var(--glass-tint-thick)",
    edge: "var(--glass-edge-thick)",
    highlight: "var(--glass-highlight)",
    shadow: "var(--shadow-glass-thick)",
    radius: "var(--radius-xl)",
    noiseOpacity: "0.04",
    scrim: "var(--xglass-scrim)",
  },
};

A small helper keeps inline styles type-safe when a component must set tokens dynamically (for example a scrim strength from data):

import type { CSSProperties } from "react";

export function glassStyleVars(
  level: GlassLevel,
  overrides: Partial<GlassTokenMap> = {},
): CSSProperties {
  const tokens = { ...glassTokens[level], ...overrides };
  const vars: Record<string, string> = {};
  for (const [name, value] of Object.entries(tokens)) {
    const kebab = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
    vars[`--xglass-${kebab}`] = value;
  }
  return vars as CSSProperties;
}

Prefer classes over inline vars. Use glassStyleVars only for values that genuinely change at runtime (scrim strength, per-data tint).

7. Generation pipeline

One build script emits all consumers from tokens.ts:

  1. styles/tokens.css - the :root/[data-theme] custom properties.
  2. styles/xglass.css - the primitive classes.
  3. Tailwind @theme block (or tokens.tailwind.css imported by it).
  4. tokens.json for docs, Figma sync, and tests.
  5. A type-checked manifest so component code cannot reference a token that the CSS does not define.

Sketch in plain Node (no dependency required):

// scripts/build-tokens.ts
import { writeFileSync } from "node:fs";
import { glassTokens } from "../src/design/tokens";

const lines: string[] = [":root {"];
for (const [level, tokens] of Object.entries(glassTokens)) {
  for (const [name, value] of Object.entries(tokens)) {
    const kebab = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
    lines.push(`  --xglass-${kebab}: ${value};`);
  }
  void level;
}
lines.push("}");
writeFileSync("src/styles/tokens.css", lines.join("\n"));

The real pipeline should include the literal light/dark token values (Section 4), not only the semantic aliases, and must be deterministic so CI diffs are meaningful.

8. Tailwind CSS 4 mapping

Tailwind 4 exposes theme values as CSS variables through @theme. Map glass tokens so utilities and custom classes stay in sync:

@import "tailwindcss";

@theme {
  --blur-glass-thin: 12px;
  --blur-glass-regular: 24px;
  --blur-glass-thick: 32px;

  --radius-glass: 16px;

  --color-glass-tint-thin: rgb(255 255 255 / 0.42);
  --color-glass-tint-regular: rgb(255 255 255 / 0.58);
  --color-glass-tint-thick: rgb(255 255 255 / 0.72);
}

/* utilities */
@utility xglass {
  border-radius: var(--radius-glass);
  border: 1px solid var(--glass-edge-regular);
  background: var(--glass-tint-regular);
}

@utility xglass-blur {
  -webkit-backdrop-filter: blur(var(--blur-glass-regular)) saturate(180%);
  backdrop-filter: blur(var(--blur-glass-regular)) saturate(180%);
}

Rules:

  • Do not use bare backdrop-blur-* utilities for glass surfaces; they omit saturation and the fallback contract.
  • Put the @supports guard and preference queries in the design-system layer, not in ad-hoc utility chains.
  • Arbitrary values (backdrop-blur-[37px]) are banned in component code; add a token instead.

9. Platform overrides

When the app runs inside Tauri with native vibrancy, glass tokens must not add a second blur (see ./08-tauri-packaging.md):

:root[data-runtime="tauri"][data-native-glass="active"] .xglass--window {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  background: transparent;
  border-color: rgb(255 255 255 / 0.08);
  box-shadow: none;
}

The runtime attribute is set once at boot from isTauri() plus platform detection; components stay unaware of the platform.

10. Anti-patterns

  • Hard-coded blur/alpha values in components.
  • Dark theme as inverted light tokens.
  • One tint for all levels ("thin" and "thick" with the same alpha).
  • Text color defined globally instead of per material.
  • Using rgba() with a brand color and assuming it is "on brand" without compositing checks.
  • Adding a token per component instead of per role.
  • Shipping tokens only in CSS while TypeScript duplicates strings.
  • Forgetting color-scheme on the root, which breaks native controls (scrollbars, inputs) inside glass surfaces.

11. Related

  • ./02-css-implementation.md - the primitive that consumes these tokens.
  • ./04-typescript-components.md - typed components built on this contract.
  • ./05-accessibility-and-legibility.md - contrast math for the tint values.
  • ./07-fullstack-architecture.md - where the generator runs in the pipeline.
04-typescript-components

04 - TypeScript components

How to expose glass as typed, composable components instead of scattering CSS classes through the app. Framework-agnostic core with concrete recipes for React 19, Vue 3, Svelte 5, and vanilla/Web Components.

1. Principles

  1. One primitive, many skins. A single GlassSurface owns the five layers; features compose it instead of re-declaring blur.
  2. Typed variants. Variants (thin | regular | thick, tone, as) are union types, not free-form strings.
  3. CSS custom properties cross the boundary. Components set --xglass-* vars; they never write backdrop-filter inline.
  4. Polymorphism without loss. as allows semantic elements (nav, aside, dialog, li) while keeping props typed.
  5. Accessibility is part of the API. Components that can host text require a contrast-level contract; components that animate respect prefers-reduced-motion.
  6. No component knows about Tauri. Platform adaptation is a data attribute on the root plus CSS (see ./03-design-tokens-and-theming.md, Section 9).

2. The core component

React 19

React 19 passes ref as a normal prop, so no forwardRef is needed.

import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";

export type GlassLevel = "thin" | "regular" | "thick";
export type GlassTone = "neutral" | "brand" | "danger";

type GlassSurfaceOwnProps<E extends ElementType> = {
  as?: E;
  level?: GlassLevel;
  tone?: GlassTone;
  scrim?: boolean;
  children?: ReactNode;
};

export type GlassSurfaceProps<E extends ElementType> =
  GlassSurfaceOwnProps<E> &
    Omit<ComponentPropsWithoutRef<E>, keyof GlassSurfaceOwnProps<E>>;

export function GlassSurface<E extends ElementType = "div">({
  as,
  level = "regular",
  tone = "neutral",
  scrim = false,
  className,
  children,
  ...rest
}: GlassSurfaceProps<E>) {
  const Component = (as ?? "div") as ElementType;
  const classes = [
    "xglass",
    `xglass--${level}`,
    tone !== "neutral" ? `xglass--${tone}` : "",
    scrim ? "xglass--scrim" : "",
    className ?? "",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <Component className={classes} {...rest}>
      {children}
    </Component>
  );
}

Usage:

<GlassSurface as="nav" level="thin" aria-label="Main">
  <NavItems />
</GlassSurface>

<GlassSurface as="section" level="thick" scrim>
  <h2>Settings</h2>
</GlassSurface>

Vue 3

<script setup lang="ts">
import { computed } from "vue";

const props = withDefaults(
  defineProps<{
    as?: string;
    level?: "thin" | "regular" | "thick";
    tone?: "neutral" | "brand" | "danger";
    scrim?: boolean;
  }>(),
  { as: "div", level: "regular", tone: "neutral", scrim: false },
);

const classes = computed(() => [
  "xglass",
  `xglass--${props.level}`,
  props.tone !== "neutral" ? `xglass--${props.tone}` : "",
  props.scrim ? "xglass--scrim" : "",
]);
</script>

<template>
  <component :is="as" :class="classes">
    <slot />
  </component>
</template>

Svelte 5

<script lang="ts">
  type GlassLevel = "thin" | "regular" | "thick";
  let {
    level = "regular",
    tone = "neutral",
    scrim = false,
    children,
  }: {
    level?: GlassLevel;
    tone?: "neutral" | "brand" | "danger";
    scrim?: boolean;
    children?: import("svelte").Snippet;
  } = $props();
</script>

<div class="xglass xglass--{level} {scrim ? 'xglass--scrim' : ''}">
  {@render children?.()}
</div>

Vanilla / Web Components

For embedded widgets or a framework-free design system, a custom element with adoptedStyleSheets keeps the contract:

export class XGlassElement extends HTMLElement {
  static observedAttributes = ["level", "tone"] as const;

  #root: ShadowRoot;

  constructor() {
    super();
    this.#root = this.attachShadow({ mode: "open" });
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(
      ":host { display: block; } :host([level='thin']) { --xglass-blur: var(--blur-12); }",
    );
    this.#root.adoptedStyleSheets = [sheet];
  }

  connectedCallback() {
    if (!this.#root.querySelector("slot")) {
      this.#root.append(document.createElement("slot"));
    }
    this.setAttribute("class", "xglass");
  }
}

customElements.define("x-glass", XGlassElement);

The host page still provides --xglass-* tokens; the element only maps level names to tokens.

3. Component recipes

Each recipe lists only what is specific to glass. Layout, data fetching, and business logic follow the app's normal conventions.

3.1 Sticky glass header

<GlassSurface as="header" level="thin" className="xglass-header">
  <Brand />
  <Nav />
</GlassSurface>

Component-specific CSS contract:

  • The blurred layer is a child (.xglass-header__backdrop) so the nearby-pixel extension and pointer-events rules from ./02-css-implementation.md apply.
  • scroll-padding-top: var(--header-height) is set on the scrolling container so anchor navigation is not hidden under the bar (WCAG 2.4.11, see ./05-accessibility-and-legibility.md).
  • The header reserves its exact block size (block-size or min-height) to avoid layout shift when content changes.

3.2 Card

<GlassSurface level="regular" className="xglass-card">
  <img src={cover} alt="" className="xglass-card__media" />
  <div className="xglass-card__body">
    <h3>{title}</h3>
    <p>{summary}</p>
  </div>
</GlassSurface>

Rules: media inside a card is not blurred by the card's own backdrop filter (children paint after the filter). If the image must appear behind frost, it has to be the page background, not a child.

3.3 Modal dialog

Use the native <dialog> element; it handles focus trapping, Escape, and the top layer. Style ::backdrop as the scrim rather than a separate overlay div.

dialog.xglass-dialog {
  border: none;
  padding: 0;
  background: transparent;   /* the GlassSurface child paints the glass */
}

dialog.xglass-dialog::backdrop {
  background: var(--xglass-scrim, rgb(10 12 16 / 0.45));
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
}
function SettingsDialog({ open }: { open: boolean }) {
  const ref = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);

  return (
    <dialog ref={ref} className="xglass-dialog" aria-labelledby="settings-title">
      <GlassSurface level="thick" scrim>
        <h2 id="settings-title">Settings</h2>
        {/* content */}
      </GlassSurface>
    </dialog>
  );
}

Notes:

  • ::backdrop sits in the top layer; it blurs the page behind without competing with the dialog's own glass (still two backdrop passes; acceptable because a modal is exclusive).
  • Do not put backdrop-filter on the <dialog> itself and on a child; choose one (prefer ::backdrop for the scrim and the child for the pane).
  • Keep a solid fallback: if ::backdrop blur is unsupported, the scrim color alone is already readable.

3.4 Popover and menus

Prefer the Popover API plus CSS anchor positioning when available; fall back to a JavaScript positioning library when the target browsers do not ship anchor positioning.

<>
  <button popoverTarget="user-menu" aria-haspopup="menu" aria-expanded={open}>
    Account
  </button>
  <GlassSurface as="div" level="regular" popover="auto" id="user-menu">
    <Menu />
  </GlassSurface>
</>

Support notes (2026): the Popover API is Baseline; CSS anchor positioning shipped in Chromium 125+ and Safari 26, and is still settling in Firefox - verify before dropping the JS fallback. A popover over glass must not itself be glass if it overlaps a glass parent; use an opaque translucent fill (no second blur).

3.5 Sheet / drawer

<div className="xglass-sheet" data-state={open ? "open" : "closed"}>
  <GlassSurface level="thick" scrim className="xglass-sheet__panel">
    <SheetContent />
  </GlassSurface>
</div>

Motion contract:

  • Animate only opacity and transform on the panel.
  • The scrim animates opacity.
  • Under prefers-reduced-motion: reduce, replace the slide with an instant state change (or a short fade).
  • Never animate backdrop-filter; the blur is static while the panel moves.

3.6 Glass button

<button className="xglass xglass--control xglass--thin">Save</button>

Glass buttons are small surfaces; keep tint high (0.12-0.2) and blur low (8-12px). States:

.xglass--control {
  transition:
    background-color var(--xglass-transition),
    transform var(--xglass-transition),
    box-shadow var(--xglass-transition);
}

.xglass--control:hover {
  background: color-mix(in oklab, var(--xglass-tint) 80%, white 20%);
}

.xglass--control:active {
  transform: translateY(1px) scale(0.99);
}

.xglass--control:disabled {
  opacity: 0.55;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  background: var(--surface);
}

Disabled glass disables the blur: it is a performance and honesty win (a disabled control should not advertise depth).

3.7 Input on glass

Inputs need opacity for text and caret contrast:

.xglass-input {
  background: color-mix(in oklab, var(--xglass-tint) 70%, var(--surface) 30%);
  border: 1px solid var(--xglass-edge);
  color: var(--xglass-text);
  caret-color: currentColor;
}

.xglass-input::placeholder {
  color: color-mix(in oklab, var(--xglass-text-muted) 70%, transparent);
}

Never make an input fully transparent over imagery: the caret and selection become invisible.

3.8 Toast and command palette

  • Toasts: thin glass, high tint (0.7+), positioned in a safe area, no backdrop-filter animation; enter/exit with transform + opacity.
  • Command palette: thick glass, scrim behind, native <dialog> for focus management, results list with a non-blurred inner container. This is the highest-value glass surface in an app and the one worth spending render budget on.

4. Focus and keyboard behavior

  • Focus visible: style :focus-visible with an outline in a color that survives the glass (use --xglass-text or a high-contrast ring token). A blurred backdrop does not blur the focused element's own outline, but a sticky glass bar can cover it (WCAG 2.4.11).
  • Focus not obscured: when a glass overlay opens, move focus into it; when it closes, restore focus to the trigger. Use the native <dialog>/popover machinery first.
  • Inert background: for custom overlays, set inert on the app root while the overlay is open instead of relying on a click-catching scrim.
  • Escape and scroll lock: overlays close on Escape; scroll lock must not shift layout (compensate scrollbar width) or the glass will jump.

5. Tauri-specific component behavior

When the app runs in Tauri (see ./08-tauri-packaging.md):

  • Add data-tauri-drag-region to the glass titlebar element itself, not to its children. Buttons and inputs inside must remain clickable, which the Tauri implementation guarantees by not inheriting the attribute.
  • On Windows, also declare the CSS app-region: drag rule for touch and pen input:
[data-tauri-drag-region] {
  app-region: drag;
  user-select: none;
}

[data-tauri-drag-region] button,
[data-tauri-drag-region] input,
[data-tauri-drag-region] a {
  app-region: no-drag;
}
  • On macOS, add -webkit-user-select: none and -webkit-touch-callout: none to chrome surfaces to avoid text selection and link callouts on what is supposed to feel like native chrome.
  • Double-click on the drag region should toggleMaximize(); Tauri handles this by default for data-tauri-drag-region.

6. Testing hooks

  • data-glass-level, data-glass-tone attributes on every surface: used by visual regression tests and by audit scripts that enumerate glass surfaces.
  • A render test per level asserting the primitive class combination (xglass xglass--regular) protects against accidental variant drift.
  • A Playwright visual test that screenshots each surface over a light, a dark, and a busy backdrop fixture (see ./07-fullstack-architecture.md).

7. Anti-patterns

  • A GlassCard, GlassModal, GlassNav each re-declaring blur values.
  • Inline style={{ backdropFilter: "blur(20px)" }} anywhere.
  • Wrapping every layout container in GlassSurface "for consistency".
  • Making the glass component own content padding, radius, and layout; those belong to the feature, the material only owns the surface.
  • Using <div> for interactive glass when <button>, <a>, or <dialog> fits.
  • Ignoring prefers-reduced-transparency in component CSS because "the theme handles it"; the theme sets tokens, the component must consume the fallback.

8. Related

  • ./02-css-implementation.md - the primitive these components render.
  • ./03-design-tokens-and-theming.md - token contract consumed here.
  • ./05-accessibility-and-legibility.md - focus and contrast rules.
  • ./08-tauri-packaging.md - runtime detection and native effects.
05-accessibility-and-legibility

05 - Accessibility and legibility

Translucent UI fails accessibility in predictable ways: contrast that varies with the scroll position, focus rings hidden by blurred overlays, and users who explicitly asked the OS for less transparency. This reference defines the contract, the math, and the pre-ship checklist.

1. WCAG criteria that apply

| Criterion | Level | Requirement | Glass implication | |-----------|-------|-------------|-------------------| | 1.4.3 Contrast (Minimum) | AA | 4.5:1 normal text, 3:1 large (>=24px, or >=18.66px bold) | Text over glass must meet the ratio against the composited (tinted + backdrop) color, not the tint alone | | 1.4.6 Contrast (Enhanced) | AAA | 7:1 normal, 4.5:1 large | Target for reading surfaces (modals, long-form panels) | | 1.4.11 Non-text Contrast | AA | 3:1 for UI component boundaries, states, meaningful graphics | Includes focus indicators and input borders; translucent borders often fail | | 2.4.11 Focus Not Obscured (Minimum) | AA (WCAG 2.2) | Focused component not entirely hidden by author content | Sticky glass headers/bars must not cover the focus ring; see failure F110 and technique C43 | | 2.4.12 Focus Not Obscured (Enhanced) | AAA | Focused component not partially obscured | Same surfaces, stricter | | 1.4.12 Text Spacing | AA | Content must not lose info when spacing is overridden | Glass fixed-height bars often clip when users inject spacing | | 2.5.8 Target Size (Minimum) | AA | 24x24 CSS px minimum target | Small glass chips/buttons are a common violation | | 2.3.3 Animation from Interactions | AAA | Motion triggered by interaction can be disabled | Pointer-following specular effects must respect reduced motion |

Ratios are exact thresholds: 4.499 fails. Background images are a documented failure mode (F83); gradients and photos require testing the least contrasting area, not the average.

2. Worst-case testing, not average

Blur averages pixels locally; it does not guarantee contrast. A bright blob behind one word survives the blur and breaks that word. The evaluation method:

  1. Inventory every text, icon, boundary, and focus indicator that can sit on a translucent layer.
  2. Define the worst-case backdrop set: lightest region, darkest region, busiest region, both themes, scroll extremes, 200% and 400% zoom.
  3. Composite the actual rendered color and measure; never eyeball.

CSS alpha compositing happens in gamma-encoded sRGB:

resultChannel = alpha * foreground + (1 - alpha) * background

Compute the ratio from that result using relative luminance:

L = 0.2126 R + 0.7152 G + 0.0722 B      (channels linearized)
ratio = (L1 + 0.05) / (L2 + 0.05)

TypeScript contrast utility

Ship this in the design system and unit-test the palette with it. It gives the team a single, objective definition of "readable over glass".

export type RGB = readonly [number, number, number];
export type RGBA = readonly [number, number, number, number];

function linearize(channel: number): number {
  const c = channel / 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

export function luminance([r, g, b]: RGB): number {
  return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}

export function over(fg: RGBA, bg: RGB): RGB {
  const a = fg[3];
  return [
    Math.round(a * fg[0] + (1 - a) * bg[0]),
    Math.round(a * fg[1] + (1 - a) * bg[1]),
    Math.round(a * fg[2] + (1 - a) * bg[2]),
  ] as const;
}

export function contrast(a: RGB, b: RGB): number {
  const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (l1 + 0.05) / (l2 + 0.05);
}

export function worstCaseContrast(
  text: RGB,
  tint: RGBA,
  backdrops: readonly RGB[],
): number {
  return Math.min(
    ...backdrops.map((backdrop) => contrast(text, over(tint, backdrop))),
  );
}

Test:

import { worstCaseContrast } from "./contrast";

const backdrops = [
  [255, 255, 255], // white
  [0, 0, 0],       // black
  [230, 60, 80],   // brand red
  [40, 80, 220],   // brand blue
  [120, 180, 90],  // photo green
] as const;

const bodyText = [22, 25, 33] as const;

test("light regular glass keeps AA over worst-case backdrops", () => {
  const tint = [255, 255, 255, 0.58] as const;
  expect(worstCaseContrast(bodyText, tint, backdrops)).toBeGreaterThanOrEqual(4.5);
});

If a surface cannot pass even with a scrim, the design changes: more tint, darker scrim, larger text, or the text leaves the glass.

3. Scrims: the math

A scrim is an additional translucent layer between the backdrop and the text. For pure white text over a black scrim on the worst-case white backdrop:

| Target ratio | Minimum black scrim alpha | |--------------|---------------------------| | 3:1 (large text, UI) | ~0.42 | | 4.5:1 (AA normal) | ~0.54 | | 7:1 (AAA normal) | ~0.65 |

These are the minimums for the absolute worst case (pure white backdrop). Real backdrops are usually darker; still design to the worst case or document the exception. For dark text over a white scrim on a black backdrop the mirrored values apply.

Practical rules:

  • Scrim under text, not over the whole glass; a full-surface scrim kills the glass effect. A text-band gradient scrim is the usual compromise.
  • A scrim can be cheaper than more blur: prefer raising tint alpha over raising the blur radius.
  • Recompute when the tint or backdrop changes; treat the numbers as token inputs, not one-time decisions.

4. Preference media queries

prefers-reduced-transparency

Values: no-preference | reduce.

Support (Sep 2026):

| Engine | Status | |--------|--------| | Chrome / Edge / Opera | 118+ (2023-10-10) | | Samsung Internet | 25+ (2024-04) | | Firefox | 113+, behind layout.css.prefers-reduced-transparency.enabled, not enabled by default | | Safari / WebKit | Not exposed as a web query yet (WebKit bug 175497 open) |

Because coverage is partial, the query is an enhancement, never the safety net. The base declaration must already be readable (see Section 5).

@media (prefers-reduced-transparency: reduce) {
  .xglass {
    background: var(--surface);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

prefers-contrast

Values: no-preference | less | more | custom. Baseline since May 2022 (Chrome 96, Firefox 101, Safari 14.1). Always qualify the value; a bare @media (prefers-contrast) also matches less.

@media (prefers-contrast: more) {
  .xglass {
    background: var(--surface);
    border-color: currentColor;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

@media (prefers-contrast: less) {
  .xglass {
    --xglass-edge: transparent;
  }
}

forced-colors

Baseline since Sep 2022. In forced colors, backgrounds and shadows are replaced, but backdrop-filter is not in the forced-property list, so behavior is engine-dependent - verify per target. Patch illegible spots only; do not redesign.

@media (forced-colors: active) {
  .xglass {
    background: Canvas;
    color: CanvasText;
    border: 1px solid ButtonText;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    box-shadow: none;
  }
}

prefers-reduced-motion

Baseline. Disable pointer-following specular effects, parallax glass, and animated blur. Keep instant state changes.

@media (prefers-reduced-motion: reduce) {
  .xglass,
  .xglass::before,
  .xglass::after {
    transition-duration: 0.01ms;
    animation-duration: 0.01ms;
    animation-iteration-count: 1;
  }
}

5. Defensive CSS order

The correct order makes the fallback structural, not conditional:

  1. Opaque-enough default so text is readable with no blur support and no media query support.
  2. @supports block adds the glass.
  3. Preference queries collapse back to solid/high-contrast.
  4. Print resets to opaque.

If a reviewer asks "what happens on Safari with reduced transparency?", the answer is "it is already readable because the default is not glass".

6. Focus, selection, and controls

  • Focus ring: the ring itself is not blurred (children paint after the filter), but a sticky glass bar can cover it. Set scroll-padding-top on the scroll container for sticky headers (WCAG C43), and keep sticky overlays below the focused element's scroll position.
  • Hit targets: glass chips and icon buttons must keep 24x24 CSS px minimum, and decorative overlays must be pointer-events: none so they do not steal clicks from the content they overlap.
  • Text selection: define ::selection with a solid enough background over glass; default translucent selection can drop below contrast.
.xglass ::selection {
  background: color-mix(in oklab, var(--brand) 70%, black 10%);
  color: white;
}
  • Forms: placeholders are text and are subject to 1.4.3; do not use them as labels. Input backgrounds must be opaque enough for the caret and selection to be visible. Autofill styles (:-webkit-autofill) often paint an opaque yellow that breaks glass; override with a box-shadow inset in the surface color.
  • Dialogs: native <dialog>/popover handles focus trap, Escape, and top-layer ordering. If you build custom overlays, use inert on the background and restore focus on close.
  • Screen readers: transparency is irrelevant to the accessibility tree; glass only matters visually. Do not add ARIA to decorative layers, and hide noise/edge pseudo-elements from the tree (pseudo-elements already are).

7. Platform guidance worth mirroring

  • Apple HIG: treat WCAG AA as the floor; provide a higher-contrast variant when Increase Contrast is on; use vibrancy text levels deliberately (the quaternary level is too low for body text); thicker materials improve contrast. Native APIs exist to detect Reduce Transparency (UIAccessibility.isReduceTransparencyEnabled, NSWorkspace.accessibilityDisplayShouldReduceTransparency).
  • Microsoft Fluent/Acrylic: the recipe is background + blur + exclusion blend (used specifically to preserve text contrast) + tint + noise. Acrylic is disabled automatically in Battery Saver, when Transparency effects are off, on low-end hardware, and in High Contrast; apps replace it with a solid color. Mirror this: build the solid fallback first, glass second.
  • Windows/Linux: the OS transparency switch is a real user preference even when the web query is not available. Expose an in-app "reduce transparency" setting for the gap.

8. Pre-ship checklist

  • [ ] Every glass surface is inventoried (data-glass-level attribute helps).
  • [ ] Worst-case fixtures exist: light, dark, busy, brand-colored backdrop.
  • [ ] Contrast measured from rendered pixels (screenshot + color picker or TPGi Colour Contrast Analyser), not guessed.
  • [ ] Automated tests acknowledge their blind spots: axe and Lighthouse do not report text over background-image or understand blur/scrim compositing; they return "incomplete" and require manual review.
  • [ ] DevTools > Rendering emulation checked for prefers-reduced-motion, prefers-contrast (more/less/custom), forced-colors, and prefers-reduced-transparency (Chrome 118+).
  • [ ] Keyboard tab-through: sticky bars and overlays never cover the focus ring.
  • [ ] OS-level toggles tested: macOS/iOS Reduce Transparency, Windows Transparency effects off + Contrast themes, Battery Saver.
  • [ ] Fallback verified with backdrop-filter forced off (DevTools or a build flag).
  • [ ] Zoom to 200% and 400%: no clipped text in fixed-height glass bars.
  • [ ] Text spacing override applied: no loss of content.
  • [ ] Print preview: opaque, readable.
  • [ ] Optional: APCA as a secondary check (flat colors only, not normative in WCAG 2.2; WCAG 3 is still a draft).

9. Anti-patterns

  • Declaring accessibility "handled" because the theme has a dark mode.
  • Using blur to make low-contrast text "work".
  • Relying on prefers-reduced-transparency alone (Safari/WebKit and default Firefox do not honor it).
  • Testing only the first viewport; the worst-case backdrop is usually further down the page.
  • Full-screen scrims that swallow the page when no modal is open.
  • Focus rings in a translucent brand color that composites below 3:1.
  • Animated pointer-following highlights for users who asked for reduced motion.

10. Related

  • ./02-css-implementation.md - the fallback and preference CSS blocks.
  • ./03-design-tokens-and-theming.md - scrim and text tokens.
  • ./06-performance-and-browser-support.md - support matrix details.
  • ./07-fullstack-architecture.md - visual regression fixtures for these worst-case backdrops.
06-performance-and-browser-support

06 - Performance and browser support

backdrop-filter is one of the most expensive compositing features available in CSS. This reference explains the cost model, the budget rules, the engine bug catalog, and the support matrix as of September 2026.

1. Why backdrop blur costs

From the Filter Effects Module Level 2 motivation: applying backdrop-filter or mix-blend-mode requires a separate rendering pass to complete partially painted stacking contexts. The spec itself warns this "would double the required rendering time" and "twice the memory usage and GPU bandwidth", and that nested backdrop filters would degrade exponentially. Backdrop roots exist to bound this cost.

Practical consequences:

  • Each glass surface is an extra texture/surface and an extra pass before the final composite.
  • Cost grows roughly with blurred area x kernel radius. A 400px-wide bar with blur(16px) is far cheaper than a full-screen overlay with blur(60px).
  • Engines downscale the backdrop before blurring at large radii, which is why very large blurs can show banding or blockiness.
  • Paint-bound work runs on the main thread; only opacity and transform animate on the compositor.

There are no official per-radius benchmark numbers from Chromium or WebKit; treat exact cost curves as empirical and measure on your own target hardware.

2. Budget rules

| Rule | Rationale | |------|-----------| | At most 2-3 blurred surfaces visible in a viewport; 1 is ideal | Each is a separate pass | | Prefer blur <= 16px for large surfaces, <= 32px for modals | Area x radius dominates | | Never full-screen backdrop-filter except a modal scrim or media overlay | Full-screen pass over the whole frame | | Full-width sticky bars use thin glass (8-12px) | Persistent, always visible, always paying | | Small controls (buttons, chips) use <= 10px | They add up quickly in toolbars/lists | | No glass inside scrolled list items by default | Dozens of blurred surfaces while scrolling = jank | | Disable blur when the app is not focused or the surface is offscreen | Free GPU cycles | | Animate only opacity/transform | Compositor-only properties |

Performance tiers by cost (cheapest to most expensive):

  1. Solid translucent fill, no blur.
  2. Small blur on a fixed-size element (blur(8-12px) badges, buttons).
  3. Medium blur on a bounded panel (blur(16-24px) cards, popovers).
  4. Large blur on a viewport-sized surface (blur(32-60px) modals, overlays).
  5. Animated or nested blur (avoid outside controlled demos).

3. Optimization techniques

Lazy-mount below the fold

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      (entry.target as HTMLElement).dataset.glassActive = "true";
      observer.unobserve(entry.target);
    }
  }
});

document.querySelectorAll("[data-glass-lazy]").forEach((el) => observer.observe(el));
[data-glass-lazy="true"] {
  -webkit-backdrop-filter: blur(var(--xglass-blur)) saturate(180%);
  backdrop-filter: blur(var(--xglass-blur)) saturate(180%);
}

The element renders with the opaque fallback until it approaches the viewport, then upgrades to glass once.

Skip work offscreen

.xglass-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 320px;
}

content-visibility: auto lets the engine skip rendering completely offscreen subtrees (Chrome 85+, Firefox 125+, Safari 18+/26; partial in early Safari 18 versions). Use it on glass cards in long lists, always with contain-intrinsic-size to avoid scrollbar jumps.

Stop paying when not visible

document.addEventListener("visibilitychange", () => {
  document.documentElement.dataset.appVisible = String(!document.hidden);
});
:root[data-app-visible="false"] .xglass {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  background: var(--surface);
}

Do not pre-declare will-change

will-change: filter, opacity, mask, or backdrop-filter creates a backdrop root (changing what gets blurred) and holds GPU memory. Add it only for the duration of an animation and remove it after.

Avoid blur during scroll-driven effects

Scroll-driven animations (animation-timeline: scroll(), supported in Chromium 115+ and Safari 26+) are perfect for animating opacity of a tint layer. Animating the blur value itself is paint-bound on every frame: do not.

Respect power state when available

There is no reliable battery API in 2026 (navigator.getBattery is deprecated/removed in some engines). Use coarse heuristics you control:

  • App-level "reduced effects" setting, defaulting on for prefers-reduced-transparency and low navigator.hardwareConcurrency (<= 4) devices.
  • Tauri: disable CSS glass when native vibrancy is active (avoid double cost), and prefer Mica/tabbed over Acrylic on Windows (see ./08-tauri-packaging.md).

4. Animation rules

| You want to animate | Do this | Never this | |---------------------|---------|------------| | Glass appearing/disappearing | Fade opacity of the element | Animate backdrop-filter from none | | Panel sliding in | transform: translate3d() | Animate blur() radius | | Hover lift | transform + box-shadow | Animate background alpha with blur | | Tint shift on scroll | Wrap the tint in a layer and animate its opacity | Change --xglass-tint per frame | | Specular follow | Move a gradient layer with transform | Recompute backdrop-filter |

Interpolating backdrop-filter between two filter lists is technically animatable, but it is paint-bound and causes main-thread work per frame. The only case where it is acceptable is a small, one-shot transition (<= 200ms) on a tiny surface, and even then prefer the opacity-crossfade of two layers.

5. Engine bug catalog (open as of Sep 2026)

Firefox

| Bug | Symptom | Workaround | |-----|---------|------------| | 1803813 | backdrop-filter stops working on position: sticky when an ancestor has both overflow and border-radius | Put the blur on an inner absolute layer | | 1882178, 2034651 | Backdrop not clipped correctly by parent overflow/radius | Trim with mask-image; test corners | | 1909463 | Failures with sticky elements in complex pages | Simplify sticky ancestry | | Pre-123 GPU bug (1868737, fixed 2026-04) | Property disabled on systems with unknown GPU vendor | Historical; no action |

WebKit / Safari

| Bug | Symptom | Workaround | |-----|---------|------------| | 319187 | Severe initial slowdown on iOS with large fixed blurred elements | Avoid large fixed glass on iOS | | 275305 | Text-input lag when backdrop-filter is present | Remove blur from views with active text inputs on affected versions | | 159428 | Sticky blur boundary artifacts | Avoid sticky + blur combination if visible | | 263194 | Blur "glow" bleeds onto elements painted above | Reduce inset/edge glow layers | | 297620 | Safari 18.x regression: CSS variables ignored in backdrop-filter on some macOS versions | Inline literal values as a stopgap, or feature-detect | | 245510, 297770 | url() SVG filters as backdrop/filter unreliable | Chromium-only enhancement; fallback to blur | | 252181, 201987 | Transformed ancestors filter the wrong content | Move glass out of transforms | | 319479, 322087, 325446 (Safari 26) | OS scroll-edge tint sampling disabled/faulty around fixed/sticky backdrop-filter layers | Use an opaque or gradient header in Tauri/macOS scroll-edge contexts |

Chromium

  • Overflow/clip vs mask order-of-operations (community-verified, no public crbug found): overflow: hidden trims before the filter runs, so the nearby-pixels extension does not work. Use mask-image.
  • Transparent Tauri/Electron windows can show resize/scroll artifacts on WebView2; noRedirectionBitmap: true mitigates creation flash. Treat scroll artifacts as empirical.

WebKitGTK (Tauri on Linux)

  • Uses the WebKit engine: same class of WebKit bugs applies.
  • Unprefixed backdrop-filter lands around WebKitGTK 2.46+; older distros need -webkit-backdrop-filter. Current WebKitGTK line is 2.54 (Sep 16, 2026).
  • Compositor blur (KWin rules, Hyprland) is outside the app; the webview still does its own backdrop pass.

6. Support matrix (Sep 2026)

| Feature | Chrome/Edge | Firefox | Safari/WKWebView | Notes | |---------|-------------|---------|------------------|-------| | backdrop-filter | 76+ (2019) | 103+ (2022) | -webkit- 9+, unprefixed 18+ | Baseline newly available 2024-09-16; ~96% usage | | mask-image | 120+ unprefixed (-webkit- long before) | 53+ (2017) | 15.4+ unprefixed (-webkit- long before) | Baseline Dec 2023 | | color-mix() | 111+ (2023) | 113+ (2023) | 16.2+ (2022) | Baseline May 2023; >2 colors is newer | | Relative color syntax | 119+ (2023) | 128+ (2024) | 16.4+ partial, 18 fixed | Baseline Jul 2024 | | light-dark() | 123+ (2024) | 120+ (2023) | 17.5+ (2024) | Baseline May 2024 | | prefers-reduced-transparency | 118+ (2023) | 113+ flag only | Not exposed | Not Baseline; treat as enhancement | | prefers-contrast | 96+ | 101+ | 14.1+ | Baseline May 2022 | | forced-colors | 89+ | 89+ | 16+ | Baseline Sep 2022 | | corner-shape (squircles) | 139+ (Aug 2025) | 159 | TP only | ~71% usage; progressive enhancement | | Popover API | 114+ | 125+ | 17+ | Baseline 2024; anchor positioning still settling in Firefox |

Policy recommendation for 2026 internal apps: target the Baseline of backdrop-filter (Sep 2024) for glass, keep the opaque fallback forever, and treat refraction, squircle corners, and reduced-transparency queries as enhancements.

7. SSR, static export, and hydration

  • Emit the full glass CSS (both prefixes) in the stylesheet. Never gate glass behind hydration: first paint would show a different material and then flash.
  • First paint still pays the blur raster cost. Do not stack above-the-fold glass on a slow device; the loading experience should already be readable.
  • backdrop-filter is paint-only, so it causes no layout shift by itself. The usual CLS source is a sticky glass header whose height changes with content. Reserve its block size.
  • Stagger hydration of below-fold glass components (mount a few per frame or on intersection) so the main thread is not blocked compositing many surfaces at once.
  • In static exports (Next.js output: "export", Astro, Astro islands), glass is a pure CSS concern; keep the token generator deterministic so the exported CSS is stable and cacheable.

8. Measuring

Practical methods that surface glass cost:

  1. Chrome DevTools Performance: record a scroll and look for long Paint/Composite Layers frames on glass elements. Compare with glass disabled.
  2. DevTools Rendering panel: "Paint flashing" shows the backdrop area repaint per frame.
  3. Lighthouse/INP: a glass-heavy page usually shows INP and Total Blocking Time regressions, not CLS.
  4. A page-level audit script that counts blurred elements:
export function countGlassSurfaces(root: ParentNode = document): number {
  let count = 0;
  for (const el of root.querySelectorAll<HTMLElement>("*")) {
    const style = getComputedStyle(el);
    if (
      style.backdropFilter !== "none" &&
      style.backdropFilter !== "" &&
      style.display !== "none" &&
      style.visibility !== "hidden"
    ) {
      count++;
    }
  }
  return count;
}

Add a CI budget: fail if countGlassSurfaces() exceeds the agreed maximum per route fixture.

  1. Test on a mid-range Android device, not only on a developer laptop.

9. Print

@media print {
  .xglass {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background: #fff;
    color: #000;
    box-shadow: none;
  }
}

Printers do not composite blur, and browsers often strip backgrounds anyway. Force opaque for legibility rather than fighting with print-color-adjust.

10. Checklist

  • [ ] Glass surfaces per viewport within budget (default max 3).
  • [ ] Blur radii small unless there is a documented exception.
  • [ ] No animated backdrop-filter.
  • [ ] No permanent will-change on glass.
  • [ ] Below-fold glass lazy-mounts or uses content-visibility.
  • [ ] Scroll test on a mid-range device passes without dropped frames.
  • [ ] Fallback verified in Firefox/Safari/WebKitGTK when relevant.
  • [ ] Tauri: native vibrancy and CSS glass not stacked.
  • [ ] Print stylesheet opaque.

11. Related

  • ./02-css-implementation.md - the CSS that implements these rules.
  • ./03-design-tokens-and-theming.md - levels map to cost tiers.
  • ./05-accessibility-and-legibility.md - reduced transparency support.
  • ./08-tauri-packaging.md - webview and native effect costs.
07-fullstack-architecture

07 - Fullstack architecture

The glass system is front-end-first, but it touches the whole stack: token build, SSR, theme persistence, an API, an admin preview, visual regression, CI, and deployment. This is the reference for wiring it into a real TypeScript application end to end.

1. Monorepo layout

repo/
  apps/
    web/                    # customer-facing app (SSR/static/SPA)
    admin/                  # theme editor + glass playground
  packages/
    design-tokens/          # token source + generator
    glass-css/              # primitive CSS + tokens.css output
    glass-ui/               # typed components (framework-specific or agnostic)
    api-client/             # generated/typed client for the preferences API
  services/
    api/                    # preferences + themes endpoints
  fixtures/
    backdrops/              # worst-case backdrop images for tests

Build order is strict: design-tokens -> glass-css -> glass-ui -> apps/*. The api-client types must be generated from the same Zod schemas the API uses, so a token rename fails at compile time on both sides.

2. Tokens pipeline

The generator from ./03-design-tokens-and-theming.md runs as a prebuild step and as a CI check:

{
  "scripts": {
    "tokens:build": "tsx packages/design-tokens/src/generate.ts",
    "tokens:check": "tsx packages/design-tokens/src/generate.ts --check",
    "prebuild": "pnpm tokens:build"
  }
}

--check regenerates into memory and diffs against the committed artifacts; CI fails on drift. Committed artifacts: tokens.css, tokens.json, and the Tailwind @theme block.

Cache busting: emit tokens.[contenthash].css in the app build, or import the generated file into the bundler so the hash is automatic.

3. SSR, theme detection, and no flash

The theme must be known before first paint. Two-phase approach:

  1. Server: read the theme cookie and render <html data-theme="dark">.
  2. Client, pre-hydration: a tiny blocking script reconciles localStorage and system preference, only if the cookie is absent or stale.
// apps/web/src/theme/bootstrap.ts (inlined in <head> by the bundler)
const storageKey = "xglass.theme";

function resolveTheme(): "light" | "dark" {
  const stored = localStorage.getItem(storageKey);
  if (stored === "light" || stored === "dark") return stored;
  return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

document.documentElement.dataset.theme = resolveTheme();
<script>
  /* inlined bootstrap, must run before the stylesheet renders */
</script>

Notes:

  • CSP: inline scripts require a nonce or hash. Generate the nonce per request and add it to both the CSP header and the script tag. Do not weaken the CSP to unsafe-inline.
  • The bootstrap script is the only inline script in the system; keep it minimal and deterministic.
  • During SSR, default to the cookie; if there is no cookie, use the Sec-CH-Prefers-Color-Scheme client hint when the platform sends it, otherwise render light and let the bootstrap correct before paint.
  • Static export: the bootstrap is the only mechanism (no server), so it must be in the exported HTML head, not in a module.

4. Glass preferences API

Model user-visible glass settings as data, not client-only state:

// packages/api-client/src/schemas.ts
import { z } from "zod";

export const glassLevel = z.enum(["off", "thin", "regular", "thick"]);
export const themeMode = z.enum(["system", "light", "dark"]);

export const preferencesSchema = z.object({
  theme: themeMode.default("system"),
  accent: z.string().regex(/^oklch\(/).default("oklch(0.62 0.17 262)"),
  glassLevel: glassLevel.default("regular"),
  reducedTransparency: z.boolean().default(false),
  reducedMotion: z.boolean().default(false),
});

export type Preferences = z.infer<typeof preferencesSchema>;

Endpoints:

GET    /api/preferences          -> Preferences
PUT    /api/preferences          -> Preferences (validated, partial merge)
DELETE /api/preferences          -> reset to defaults

Implementation rules:

  • Validate on the server with the same schema the client uses; never trust the client for token-critical values.
  • Store reducedTransparency and reducedMotion as explicit user intent, separate from OS queries; the effective value is userSetting || osQuery.
  • Return an ETag; preferences change rarely and are read on every page load.
  • The API is tiny; keep it in the same app (Next.js route handlers, SvelteKit endpoints) unless the product already has a separate service.

Cookie strategy for SSR: mirror only theme and glassLevel into a readable (non-httpOnly) cookie, because the pre-hydration script needs them. Everything else can stay server-side.

5. Persistence schema

create table user_preferences (
  user_id              text primary key references users(id) on delete cascade,
  theme                text not null default 'system'
                       check (theme in ('system', 'light', 'dark')),
  accent               text not null default 'oklch(0.62 0.17 262)',
  glass_level          text not null default 'regular'
                       check (glass_level in ('off', 'thin', 'regular', 'thick')),
  reduced_transparency boolean not null default false,
  reduced_motion       boolean not null default false,
  updated_at           timestamptz not null default now()
);

Rules:

  • One row per user; upsert on write.
  • Add an audit table if the admin can publish themes: theme_revisions(id, author_id, payload jsonb, created_at).
  • Do not store derived values (computed tints, contrast results). Store intent; derive with tokens at render time.

6. Theme publishing and admin preview

When designers can edit tokens:

  1. Draft: token JSON edited in the admin app (apps/admin), validated against a schema that mirrors design-tokens.
  2. Preview: render inside an iframe pointed at the real app with ?previewTheme=<revisionId>; the app loads the draft token JSON before first paint (same bootstrap mechanism) and sets data-preview="true".
  3. Publish: write a theme_revisions row and invalidate the public token JSON cache.
  4. Rollback: republish a previous revision; the public token JSON is immutable per revision.
// iframe preview handshake
const frame = document.querySelector<HTMLIFrameElement>("#preview");
frame?.contentWindow?.postMessage(
  { type: "xglass:preview", tokens: draftTokens },
  window.location.origin,
);

Validate event.origin on the receiving side. The preview route must never be indexable and must be excluded from caching.

Publishing tokens is a security boundary: treat token values as untrusted input (url() values, expression()-like payloads). Validate that color and length tokens match strict patterns before persisting.

7. Rendering strategy per app type

| App type | Glass strategy | |----------|----------------| | SSR (Next.js, Nuxt, SvelteKit) | Emit full CSS; theme from cookie; glass in the server-rendered markup so first paint is final | | Static export | Same, plus inline bootstrap; verify with JS disabled that the opaque fallback is readable | | SPA | Bootstrap theme in index.html; lazy-mount below-fold glass; prefetch token CSS | | Islands (Astro) | Static glass by default; hydrate only interactive glass (command palette, menus) | | Tauri (desktop) | Same web build; runtime flag swaps window-level glass to native vibrancy (see ./08-tauri-packaging.md) |

Glass must never depend on client JavaScript to be legible. The fallback is a CSS baseline, not a hydrated state.

8. Testing

Unit

  • Token snapshot: generated CSS matches committed artifacts.
  • Contrast tests using the utility from ./05-accessibility-and-legibility.md, run against a fixture palette of worst-case backdrops.
  • Component render tests asserting variant classes.

Integration

  • Preference API round-trip with schema validation and ETag behavior.
  • SSR render with each theme cookie asserts data-theme on <html>.

Visual regression

Playwright with fixed backdrops:

// tests/visual/glass.spec.ts
import { test, expect } from "@playwright/test";

const themes = ["light", "dark"] as const;
const backdrops = ["plain", "busy", "brand"] as const;

for (const theme of themes) {
  for (const backdrop of backdrops) {
    test(`glass surfaces: ${theme} over ${backdrop}`, async ({ page }) => {
      await page.goto(`/fixtures/glass?theme=${theme}&backdrop=${backdrop}`);
      await page.emulateMedia({ reducedMotion: "reduce" });
      await expect(page).toHaveScreenshot(`glass-${theme}-${backdrop}.png`, {
        animations: "disabled",
        maxDiffPixelRatio: 0.01,
      });
    });
  }
}

Fixtures required: plain, busy (high-frequency photo), brand (saturated gradient), and a scroll extreme of each. Store the backdrops in fixtures/backdrops/ so they are deterministic across machines.

Accessibility in CI

  • axe scan per fixture for structural issues (noting its contrast blind spots).
  • A keyboard test tabbing through sticky glass asserting the focused element is not obscured (document.elementFromPoint over the focus ring center).

9. CI pipeline

name: ci
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm tokens:check
      - run: pnpm typecheck
      - run: pnpm test
      - run: pnpm --filter web build
      - run: pnpm exec playwright install --with-deps chromium firefox webkit
      - run: pnpm test:visual

Order matters: token drift first, then types, then tests, then build, then visual. Add a budget check step that fails when countGlassSurfaces() (from ./06-performance-and-browser-support.md) exceeds the per-route maximum.

10. Deployment

  • Static/CDN: serve hashed token CSS with immutable caching; the HTML must reference theme state without a server round-trip.
  • The preferences API gets its own cache policy: private, max-age=0, must-revalidate with ETag.
  • Feature-flag new glass levels (glassLevel: "regular-v2") so rollouts can revert without a deploy.
  • Monitor Core Web Vitals (INP especially) per theme; glass regressions show up as INP spikes, not CLS.
  • Tauri builds use the same web bundle; desktop packaging and native effects are covered in ./08-tauri-packaging.md.

11. Anti-patterns

  • Tokens generated at runtime in the browser instead of build time.
  • Theme stored only in localStorage: SSR renders the wrong theme and users see a flash.
  • Two sources of truth for schema (Zod vs TypeScript interfaces drifting).
  • Admin preview that writes directly to production tokens with no revision or rollback.
  • Visual regression tests that only cover the default backdrop.
  • Enabling glass for all users at once with no flag.
  • Shipping the inlined theme bootstrap without a CSP nonce.

12. Related

  • ./03-design-tokens-and-theming.md - the token source this pipeline builds.
  • ./05-accessibility-and-legibility.md - the contrast tests used in CI.
  • ./06-performance-and-browser-support.md - the performance budgets.
  • ./08-tauri-packaging.md - the desktop target of the same build.
08-tauri-packaging

08 - Tauri packaging

How to ship the same TypeScript + CSS glass app as a desktop app with Tauri 2.x, when to use native window vibrancy instead of CSS backdrop-filter, and how to keep both from stacking.

Scope: Tauri stable 2.x (2.12 shipped 2026-09-26; MSRV Rust 1.90; Windows 7 dropped), @tauri-apps/api 2.12.x, window-vibrancy 0.8.1 (first-party, published 2026-09-22). The crate is being reorganized toward a tauri-plugin-vibrancy name; verify the import path when upgrading.

1. The one rule that prevents most bugs

backdrop-filter blurs only what the webview paints. It cannot blur the desktop, the wallpaper, or native window vibrancy composited by the OS behind a transparent webview.

Consequences:

  • A transparent Tauri window whose page has background: transparent and a CSS glass panel over empty space: the CSS blur has nothing to sample. The desktop shows through untinted; the "glass" is just a translucent border.
  • Native vibrancy behind the window + CSS blur on a panel over in-page content: working combination (the panel blurs page content, not the desktop).
  • Native vibrancy + CSS blur over the same pixels: double blur, wasted GPU, muddy result.

Choose one layer to own the window, one to own the content:

| Layer | Owns | Technology | |-------|------|------------| | Window chrome (titlebar, sidebar, whole background) | desktop/wallpaper depth | native: windowEffects / window-vibrancy | | In-page floating surfaces (cards, popovers, panels over app content) | in-page content depth | CSS backdrop-filter | | Full-screen scrims (modals) | page content | CSS (native vibrancy is the window, not the scrim) |

2. Native effects by platform

window-vibrancy (Rust) is the direct API; Tauri windowEffects config and setEffects are the integrated path.

| Function | Platform | Notes | |----------|----------|-------| | apply_vibrancy / clear_vibrancy | macOS 10.10+ | Takes an NSVisualEffectMaterial, optional state, optional radius | | apply_liquid_glass / clear_liquid_glass | macOS 26+ | LiquidGlassOptions with style Clear/Regular, radius, opaque, interactive (interactive is macOS 27+) | | apply_mica / clear_mica | Windows 11 | Preferred on Win11, cheapest native effect | | apply_acrylic / clear_acrylic | Windows 10 (1903+) / 11 | GPU-heavy; drag/resize lag on Win10 1903+ and Win11 22000 | | apply_blur / clear_blur | Windows 7/10/11 (22H1) | Poor performance on Win11 22621+; prefer mica | | Tabbed | Windows 11 | Available through Tauri's Effect enum, not as a crate function | | Linux | - | Not supported; no first-party API. Compositor rules (KWin, Hyprland) are user-side | | macOS 26 Liquid Glass | macOS 26+ | liquidGlassRegular / liquidGlassClear; Clear requires a dimming layer |

macOS materials (valid Effect values): appearanceBased, light, dark, mediumLight, ultraDark (deprecated since 10.14), titlebar, selection, menu, popover, sidebar, headerView, sheet, windowBackground, hudWindow, fullScreenUI, tooltip, contentBackground, underWindowBackground, underPageBackground, liquidGlassRegular, liquidGlassClear. Practical picks: underWindowBackground for app windows, sidebar for sidebars, hudWindow for floating tools.

States: active, inactive, followsWindowActiveState (macOS; ignored for Liquid Glass). Radius is macOS-only. Color applies to Windows 10 1903+ Blur/Acrylic and macOS Liquid Glass.

3. tauri.conf.json

{
  "app": {
    "macOSPrivateApi": true,
    "windows": [
      {
        "label": "main",
        "transparent": true,
        "decorations": true,
        "titleBarStyle": "Overlay",
        "hiddenTitle": true,
        "trafficLightPosition": { "x": 16, "y": 16 },
        "windowEffects": {
          "effects": ["underWindowBackground"],
          "state": "followsWindowActiveState",
          "radius": 12
        }
      }
    ]
  }
}

Field notes:

  • windowEffects requires transparent: true and is unsupported on Linux.
  • macOSPrivateApi: true is required for transparent on macOS. It uses private APIs: the app is not App Store-eligible. If App Store distribution matters, use windowEffects on a normal (non-transparent) window, which relies on public APIs.
  • titleBarStyle: "Visible" | "Transparent" | "Overlay". Overlay puts the traffic lights over your content and requires a custom drag region; dragging is disabled while the window is unfocused (tauri#4316).
  • hiddenTitle and trafficLightPosition are macOS-specific; trafficLightPosition requires Overlay + decorations: true.
  • Windows: transparent: true can flash white on creation; "noRedirectionBitmap": true mitigates it. With decorations, Windows 11 rounds corners automatically; for borderless rounded windows call DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE) yourself (no Tauri config field; DIY).

4. Runtime APIs

JavaScript

import { getCurrentWindow, Effect, EffectState } from "@tauri-apps/api/window";

await getCurrentWindow().setEffects({
  effects: [
    Effect.UnderWindowBackground, // macOS + fallback
    Effect.Mica,                  // Windows 11
    Effect.Acrylic,               // Windows 10 fallback
  ],
  state: EffectState.Active,
  radius: 8,
});

await getCurrentWindow().clearEffects();

The first supported effect wins. On macOS you can list a Liquid Glass style plus a material fallback for macOS 15 and older in the same array.

Capabilities

setEffects and clearEffects need core:window:allow-set-effects, which is not included in core:window:default:

{
  "identifier": "main-capability",
  "windows": ["main"],
  "permissions": [
    "core:window:default",
    "core:window:allow-set-effects",
    "core:window:allow-start-dragging"
  ]
}

Grant only what the app uses; window effects do not justify broad permissions.

Rust

use tauri::utils::config::{WindowEffect, WindowEffectState, WindowEffectsConfig};

let effects = WindowEffectsConfig {
    effects: vec![WindowEffect::Mica, WindowEffect::UnderWindowBackground],
    state: Some(WindowEffectState::Active),
    radius: Some(8.0),
    color: None,
};
window.set_effects(Some(effects))?;

Verify exact field names against docs.rs for your Tauri minor; the effects config has evolved between 2.x minors.

5. CSS inside the Tauri webview

Engine map:

| Platform | Webview | backdrop-filter | |----------|---------|-------------------| | macOS | WKWebView | -webkit- long-standing; unprefixed 18+ | | Windows | WebView2 (Chromium, evergreen) | 76+, unprefixed | | Linux | WebKitGTK | -webkit- in older 2.3x; unprefixed around 2.46+; current line 2.54 (2026-09) |

Always ship both declarations.

Transparent window baseline

html,
body {
  background: transparent;
}

If the page paints an opaque background, native vibrancy is invisible. Keep app surfaces translucent and only the content-heavy areas opaque.

Drag regions

[data-tauri-drag-region] {
  app-region: drag;
  user-select: none;
}

[data-tauri-drag-region] button,
[data-tauri-drag-region] input,
[data-tauri-drag-region] a,
[data-tauri-drag-region] [role="button"] {
  app-region: no-drag;
}
  • data-tauri-drag-region applies only to the element it is set on, not its children. That is deliberate: buttons inside a glass titlebar stay clickable.
  • app-region: drag is needed for Windows touch/pen input; keyboard users do not drag.
  • Manual fallback: call getCurrentWindow().startDragging() on mousedown with event.buttons === 1; on double-click call toggleMaximize().
  • On macOS add -webkit-user-select: none and -webkit-touch-callout: none to chrome surfaces to prevent text selection and link callouts.

Detecting the runtime and platform

import { isTauri } from "@tauri-apps/api/core";
import { platform } from "@tauri-apps/plugin-os";

export async function applyRuntimeAttributes() {
  const root = document.documentElement;
  if (!isTauri()) {
    root.dataset.runtime = "web";
    return;
  }
  root.dataset.runtime = "tauri";
  root.dataset.platform = await platform(); // "macos" | "windows" | "linux" | ...
}

Notes:

  • isTauri() semantics changed in 2.12 (checks globalThis.isTauri; older releases checked window.__TAURI_INTERNALS__, which still exists). Use the function, not the internals, and guard platform calls behind it.
  • Set attributes before first paint when possible (top of the entry module) to avoid a flash of the web glass over native vibrancy.
  • Components never branch on platform; CSS does, via :root[data-runtime="tauri"][data-platform="macos"].

Reduced transparency inside Tauri

  • WebKit (macOS) exposes the system setting natively, but the web media query is not exposed in WKWebView as of Sep 2026; you cannot rely on prefers-reduced-transparency there.
  • Chromium/WebView2 does not ship the query either (limited availability), even though desktop Chrome 118+ does.
  • Practical approach: read the OS setting from Rust and forward it as an app event, or expose an in-app "reduce transparency" setting persisted through the preferences API (./07-fullstack-architecture.md).

Sketch of a native-forwarded flag (Windows registry HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\EnableTransparency, macOS NSWorkspace.accessibilityDisplayShouldReduceTransparency), emitted to the webview as:

import { emit, listen } from "@tauri-apps/api/event";

await listen<boolean>("system://reduce-transparency", (event) => {
  document.documentElement.dataset.reduceTransparency = String(event.payload);
});

Treat this as a small platform shim, not part of the design system's core.

6. Decision matrix

| Scenario | Native | CSS | Notes | |----------|--------|-----|-------| | Whole window over wallpaper (macOS/Windows) | yes | no | underWindowBackground, mica/acrylic | | Sidebar over wallpaper | yes | no | sidebar material on macOS | | Cards/popovers over app content | no | yes | Native vibrancy cannot follow in-page content | | Modal scrim | no | yes | Blurs page content; native would blur desktop | | macOS 26+ Liquid Glass chrome | yes | no | liquidGlassRegular; Clear needs a dimming layer | | macOS 15 and older | yes | no | fallback material underWindowBackground | | Windows 11 | yes | no for window; yes for panels | Mica cheapest; tabbed for tabs | | Windows 10 1903+ | yes, careful | yes for panels | Acrylic works but is heavy; blur on 22H1- only | | Linux | no | yes | Native unsupported; CSS only | | App Store distribution | public APIs only | n/a | No macOSPrivateApi, no transparent window macOS |

7. Performance and packaging

  • Microsoft documents Acrylic as GPU-intensive with battery impact and disables it automatically in Battery Saver, when Transparency effects are off, and in High Contrast. Mica and Tabbed are cheaper. Prefer Mica on Win11.
  • Avoid Acrylic as the base of large scrolling surfaces; known drag/resize lag on Win10 1903+ and Win11 22000. Prefer Mica, or CSS glass on bounded panels.
  • Do not stack acrylic surfaces: seams become visible.
  • WebView2 transparent windows can show artifacts while resizing/scrolling; noRedirectionBitmap: true mitigates creation flash, treat the rest as empirical per machine.
  • CSS glass on Tauri still pays the same cost as on the web; the budgets in ./06-performance-and-browser-support.md apply unchanged.

Bundling (tauri build, bundle.targets): deb, rpm, appimage, nsis, msi, app, dmg, or "all". Updater: bundle.createUpdaterArtifacts plus tauri-plugin-updater with signed artifacts. With macOSPrivateApi: true, plan for direct distribution (codesigning + notarization, hardenedRuntime defaults true) instead of the App Store.

Automated UI testing of the packaged app is limited: tauri-driver covers Linux (WebKitWebDriver) and Windows (msedgedriver); macOS is not supported by the driver as of Sep 2026. Keep the web build testable in Playwright (./07-fullstack-architecture.md) and use the packaged app for manual platform checks.

8. Shipping checklist

  • [ ] Decision made per surface: native, CSS, or neither (Section 1).
  • [ ] No CSS blur over native vibrancy pixels.
  • [ ] html, body { background: transparent } only when native vibrancy is active; opaque otherwise.
  • [ ] windowEffects arrays ordered with a fallback per OS.
  • [ ] Capability grants core:window:allow-set-effects (and only that).
  • [ ] Drag region tested with buttons, inputs, and double-click maximize.
  • [ ] macOS: macOSPrivateApi implication accepted (no App Store) or avoided.
  • [ ] Windows: Mica preferred; Acrylic only when required by Win10 support.
  • [ ] Linux: CSS-only path verified on WebKitGTK.
  • [ ] Reduced transparency handled through the app setting / native flag.
  • [ ] Packaged build tested on each target OS, including window resize and scroll.

9. Anti-patterns

  • Copying a CSS glass recipe into a transparent Tauri window with nothing behind the webview, then wondering why there is no blur.
  • transparent: true with an opaque page background.
  • Using macOSPrivateApi without accepting the App Store consequence.
  • Acrylic on Win11 when Mica exists.
  • Putting data-tauri-drag-region on a container and expecting child buttons to work; the attribute does not propagate (by design), and the container may still swallow events if styled with pointer-events: none.
  • Platform detection in components instead of CSS attributes.
  • Testing only the web build and assuming the packaged webview renders identically (WebKitGTK and old WKWebView will differ).

10. Related

  • ./01-physics-of-glass.md - the layers native vibrancy replaces or complements.
  • ./02-css-implementation.md - the CSS that runs inside the webview.
  • ./06-performance-and-browser-support.md - budgets shared with the web build.
  • ./07-fullstack-architecture.md - the web build packaged by Tauri.