# Native Feel

> Use when web UI must pass as a native app: momentum scroll, keyboard avoidance, back gesture, platform control conventions, status bar, and PWA chrome.

- Skill: `agentsorg/native-feel` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add agentsorg/native-feel`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentsorg/native-feel/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: agentsorg (https://skillmd.com/u/agentsorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentsorg/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

1. **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-system` first 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.

2. **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: contain` on panels and `scroll-snap-type` for paging. *Exception:* a marketing page whose scroll *is* the content — `marketing-pages` explicitly permits what product UI does not.

3. **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. In `display: standalone` there 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 are `navigation`'s.

4. **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.

5. **Solve the keyboard with `visualViewport`, not with guesses.** On iOS the keyboard does not resize the layout viewport, so `position: fixed; bottom: 0` composers and submit bars end up behind it. Add `interactive-widget=resizes-content` to the viewport meta, drive the offset from `window.visualViewport` on its `resize` and `scroll` events, and where supported set `navigator.virtualKeyboard.overlaysContent = true` and lay out against `env(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.

6. **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"`, a `background_color` matching the app shell, and `theme-color` per colour scheme via `<meta name="theme-color" media="(prefers-color-scheme: dark)">`. On desktop, `display_override: ["window-controls-overlay"]` plus `env(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 stay `browser` or `minimal-ui` — the URL bar is a feature there.

7. **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: none` on chrome and controls, and re-enable `user-select: text` on 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* is `ui-polish`.

8. **Show the platform's modifier, and bind it.** Displaying `Ctrl+K` on a Mac is the kind of detail that instantly reveals a port. Detect the platform once at startup, render `Cmd` on macOS and `Ctrl` elsewhere, and bind the matching key — checking `metaKey` on macOS and `ctrlKey` elsewhere 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.

9. **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.md` rather 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: contain` on 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_color` and per-scheme `theme-color`
- [ ] Standalone builds have an in-app back affordance on every screen
- [ ] `user-select: none` on 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

