A browser gives your page a set of defaults, and every one of them tells the user they have left the app — bouncing scroll, blue tap flashes, pinch zoom, a long-press menu. None of it is something the host can switch off for you. This is the page-level work that makes a wrap feel native.
<web-view>. Native mini-app pages have no viewport meta, no browser chrome and no bounce, so none of it applies there — their styling is .ftss.Shoppingate draws the title bar and the back control. A header of your own puts a second title on the same screen, and the user ends up with two ways back that behave differently. Set the title in the shell's app.json and let the host render it.
<meta name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">maximum-scale=1 and user-scalable=no stop pinch zoom; viewport-fit=cover lets your page reach under the notch, which you then handle with safe-area insets. touch-action: manipulation kills the double-tap zoom delay.
iOS honours pinch gestures regardless of the meta tag, so the JavaScript below is not belt-and-braces — it is the part that actually works on iPhone.
// The meta tag and touch-action cover most of it. iOS still honours pinch gestures and
// the long-press menu, and both read as "this is a browser" the moment someone hits them.
["gesturestart", "gesturechange"].forEach(function (e) {
document.addEventListener(e, function (ev) { ev.preventDefault(); }, { passive: false });
});
document.addEventListener("contextmenu", function (e) { e.preventDefault(); });Rubber-banding the whole document is the single most website-ish thing a web-view page does. Pin the document and let an inner container scroll instead.
/* The app cannot do any of this for you: a page gets the browser's defaults unless it
opts out, and every one of those defaults tells the user they left the app. */
html {
touch-action: manipulation; /* kills double-tap zoom */
-webkit-text-size-adjust: 100%;
}
/* Pin the document and let an inner container scroll. This is what stops the whole page
rubber-banding, which is the single most website-ish thing a web-view page does. */
html, body {
height: 100%; width: 100%;
overflow: hidden;
position: fixed;
margin: 0;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
}
#app {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: none;
/* Clear of the home indicator. */
padding-bottom: calc(2rem + env(safe-area-inset-bottom));
}
/* ...but anything worth copying stays selectable. Order numbers, reference ids, errors. */
input, textarea, [contenteditable], .selectable {
user-select: text;
}
img { -webkit-user-drag: none; }
::-webkit-scrollbar { display: none; }
/* A phone has no hover, so the pressed state is the only feedback before the result
arrives. 44px is the minimum touch target. */
button {
min-height: 44px;
transition: transform .08s ease;
}
button:active { transform: scale(.985); }
@media (prefers-reduced-motion: reduce) {
button, button:active { transition: none; transform: none; }
}Give any inner scrolling region — a long code block, a table — overscroll-behavior: contain, so reaching its end does not start scrolling the page behind it.
user-select: none is what makes a page feel like an app, but it also makes order numbers, reference ids and error messages impossible to copy. Opt those back in. People need to paste them into a support chat.env(safe-area-inset-bottom) keeps content clear of the home indicator. Design between 320px and 430px wide, and make sure nothing scrolls sideways anywhere in that range.
Your page renders in the viewer's theme, which you do not choose. Define a complete token set for light, then redefine only the tokens for dark.
/* Both themes, as a complete token set each. Define every colour on bare :root, then
redefine ONLY the tokens for dark — a colour whose single definition sits inside the
media query simply never applies in the other theme.
:root is correct in a web page. Inside a mini-app's own .ftss the root element is
`page`, and tokens on :root there match nothing at all. */
:root {
--bg: #f5f5f7;
--surface: #ffffff;
--text: #111111;
--muted: #6e6e73;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--surface: #1c1c1e;
--text: #f2f2f2;
--muted: #9b9ba1;
}
}
body {
/* Explicit, always. The host paints its own ground behind the page, so a transparent
body borrows the host's theme and you get one theme's text on the other's background. */
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Noto Sans Arabic", sans-serif;
}Two failure modes worth naming. A colour whose only definition sits inside the dark media query never applies in light — that is the classic unreadable page. And a body with no explicit background borrows the host's ground, so you get one theme's text on the other theme's background.
Set dir="rtl" and use logical properties throughout. A layout built on margin-left needs a second stylesheet to mirror; one built on margin-inline-start mirrors itself.
/* Logical properties, so Arabic mirrors without a second stylesheet. */
.card {
padding-inline-start: 1rem; /* not padding-left */
padding-inline-end: 1rem;
margin-inline-start: auto;
}Include "Noto Sans Arabic" in your font stack, and check the result with real Arabic copy — logical properties mean it can mirror, not that it does.
Touch targets at least 44×44px. Give every control a pressed state: a phone has no hover, so the press is the only feedback available before the result arrives, and without it people tap twice. Never put behaviour behind :hover alone.
Size type in rem so the system text-size setting is honoured, keep inputs at 16px or larger to stop iOS zooming on focus, and give keyboard and switch-control users a visible :focus-visible state.
Fifteen things, most of them a line of CSS:
Most of these are statically checkable — whether the viewport meta is right, whether a 44px minimum is set, whether both themes are defined. A handful are not, and those need a real device: horizontal scroll across the width range, Arabic with real copy, and whether every tap actually feels instant.