Write web code that renders inside macOS WKWebView with native look and feel. Use when building HTML/CSS/JS content for WKWebView, creating onboarding overlays, in-app web panels, or any web-rendered UI in a macOS native app. Covers layout, transparency, animation performance, system colors, interaction hygiene, scroll handling, Swift bridge patterns, and development workflow.
WKWebView is a NSView subclass. Your HTML/CSS/JS is the paint implementation of a native view, not a web page.
| Traditional Web | WKWebView |
|---|---|
| Viewport is variable, must adapt | Viewport is a known constant from Swift |
overflow: hidden is a restriction | overflow: hidden is body's default state |
| Absolute positioning is a hack | Absolute positioning is a legitimate first choice |
rem is a portable relative unit | rem is meaningless — root font-size is undefined |
| Background defaults to white | Background defaults to transparent |
| Scrolling is default behavior | Scrolling must be explicitly opted-in |
| Router manages navigation | State comes from Swift injection, no URL |
localStorage persists | localStorage may be wiped on process termination |
For the full mental model, see mental-model.md.
The viewport is a known constant. Window size is controlled by Swift. Do not use media queries or breakpoints.
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
}
w-full h-full is the normal default, not a special case.
Absolute positioning is legitimate. Overlays, badges, window-anchored elements — use position: absolute with precise values from the bridge without hesitation.
No responsive design. One viewport size at runtime.
No global scroll. body is always overflow: hidden. Scrollable regions are explicit sub-containers with overflow-y: auto.
Coordinate system: macOS native uses bottom-left origin (y up). CSS uses top-left (y down). When bridging coordinates: cssTop = screenHeight - macY - elementHeight.
WKWebView can render over native content (desktop, vibrancy materials). Three layers must align:
Swift side:
The full transparency chain must be intact — if any layer is opaque, everything below is invisible. See mental-model.md for the complete layer diagram.
webView.isOpaque = false
webView.setValue(false, forKey: "drawsBackground") // fallback for older SDKs
CSS side:
html,
body {
background: transparent;
}
Browser dev mock: Real transparency is invisible in the browser. Fake it:
if (!isNative) document.documentElement.dataset.env = 'browser';
#root[data-env='browser'] {
background: url('/wallpaper-light.png') center / cover;
}
@media (prefers-color-scheme: dark) {
#root[data-env='browser'] {
background: url('/wallpaper-dark.png') center / cover;
}
}
| Unit | When |
|---|---|
px | Fixed: icons, buttons, borders, spacing tokens, control heights |
% | Relative to parent: panels, columns, fill regions |
vw/vh | Root level only; unreliable inside flex/grid subtrees |
rem | Avoid — no meaningful root font size in WebView context |
Use px for anything small/fixed, % for filling containers, calc() to combine.
These are the tells. Eliminate all of them:
| Symptom | Fix |
|---|---|
| Hand cursor on buttons | * { cursor: default; } |
| Text selectable everywhere | * { user-select: none; } + whitelist inputs |
| Images/links draggable | * { -webkit-user-drag: none; } |
| Browser right-click menu | contextmenu → preventDefault() (native only) |
| Blue focus outline | Custom :focus-visible with system accent color glow |
| Elastic overscroll bounce | Swift: disable NSScrollView elasticity (CSS alone insufficient) |
| Tap flash on iOS | -webkit-tap-highlight-color: transparent |
| Non-native form controls | color-scheme: light dark + accent-color |
Global reset:
*,
*::before,
*::after {
box-sizing: border-box;
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
-webkit-tap-highlight-color: transparent;
}
input,
textarea,
[contenteditable] {
cursor: text;
user-select: text;
-webkit-user-select: text;
}
*:focus {
outline: none;
}
*:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.5);
border-radius: 4px;
}
body {
-webkit-font-smoothing: antialiased;
font-family:
system-ui,
-apple-system,
sans-serif;
color-scheme: light dark;
}
GPU-composited (always prefer): transform and opacity.
Triggers layout (avoid animating): width, height, top, left, margin, padding — any property that changes box dimensions.
In WKWebView, layout recalculation crosses a process boundary (UI process ↔ WebContent process). Layout thrashing is more expensive than in a standalone browser.
will-change caution: Each will-change: transform creates a compositing layer consuming GPU memory. Use on-demand (toggle via JS before animation), not as a blanket declaration.
For size changes: Use clip-path: inset() instead of animating width/height — it's paint-only, no layout.
Entrance animation pattern:
.panel {
opacity: 0;
transform: translateY(8px);
transition:
opacity 200ms ease,
transform 200ms ease;
}
.panel.visible {
opacity: 1;
transform: translateY(0);
}
Apply .visible in the next frame after mount:
requestAnimationFrame(() => el.classList.add('visible'));
macOS timing conventions: enter slow (ease-out, ~250ms), exit fast (ease-in, ~200ms), micro-interactions ~100ms.
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
sans-serif;
-webkit-font-smoothing: antialiased;
}
-webkit-font-smoothing: antialiased is the single highest-impact line for matching native text rendering. Without it, text appears heavier than SwiftUI/AppKit on dark backgrounds.
System font shorthands (exact semantic sizing, auto light/dark):