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: none on the pinned container and pointer-events: auto back 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 scrollEnabled in 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-motion by 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(): light for navigation and CTAs, selection for choosing between options, success when a purchase lands, error sparingly on failures. iOS fires nothing on its own inside a webview.
  • Suppress focus rings on tap-driven controls. The :focus-visible heuristics 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% on html, 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?

On this page