Run acrylic-ui inside a Tauri transparent-vibrancy window so the frosted components frost against the real OS acrylic.
acrylic-ui's frosted look comes from CSS backdrop-filter. In a browser that
blurs the page behind the element. In a Tauri app you usually want something
better: a transparent window over the real desktop, with the OS compositing
native vibrancy (macOS) / acrylic (Windows) behind your UI — exactly like a native
macOS sheet. This guide wires that up.
A complete, runnable reference lives in
examples/tauri/ —
scaffold it in one command (below), or follow the from-scratch steps further down.
Fastest path: run the bundled example as-is, then swap in your own UI.
# scaffold just the example folder (no full-repo clone)pnpm dlx degit JaggerH/acrylic-ui/examples/tauri my-acrylic-appcd my-acrylic-apppnpm installpnpm tauri dev # native window with real OS vibrancy# — or just the web frontend, no Rust toolchain needed:pnpm dev # http://localhost:5180
Prerequisites: Node + pnpm for both; pnpm tauri dev additionally needs the
Rust toolchain and your platform's
Tauri system deps. On Windows, turn on
Settings → Personalization → Colors → Transparency effects or the window stays
opaque (see Platform support).
The example already wires up everything in the rest of this guide — transparent
window, native vibrancy, the html.vibrancy flip, the useModalAcrylicBody
overlay paint, and the Inter font (so text matches this docs site). Read on only
if you're adding vibrancy to an existing app instead of starting from the
example.
The playground does not copy or re-fork any component — it renders the same
files the web docs render. The whole app is just a thin host whose only job is the
Backdrop/vibrancy wiring; everything else (the sidebar demo, the gallery, the
tokens) is shared. Three things make that work, and two of them bit us — read the
pitfalls.
1. Alias @ to the repo root so the app imports the real registry + shared
demo components, not copies:
Now @/registry/acrylic/*, @/components/*, and @/lib/utils all resolve to the
one source of truth. dedupe keeps a single React instance even though the
imported files live in the root package.
2. @import the registry CSS and @sourceevery dir you render from:
/* src/index.css */@import "../../../registry/acrylic/acrylic.css"; /* tokens + .vibrancy flips */@source "../../../registry/acrylic"; /* component class usage */@source "../../../components"; /* the SHARED demo lives here */@source "."; /* this app's own files */
Pitfall — the missing @source silently drops classes. Tailwind v4 only
emits a utility class if it appears in a scanned file. A class used only in
a file outside every @source (e.g. an avatar accent fill that lives just in the
shared demo) is never generated, and that element renders unstyled — no error, no
warning. When we moved the demo into the repo's components/ dir, its
demo-specific classes vanished from the Tauri build until we added
@source "../../../components". Whenever you import a component from a new
directory, add that directory to @source.
3. Lock the theme to Acrylic.next-themes is framework-agnostic despite the
name — it works in a plain Vite app. But the app is always Acrylic: that is the
entire point of running over native vibrancy, so a Light/Dark switch would only
cover the OS material. Use forcedTheme="acrylic" and tell the shared demo to hide
its switcher:
// src/App.tsximport { ThemeProvider } from "next-themes"import { Backdrop } from "@/registry/acrylic/backdrop"import { SidebarDemo } from "@/components/sidebar-demo"export default function App() { return ( <ThemeProvider attribute="class" forcedTheme="acrylic" enableSystem={false}> <Backdrop /> {/* CSS-gated: wallpaper on web, hidden under vibrancy */} <SidebarDemo showThemeSwitcher={false} /> </ThemeProvider> )}
<Backdrop> is the single host-specific concern: on the web it paints the
wallpaper the chrome frosts over; under vibrancy the registry CSS hides it so the
native OS material shows instead. Everything above it is identical to the web — the
web showcase keeps the 3-way switcher (to demo all three themes); the app drops it.
Pitfall — vibrancy compensation must exclude the Acrylic theme. Acrylic is a
dark-based theme (white text over the native dark material). Any Tauri-local
readability fix written as .vibrancy:not(.dark) { … } — e.g. restoring a dark
--foreground because light panels stay light under vibrancy — will also
catch Acrylic and force its text dark, so it stops flipping when you switch into
it. Scope those rules to .vibrancy.light (light only), not :not(.dark), and
let Acrylic render through the shared dark tokens. The symptom is telltale: some
text changes on theme switch (anything keyed off --sidebar-foreground) and some
does not (anything keyed off --foreground).
CSS backdrop-filter only blurs pixels the web page paints. Under a
transparent vibrancy window the page body is transparent — the OS acrylic is
composited outside the webview — so a full-screen overlay's blur has nothing to
sample and becomes a no-op (text behind stays sharp, only the tint shows).
The fix is a per-state toggle. While any Dialog/AlertDialog is open, acrylic-ui's
shipped useModalAcrylicBody hook adds html.modal-acrylic; a CSS rule then
paints the body opaque so the overlay's blur has real pixels to frost — reverting
to transparent (native acrylic) when it closes. acrylic-ui ships both the hook
and that CSS rule (in acrylic.css); your app only has to provide the
transparent window + native vibrancy and flip html.vibrancy on.
Apply it on setup and expose whether it succeeded to the frontend. Windows only
blurs when the user's "Transparency effects" is on, and Win11 22H2+ stops blurring
unfocused windows unless you keep the window reported as active:
// src-tauri/src/lib.rsuse std::sync::atomic::AtomicBool;use tauri::Manager;struct Translucent(AtomicBool);#[tauri::command]fn window_translucent(state: tauri::State<'_, Translucent>) -> bool { state.0.load(std::sync::atomic::Ordering::Relaxed)}pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![window_translucent]) .setup(|app| { let translucent = detect_and_apply_translucency(app); app.manage(Translucent(AtomicBool::new(translucent))); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application");}fn detect_and_apply_translucency(app: &tauri::App) -> bool { #[cfg(target_os = "macos")] { use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; let Some(w) = app.get_webview_window("main") else { return false }; apply_vibrancy(&w, NSVisualEffectMaterial::HudWindow, None, None).is_ok() } #[cfg(target_os = "windows")] { use window_vibrancy::apply_acrylic; let Some(w) = app.get_webview_window("main") else { return false }; if !transparency_effects_enabled() { return false; } // opaque fallback // Best-effort tint — but on Win11 this is largely ignored (see the pitfall // below); the real bleed-through fix is a CSS veil on the panels. match apply_acrylic(&w, Some((24, 24, 28, 200))) { Ok(()) => { install_always_active_subclass(&w); true } Err(_) => false, } } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { let _ = app; false } // Linux: no support → opaque fallback}
See examples/tauri/src-tauri/src/lib.rs for the full Windows helpers
(transparency_effects_enabled reads the registry flag;
install_always_active_subclass intercepts WM_NCACTIVATE).
Pitfall — over a light background the window bleeds through, and on Win11 you
fix it from CSS, not the tint. Under vibrancy --background is transparent, so
the panels show the raw OS acrylic. Over a sharp white desktop DWM's blur radius
isn't enough to dissolve it and you read the UI edges behind your window —
nothing like macOS, whose NSVisualEffectView carries a heavy built-in tint so you
only ever see soft colour. The obvious lever, apply_acrylic's tint
Some((r,g,b,a)), should darken the material as alpha rises — but Win11's legacy
acrylic API largely ignores it (window-vibrancy warns the API is degraded on
22000+), so on modern Windows varying the alpha does nothing visible. Fix it on the
webview side instead: paint a semi-opaque dark background-color on the panels
under .acrylic.vibrancy. A background-color veil does render over a
transparent body (unlike backdrop-filter, which is a no-op), and its alpha is the
real opacity knob. Tune two values — the content pane (inset) and the sidebar rail —
keeping the rail darker so the two panes stay distinct without needing a border:
That's it. When --background is transparent the native acrylic shows through;
when a Dialog opens, useModalAcrylicBody adds .modal-acrylic, the body goes
opaque, and the overlay frosts.
rustup target add x86_64-pc-windows-gnusudo apt install gcc-mingw-w64-x86-64 # provides the linker/arpnpm --filter tauri-playground tauri dev --target x86_64-pc-windows-gnu
Tauri's [lib] crate-type must be ["staticlib", "rlib"] (not cdylib) — the
mingw BFD linker can't handle Tauri's export count. The desktop binary links via
rlib.
When you run tauri dev --target x86_64-pc-windows-gnu from WSL, the frontend runs
inside the Windows WebView2 window — you can't see its console from the WSL
terminal. Attach to its Chrome DevTools Protocol (CDP) endpoint instead. Three things
have to line up, and each one has a non-obvious failure mode.
1. Open the CDP port — env-gated in Rust (what the example ships). The
examples/tauri app opens the port only when ACRYLIC_TAURI_DEBUG is set, appending
the debug flags to the WebView2 args at the top of run(). Gated on the env flag so
production builds never expose the port; APPEND (never overwrite) so it survives any
other WebView2 flags:
#[cfg(target_os = "windows")]if std::env::var_os("ACRYLIC_TAURI_DEBUG").is_some() { let flag = "--remote-debugging-port=9222 --remote-allow-origins=*"; let existing = std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").unwrap_or_default(); let merged = if existing.is_empty() { flag.into() } else { format!("{existing} {flag}") }; std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", merged);}
Launch with the flag forwarded across the WSL→Windows boundary (see #3 for why
WSLENV is needed — a bare shell env var wouldn't reach the Windows process):
ACRYLIC_TAURI_DEBUG=1 WSLENV=ACRYLIC_TAURI_DEBUG cargo tauri dev --target x86_64-pc-windows-gnu
Quick alternative: hardcode "additionalBrowserArgs": "--remote-debugging-port=9222 --remote-allow-origins=*" on the window in tauri.conf.json. Simpler, but the port then opens in every build including production — prefer the gated approach above.
Don't add --remote-debugging-address=0.0.0.0 — it makes WebView2 refuse to open
the port at all. Bind loopback with --remote-debugging-port and allow origins.
2. Reach the port through a Windows-side relay. WebView2's CDP binds loopback
only and rejects connections whose origin isn't local. Under WSL2 mirrored
networking the TCP layer bridges localhost both ways, but Chromium's application
layer still refuses the WSL-originated connection. Run a tiny relay with the
Windows node (0.0.0.0:9223 → 127.0.0.1:9222) and point your CDP client at
9223. Chromium's DNS-rebinding guard also requires a Host: localhost header on
the /json fetch, and Host + Origin on the WebSocket upgrade — without them the
connection is rejected.
The example ships both halves in examples/tauri/scripts/: run cdp-relay.cjs
with the Windows node, then drive the WebView from WSL with cdp.mjs (needs
npm i for ws, which supplies those headers the built-in WebSocket can't):
node.exe scripts\cdp-relay.cjs # Windows node — leave runningnode scripts/cdp.mjs "document.documentElement.className" # WSL node — evaluate in the WebView
3. Pass dev env vars to the Windows process with WSLENV. A variable exported
in the WSL shell is invisible to the Windows *.exe unless you name it in WSLENV:
MY_VAR=/home/you/project/thing WSLENV=MY_VAR cargo tauri dev --target x86_64-pc-windows-gnu
With no flag suffix, WSLENV forwards the literal string, so a WSL path like
/home/... arrives intact (handy when your Rust side translates it to a \\wsl$
or /mnt/c form itself).
A leftover msedgewebview2.exe keeps the user-data dir locked, so a new launch
reuses the old browser instance — which never got the debug args — and the port
never opens. Kill the stale processes and remove the lock before relaunching:
To trace a side effect that happens on load — e.g. which code path adds a class to
<html> — inject a patch via CDP's Page.addScriptToEvaluateOnNewDocument (it runs
before any app code), then Page.reload:
// runs before the app boots; records every add/remove of the target class + stackfor (const op of ['add', 'remove']) { const orig = DOMTokenList.prototype[op] DOMTokenList.prototype[op] = function (...a) { if (a.includes('modal-acrylic')) (window.__trace ||= []).push(op + ' ' + new Error().stack) return orig.apply(this, a) }}
Read window.__trace back after the reload. This pins the exact call site and React
commit phase behind a "class stuck on after load" symptom far faster than poking at
the live DOM.
The native OS title bar breaks the vibrancy illusion — it's an opaque strip the
webview can't frost. Drop it (decorations: false) and render your own chrome
inside the app, following each host OS's convention. The example ships this as
examples/tauri/src/window-chrome.tsx
— copy that file and wire the three exports into your shell.
tauri.conf.json — pair decorations: false with the transparent: true from
step 1:
Wire it into the shell — no dedicated titlebar; the controls integrate into
the app chrome, and a data-tauri-drag-region element lets the empty chrome move
the window:
Windows / Linux → <WindowControls /> (min / max / close) at the app's
top-right, e.g. absolutely positioned in the ShellInset. Reserve that
corner (~138px) so a header's right-edge actions don't slide under it.
macOS → <MacTrafficLights /> in a thin draggable strip at the sidebar's
top-left (the Finder/Mail placement).
Both → <WindowResizeHandles /> once at the root. decorations: false also
removes the native resize borders on Windows (tauri#8519),
so these invisible edge/corner strips re-add them via startResizeDragging.
import { isMacOS, MacTrafficLights, WindowControls, WindowResizeHandles,} from "./window-chrome"// Windows caption buttons, overlaid at the ShellInset top-right corner.{!isMacOS && ( <div {...{ "data-tauri-drag-region": "" }} className="absolute right-0 top-0 z-30 flex h-8 items-stretch"> <WindowControls /> </div>)}// macOS traffic lights, in a draggable strip at the sidebar top-left.{isMacOS && ( <div {...{ "data-tauri-drag-region": "" }} className="flex h-7 items-center px-3"> <MacTrafficLights /> </div>)}// Once at the root — re-adds the resize borders decorations:false stripped.<WindowResizeHandles />
data-tauri-drag-region acts only on the element it's set on — child buttons
and inputs stay interactive — so put it on the empty container, not a wrapper
around the controls. getCurrentWindow() reads Tauri internals that don't exist
in a plain browser, so resolve it lazily (never at module load) and only mount
this chrome under isTauri(), or the web build throws.
Dialog and AlertDialog ship useModalAcrylicBody, so their full-screen
overlay frosts correctly. Card / Sidebar / Sonner are persistent
translucent surfaces that simply tint the native acrylic — they work without the
hook. Other overlays (Sheet, Popover, ContextMenu, Select) do not
currently paint the body, so under a transparent window their backdrop-filter
may not frost the way it does in a browser. Run the playground on your target OS
to see the real result before relying on it.