The "web app in a window" smell
You ship a Tauri or Electron app. The screenshots look gorgeous. Animations are buttery on your machine. Then a friend opens it and within thirty seconds says, "feels kinda... weird?" They can't tell you why. You can't either. But something is off.
I've shipped four desktop apps this way over the last few years. Two of them I had to basically rebuild because the "feel" problem compounded until users just stopped opening them. So when I saw yetone/native-feel-skill trending — an Agent Skill that distills the design principles for making WebView-based desktop apps feel native — I spent a weekend with it. It's structured around eight architectural tenets, a four-layer architecture, a WebKit/WebView2 survival guide, and a 75-item shipping checklist. It put words to a lot of stuff I'd been doing wrong.
Let me walk through what's actually happening when your app feels "off," and the concrete fixes.
The root cause: you're rendering the wrong things at the wrong layer
The "web app in a window" feeling almost always comes from one mistake: putting things in the WebView that the OS should own.
When you render a context menu in HTML, the user's brain notices. Not consciously — the menu renders in 4ms instead of the 0ms a native menu takes. The keyboard navigation has slightly different timing. The blur looks like a CSS backdrop-filter, not the OS material. None of these are individually catastrophic. Together, they scream "this is a browser pretending to be an app."
The fix is to think in layers. A useful mental model is roughly:
Most teams treat this as two layers ("Rust backend, web frontend"). That's the bug.
Step 1: stop rendering context menus in HTML
This is the single biggest perceived-quality fix you can make. In Tauri:
// src-tauri/src/menu.rs
use tauri::menu::{MenuBuilder, MenuItemBuilder};
pub fn show_context_menu(window: &tauri::Window) -> tauri::Result<()> {
let copy = MenuItemBuilder::new("Copy")
.id("copy")
.accelerator("CmdOrCtrl+C") // OS-correct modifier
.build(window)?;
let menu = MenuBuilder::new(window).items(&[©]).build()?;
// popup() defers to AppKit/Win32 — not a div
window.popup_menu(&menu)?;
Ok(())
}The popup_menu call hands off to AppKit on macOS and Win32 on Windows. You get the OS's actual menu — correct font, correct timing, correct keyboard behavior, correct accessibility tree. Free.
Do the same for tooltips that need to escape the window bounds, color pickers, and date pickers when you can get away with it.
Step 2: route keyboard events like a native app does
WebViews swallow keyboard events in subtly wrong ways. The classic bug: Cmd+W closes the window on macOS, except when focus is in an , where the WebView eats it. Or Tab cycles through DOM elements in your settings panel but skips the native sidebar.
The fix is to treat the WebView as just one focus zone among several, and route shortcuts at the shell level:
// shared/shortcuts.ts
type Scope = 'global' | 'window' | 'editor'
interface Shortcut {
// Use OS-correct chords — never hardcode 'Ctrl' on mac
mac: string
win: string
linux: string
scope: Scope
handler: () => void
}
export const shortcuts: Record<string, Shortcut> = {
closeWindow: {
mac: 'Cmd+W',
win: 'Ctrl+F4', // Windows convention, not Ctrl+W
linux: 'Ctrl+W',
scope: 'window',
handler: () => window.close(),
},
}Windows users expect Ctrl+F4 for "close document" and Alt+F4 for "close window." macOS users expect Cmd+W. Linux is a coin toss but Ctrl+W is reasonable. Hardcoding any of these cross-platform is a tell.
Step 3: fix the typography stack
This is the cheapest fix and the one most teams skip. Default web font stacks render with the wrong font, the wrong weight, and the wrong subpixel hinting on every OS.
/* tokens/typography.css */
:root {
--font-ui:
-apple-system, /* macOS — actually resolves to SF Pro */
BlinkMacSystemFont, /* macOS Chromium WebView */
'Segoe UI Variable', /* Windows 11 */
'Segoe UI', /* Windows 10 fallback */
'Ubuntu', /* GNOME */
sans-serif;
--font-mono:
ui-monospace, /* Resolves to SF Mono on macOS */
'Cascadia Code', /* Windows Terminal default */
'JetBrains Mono',
monospace;
}
body {
font-family: var(--font-ui);
/* Match native AppKit/Win32 rendering, not browser defaults */
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}If you can ship Segoe UI Variable on Windows 11 and SF Pro via -apple-system on macOS, you've already closed maybe 30% of the "feels off" gap. People don't notice the right font. They notice the wrong one.
Step 4: respect platform animation curves
Material Design's curves don't belong on macOS. iOS-style spring physics don't belong on Windows. Each platform has a vocabulary of motion, and mixing them feels uncanny.
A rough heuristic I've been using:
- macOS: ease-out, 200-300ms, generous bounce on overshoot for sheets
- Windows 11: faster ease (150-200ms), minimal bounce, slight scale on hover
- Linux/GNOME: linear-ish, fast, no bounce
Detect the platform once at boot and switch your design tokens accordingly. Don't try to be cute with a unified motion system that splits the difference — it satisfies no one.
Step 5: handle the WebKit vs WebView2 split honestly
This is where the survival guide framing in the skill earns its name. WebKit (macOS, Linux via WebKitGTK) and WebView2 (Windows, Chromium-based) are very different engines with different bugs.
A few I've personally hit:
- WebKit:
backdrop-filterperformance falls off a cliff with large blur radii. Cap it at 20px and you'll save 30fps. - WebView2: Drag-and-drop file events fire in a different order than WebKit. Don't assume
dragenterprecedesdragoverconsistently. - WebKit: IME composition events on CJK input behave differently — test with Japanese, Korean, and Chinese input methods before shipping, not after.
- WebView2: Transparent windows have a flicker on resize unless you opt into the right swap chain mode.
My rule now: any visual feature that uses GPU compositing (blur, transforms, filters) gets tested on both engines before it lands. No exceptions.
Prevention: build a shipping checklist and run it every release
The single best practice I took from the skill's framing is the idea of a shipping audit — a long, boring checklist you run before every release. The native-feel-skill repo ships with 75 items. Mine is shorter, but it includes things like:
- Does the window remember its size and position per-display?
- Does Cmd+Q quit and Cmd+W close-window, separately, on macOS?
- Does the app appear in Alt+Tab with the correct icon on Windows?
- Does dark mode follow the system in real time, without a reload?
- Do file dialogs open at the last-used directory?
- Does the dock icon bounce on important notifications (and only those)?
- Is there any place a user can right-click and get an HTML menu instead of a native one?
None of these are interesting. All of them are noticed when they're wrong.
The "native feel" of a desktop app isn't one thing you build. It's a hundred small things you didn't get wrong. The frameworks won't catch them for you. The checklist will.
