Acrylic

Tauri (vibrancy)

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.

Quick start

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-app
cd my-acrylic-app
pnpm install

pnpm 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.

Sharing the components (single source of truth)

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:

// vite.config.ts
const repoRoot = fileURLToPath(new URL("../../", import.meta.url))
export default defineConfig({
  resolve: { alias: { "@": repoRoot }, dedupe: ["react", "react-dom"] },
  server: { fs: { allow: [repoRoot] } }, // let Vite read out-of-project sources
})

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 @source every 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.tsx
import { 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).

The gotcha (read this first)

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.

1. Transparent window

src-tauri/tauri.conf.json:

{
  "app": {
    "windows": [{ "label": "main", "transparent": true }]
  }
}

2. Native vibrancy (Rust)

Add window-vibrancy:

# src-tauri/Cargo.toml
[target.'cfg(target_os = "windows")'.dependencies]
window-vibrancy = "0.7"
windows-sys = { version = "0.60", features = [
  "Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry",
] }

[target.'cfg(target_os = "macos")'.dependencies]
window-vibrancy = "0.7"

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.rs
use 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:

/* examples/tauri/src/index.css */
.acrylic.vibrancy [data-slot="sidebar-inset"] {
  background-color: rgba(24, 24, 28, 0.76); /* main panel */
}
.acrylic.vibrancy {
  --sidebar: rgba(18, 18, 22, 0.88);        /* darker rail */
}

3. Flip html.vibrancy (frontend)

Ask the Rust side whether vibrancy was applied; only then mark <html>. A harmless no-op in a plain browser:

// src/main.tsx
import { invoke } from "@tauri-apps/api/core"

invoke<boolean>("window_translucent")
  .then((active) => active && document.documentElement.classList.add("vibrancy"))
  .catch(() => {}) // not under Tauri → stay opaque

4. CSS

acrylic-ui's acrylic.css already ships the token flips (inert until html.vibrancy is present):

html.vibrancy { --background: transparent; }
html.vibrancy.modal-acrylic { --background: rgba(24, 24, 27, 0.92); }

Your app applies that token to the surfaces — this part is app-level because only your app knows its mount node (#root, #app, …):

html, body, #root { background: var(--background); }

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.

5. Build for Windows from WSL (optional)

The playground cross-compiles from WSL with mingw-w64 (no Windows toolchain):

# src-tauri/.cargo/config.toml
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-ar"
rustup target add x86_64-pc-windows-gnu
sudo apt install gcc-mingw-w64-x86-64      # provides the linker/ar
pnpm --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.

Debugging the WebView from WSL (CDP)

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 running
node 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).

Port won't bind? Clear the WebView2 lock

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:

taskkill.exe /F /IM <yourapp>.exe /T
taskkill.exe /F /IM msedgewebview2.exe
rm -f "/mnt/c/Users/<you>/AppData/Local/<bundle-id>/EBWebView/lockfile"

WebView2 opens the port a little after the app window appears, so give it a few seconds and confirm with netstat.exe -ano | grep 127.0.0.1:9222.

Catching effects that fire before you can attach

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 + stack
for (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.

Frameless title bar (custom window chrome)

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:

{
  "app": {
    "windows": [{ "label": "main", "transparent": true, "decorations": false }]
  }
}

Capabilities — the caption buttons and resize handles call window commands, so grant them in src-tauri/capabilities/default.json:

{
  "permissions": [
    "core:default",
    "core:window:allow-minimize",
    "core:window:allow-toggle-maximize",
    "core:window:allow-close",
    "core:window:allow-is-maximized",
    "core:window:allow-start-dragging",
    "core:window:allow-start-resize-dragging"
  ]
}

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.

Platform support

PlatformBackdropNotes
macOSNSVisualEffect vibrancyAlways available (Metal-backed).
Windows 10/11Acrylic (apply_acrylic)Only when Settings → Personalization → Colors → Transparency effects is on; otherwise falls back to an opaque window.
LinuxNo native vibrancy; the app stays opaque (window_translucent returns false).

Which components frost under vibrancy

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.

On this page