I had developers try this and fail. Twice. The conclusion both times was the same: "if you need a custom title bar that doesn't break Windows, use Electron."
That conclusion is wrong, and I want to lay out exactly why — because the reason it's wrong is buried under a label on a GitHub issue that almost everyone misreads.
The problem
Set "decorations": false in Tauri so you can draw your own title bar. Everything looks great. Then a user hovers your maximize button and the Windows 11 snap menu — the little grid of layout options — doesn't appear.
You lose it silently. No error, no warning. And nothing you write in HTML, CSS, or JavaScript can bring it back.
That last part is the bit that breaks people's mental model, so it's worth being precise about it.
Why no amount of frontend code can fix this
Windows only offers Snap Layouts to a window that answers the WM_NCHITTEST message with HTMAXBUTTON.
That's a Win32 return value from a window procedure. There is no DOM API that produces it. No CSS property. No Tauri config key. Your frontend is simply not a participant in this conversation.
So you go and implement the hit test properly, exactly as Microsoft documents it… and nothing happens.
Here's why. In Tauri your page lives inside a WebView2 child window that covers the entire client area. When the cursor is over your maximize button, Windows asks that window what's underneath. Your Tauri window's procedure never gets consulted.
You can recognise you're in this trap by a very specific tell: your maximize button still shows its CSS :hover state and its HTML tooltip. Both of those require the webview to have received the mouse — which means your window did not.
And subclassing doesn't rescue you either. Chromium uses its own internal input routing, so SetWindowSubclass fails on exactly the descendant windows that actually receive the mouse. Force-swapping its WndProc breaks rendering.
I watched another team reach this same conclusion independently, in a different language, two weeks ago.
The misreading that costs everyone a week
Tauri issue #4531 is labelled status: upstream. winit#3884 says plainly that returning HTMAXBUTTON isn't possible there, with no workaround.
Both statements are true. Both are about the framework shipping a first-class API. Neither says anything about what your application can do.
Nothing stops you from creating your own Win32 window alongside Tauri's. That's the whole fix. Reading status: upstream as "impossible until winit changes" is, as far as I can tell, the single most expensive misreading in this problem space — and it's why a four-year-old thread is still full of people concluding they should switch to Electron.
The actual fix
Create a small transparent native child window sitting exactly over your HTML maximize button, whose window procedure returns HTMAXBUTTON unconditionally.
It never paints, so your design shows through. But it genuinely owns the mouse in that rectangle — which means it has to report hover and clicks back to your page, or your button goes visually dead.
Five independent plugins converged on this identical technique. That's decent evidence there's no other route.
tauri::Builder::default()
.plugin(
tauri_plugin_frame::FramePluginBuilder::new()
.auto_titlebar(false) // you draw the controls
.snap_overlay(true)
.titlebar_height(32) // MUST match your CSS
.button_width(46) // MUST match your CSS
.build(),
)
.setup(|app| {
let window = app.get_webview_window("main").unwrap();
window.create_overlay_titlebar_with_height(32)?;
Ok(())
})
// The overlay covers the button, so onclick and :hover never fire.
// It emits these instead.
listen("tauri-frame://snap/click", () => appWindow.toggleMaximize());
listen("tauri-frame://snap/mouseenter", () => maxBtn.classList.add("cursor-over"));
listen("tauri-frame://snap/mouseleave", () => maxBtn.classList.remove("cursor-over"));
Five traps that aren't written down anywhere
1. Your button IDs can silently switch the plugin off. tauri-plugin-frame injects a script beginning if (!tbEl || tbEl.querySelector("[id^='frame-tb-']")) return;. If your title bar already has any frame-tb-* element, the whole setup bails and registers nothing. Those IDs are the plugin's own, published in its README — so hand-writing your buttons with the documented IDs is precisely what breaks it. Symptom: the flyout works, minimize and close work, and the maximize button does nothing while looking completely alive.
2. The overlay is positioned by arithmetic, not by measuring your DOM. x = client_right − button_width × (buttons_to_the_right + 1). Your Rust values must equal your CSS exactly. Any drift and the flyout silently stops appearing while nothing else breaks.
3. minWidth above ~500px means the flyout appears but the window won't snap. Microsoft asks for ≤500 effective pixels, ideally ≤330. Almost nobody sets this, and it reads like a half-broken feature rather than a config value.
4. Subclassing is structurally impossible, not just hard. Covered above — but worth restating, because it's the documented approach and people lose days to it.
5. WS_CLIPSIBLINGS is load-bearing if you roll your own. Working set: WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | WS_OVERLAPPED, no extended styles, NULL_BRUSH. Invisibility must come from never painting. WS_EX_LAYERED costs you the hit test, and WS_EX_TRANSPARENT makes the window hit-test-transparent — which defeats the entire point.
The lesson that surprised me most
More often than the implementation was broken, my instrument was blind.
I spent a long time convinced rounded corners weren't working. They were. PrintWindow renders a window's own pixels and is completely blind to DWM compositing — so it cheerfully showed me square corners on a window that was genuinely rounded. It also can't capture the Snap Layouts flyout at all, because the flyout is a separate OS window.
Screenshot-based verification of window chrome produces confident false failures. The answer was Windows.Graphics.Capture — the API Snipping Tool and OBS use — which reads the visual out of DWM and preserves the alpha channel. An antialiased alpha ramp from 9 to 255 across six pixels at (0,0) is a rounded corner. A square window reads 255 flat.
If you're debugging window chrome and your evidence disagrees with your code, suspect the camera before the subject.
Take the templates
I put up two runnable templates — a plain one-canvas shell, and a Windows Terminal-style tab strip sharing the row with the caption buttons — plus rounded corners, a full failure catalogue, and a verify.ps1 that asserts against a live window instead of trusting a screenshot.
https://github.com/Zbrooklyn/tauri-snap-layouts
git clone https://github.com/Zbrooklyn/tauri-snap-layouts
cd tauri-snap-layouts/frameless-window
npm install
npm run dev
Verified on Windows 11 build 26200, Tauri 2.11.5, at both 100% and 150% scaling. MIT licensed.
If you hit a failure mode I haven't documented, open an issue — the goal is for that repo to be the page that ends the search.
Top comments (0)