Styling & Mobile Design
Dark mode, safe areas, scroll behavior, motion, and touch — the platform conventions that make a paywall feel native inside a webview.
Paywalls render inside a native webview on a phone. Two things decide whether one feels native: reproducing your design exactly, and following the platform conventions — Apple's HIG and their Android equivalents — that users feel but never name. This page collects the conventions; treat them as working practices, with your design reference always winning over any rule here.
The design is the contract
- Build 1. Spacing, sizing, weights, colors, and effects come from the design, not from habit. Measure the design at logical points — a screenshot at device width — instead of eyeballing, and compare your build against it side by side before calling it done.
- Add nothing the design doesn't show. No extra links, badges, footnotes, or affordances, however well-intentioned. If something seems missing — a restore button, a legal link — raise it with your designer rather than quietly adding it.
- Effects are design decisions, not defaults. Shadows, gradients, borders, blurs, and radii belong to the design system of the paywall you're building. If the design is flat, build flat; if it's soft and elevated, match that.
Dark mode
The device decides, and the framework maintains a dark/light class on <html>. Style with plain CSS and write no wiring:
:root { --bg: #fdfef6; --fg: #0c0b0a; }
:root.dark { --bg: #1c1b19; --fg: #fdfef6; }Don't use @media (prefers-color-scheme: dark) as the mechanism. The media query can't see what the device reports through the SDK and doesn't respond to the studio's theme toggle — a paywall styled that way looks right on your machine and wrong on the device. The :root.dark class is the mechanism.
Using Tailwind? Redefine the dark: variant onto the class so it follows the SDK instead of the media query:
@custom-variant dark (&:where(.dark, .dark *));The Tailwind example shows the full setup — see Examples. Design both palettes even when the reference shows only one, and check both in the studio.
Safe areas
env(safe-area-inset-*) resolves to 0 in previews and some webview contexts, so bare env() math puts controls in the status bar or under the home indicator the moment insets go missing. Always wrap in max() with a floor:
/* fixed top chrome (close button): clears the status bar even with no env */
top: max(calc(env(safe-area-inset-top, 0px) + 10px), 60px);
/* pinned bottom chrome: clears the home indicator */
padding-bottom: max(calc(env(safe-area-inset-bottom, 0px) + 14px), 28px);Around 60px is a sensible top floor and 28px a bottom floor — adjust the numbers to your design, keep the pattern. Fixed elements (close button, CTA bar) need the inset math; scrolling content instead needs enough bottom padding to clear whatever is pinned over it.
Scrollable content
- Long content scrolls under pinned bottom chrome. Give the pinned footer a gradient — transparent to page background — so content fades out behind it instead of clipping to a hard edge.
- Put
pointer-events: noneon the pinned container andpointer-events: autoback on its interactive children, so the fade region doesn't swallow scroll gestures. - Give the scroll content bottom padding of roughly the footer height plus the safe area, so the last row can scroll clear of the fade.
- Let the page itself scroll; don't invent nested scroll areas. The platform — and
scrollEnabledin config — owns scroll behavior.
Motion
- Animate functional movement only — elements that physically travel between states: a segmented-control thumb sliding, a sheet presenting, a progress bar filling. Content that merely changes — text, list rows, a price — updates in place; it doesn't fade, slide, or stagger unless the design explicitly calls for it.
- Press feedback is the baseline interaction: a scale-down active state (around 0.96, fast in at ~80ms, settling out at ~200ms) on tappable elements, paired with a haptic. For most controls, that's the whole story.
- Entry animations are opt-in per design — and when a design has one, it gates on presentation, never mount, because paywalls are preloaded hidden. See Lifecycle & events.
- Honor
prefers-reduced-motionby collapsing durations to ~1ms.
Touch
- Tap targets are at least 44×44pt. A visually shorter control — a slim segmented control — can trade height when the design demands it, but width and spacing must compensate.
- Haptics on every meaningful tap, via
useHaptics():lightfor navigation and CTAs,selectionfor choosing between options,successwhen a purchase lands,errorsparingly on failures. iOS fires nothing on its own inside a webview. - Suppress focus rings on tap-driven controls. The
:focus-visibleheuristics misfire in webviews and previews, drawing outlines the design never asked for. Keep keyboard focus styles only where a keyboard is real, like web checkout pages. - On controls:
-webkit-tap-highlight-color: transparent,touch-action: manipulation,user-select: none. - Icon-only buttons carry an
aria-label; every control stays reachable.
Type and rendering
- Default to the system font stack —
-apple-system, BlinkMacSystemFont, …— unless the design specifies brand type. It's what makes a webview read as native iOS. (When the design calls for brand type, see custom fonts in Assets.) - Set
-webkit-text-size-adjust: 100%onhtml, use antialiased smoothing, and keep body copy around 17px to match iOS body text.
Verify like a device
In the studio, before calling any paywall done:
- Both color schemes.
- The smallest supported width — 320px — through tablet.
- Every page in the flow.
- The trial-eligibility toggle, where relevant.
- Nothing overflows horizontally at any size.
How is this guide?