native-feel
Passing as a Native App
What gives a web app away is almost never how it looks. It is that the scroll does not carry momentum, the back gesture leaves the app instead of closing the sheet, the keyboard covers the field being typed into, and the primary action sits where the platform puts something else. Fix behaviour before appearance: match the platform's gesture ownership, its latency, and its control conventions, and let the visual language stay your own. The default posture is to give the operating system back everything it already owns — scrolling, the back gesture, the keyboard, the text-selection callout — and to spend your effort only where the web genuinely has no answer. touch-input owns the mechanics of contact — target size, hover gating, tap latency — and gestures owns the physics once a finger is moving; this skill owns only whether the result reads as installed. Notch and home-indicator insets are responsive.
Detect the delivery target before writing any of this. A tab in a browser, an installed PWA, a Capacitor or Tauri shell and an Electron window each get different answers, and shipping standalone-only behaviour to a browser tab produces an app with no way back. Read the manifest, check for display-mode: standalone at runtime rather than sniffing the user agent, and see whether the project already has a platform module — if there is a usePlatform() or a Capacitor.getPlatform() in the codebase, extend it instead of adding a second detection path beside it.
| Topic | Reference |
|---|---|
| A control or gesture that differs per platform | Open references/platform-conventions.md when you need the concrete iOS / Android / macOS / Windows expectation for a control, a modifier key, a confirmation, or a gesture. |
Core Principles
-
Mimic behaviour, never chrome. An iOS-shaped switch rendered on Android is uncanny, and a hand-drawn copy of a system font is worse than the system font. Use
font-family: system-ui(with-apple-systemfirst for older Safari) and let each platform supply its own letterforms, then match the platform's timing, gesture ownership and control placement. Exception: a product with a strong cross-platform identity — a design tool, a game, an editor — should look like itself everywhere; then commit fully and match only the gestures. -
Never hijack the scroll. Smooth-scroll and parallax libraries replace the compositor's momentum with per-frame maths, and the result desyncs from the finger, breaks the OS edge-swipe, and disables the scrollbar's own affordances. Use native scrolling with
overscroll-behavior: containon panels andscroll-snap-typefor paging. Exception: a marketing page whose scroll is the content —marketing-pagesexplicitly permits what product UI does not. -
The platform back affordance closes exactly one layer. Android's back gesture and iOS's edge swipe must dismiss the topmost sheet, drawer or modal rather than leaving the app. Push a history entry when a dismissible layer opens and call
history.back()to close it, so the OS gesture, your close button and the browser back button all take the same path. Indisplay: standalonethere is no browser back at all, so a screen with no in-app back control is a dead end. Exception: a destructive-confirm dialog should consume the back event without navigating. The app's history graph and URL-as-state arenavigation's.