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.Keep the edge gutters free. The outer strip on both sides of the screen belongs to the system gesture recogniser, so a horizontally draggable element that starts there will fight it and lose. Inset draggable rows, carousels and sliders from the screen edge, or accept that the first horizontal drag from the edge will navigate instead. Exception: none worth taking — a gesture that competes with the OS loses on every platform, and the failure is silent in desktop testing.
Solve the keyboard with
visualViewport, not with guesses. On iOS the keyboard does not resize the layout viewport, soposition: fixed; bottom: 0composers and submit bars end up behind it. Addinteractive-widget=resizes-contentto the viewport meta, drive the offset fromwindow.visualViewporton itsresizeandscrollevents, and where supported setnavigator.virtualKeyboard.overlaysContent = trueand lay out againstenv(keyboard-inset-height). Scroll the focused field into view yourself; the browser's attempt is frequently wrong inside a scroll container. Exception: a single centred field on a short page — let the browser handle it.Chrome the installed app, or do not install it. A half-configured manifest produces a white flash on launch and a browser-coloured status bar, which reads as a bookmark rather than an app. Set
display: "standalone", abackground_colormatching the app shell, andtheme-colorper colour scheme via<meta name="theme-color" media="(prefers-color-scheme: dark)">. On desktop,display_override: ["window-controls-overlay"]plusenv(titlebar-area-*)lets the title bar become part of the app. Exception: products whose value is shareable links and whose offline story is nothing should staybrowserorminimal-ui— the URL bar is a feature there.Selection off on chrome, on for content. Native apps do not let you drag-select a toolbar label, and a blue smear across your own UI reads as malfunction. Set
user-select: noneon chrome and controls, and re-enableuser-select: texton genuinely copyable content. Exception: never disable selection on anything a user might reasonably want to copy — a code block, an ID, an error message, an address. Selection styling isui-polish.Show the platform's modifier, and bind it. Displaying
Ctrl+Kon a Mac is the kind of detail that instantly reveals a port. Detect the platform once at startup, renderCmdon macOS andCtrlelsewhere, and bind the matching key — checkingmetaKeyon macOS andctrlKeyelsewhere rather than accepting both everywhere, which silently steals a real system shortcut. Exception: do not render shortcut hints at all on a touch-only surface; they are noise where there is no keyboard.Adopt the platform's confirmation and control conventions, not your own. Where the close button sits, whether a destructive choice arrives as a bottom action sheet or a centred dialog, whether a setting takes effect immediately or on submit, whether a long-press or a right-click opens the context menu — each of these has a settled answer per platform, and getting it wrong is felt before it is noticed. Look it up in
references/platform-conventions.mdrather than guessing. Exception: a convention that would break your information architecture is worth overriding once, deliberately, and consistently everywhere.
Smells and Fixes
| Smell | Fix |
|---|---|
| A smooth-scroll library in the dependencies | Delete it; native scroll plus scroll-snap-type and overscroll-behavior |
| Back gesture exits the app while a sheet is open | Push a history entry per dismissible layer; close on popstate |
| Composer hidden behind the keyboard on iOS | visualViewport offset, interactive-widget=resizes-content |
| Draggable row flush to the screen edge | Inset it out of the system gesture gutter |
| White flash on PWA launch | background_color in the manifest matching the app shell |
theme-color declared once, light only |
One per colour scheme with a media attribute |
| Standalone screen with no back control | Add one; there is no browser chrome to fall back on |
Ctrl+K shown on macOS |
Detect once at startup; render Cmd, bind metaKey |
user-select: none on the whole document |
Chrome only; content stays selectable |
| User-agent sniffing to detect "app mode" | matchMedia('(display-mode: standalone)') |
Checklist
- Delivery target detected by
display-mode, not by user agent - Native scrolling everywhere;
overscroll-behavior: containon panels - Every dismissible layer pushes history; the back gesture closes exactly one
- No draggable element sits in the system edge gutter
- Keyboard avoidance driven by
visualViewport; focused field scrolled into view - Manifest sets
display,background_colorand per-schemetheme-color - Standalone builds have an in-app back affordance on every screen
-
user-select: noneon chrome only; content remains copyable - Modifier keys and shortcut hints match the host platform
- Control placement and confirmation style checked against
references/platform-conventions.md - Tested as an installed app on real hardware, not only in a browser tab