Styling
How styling works in a paywall, plain CSS, the color-scheme class, the background color, insets and safe areas, and the platform stylesheet Superwall applies at serve time.
A paywall is styled with ordinary CSS. The framework ships no component library, no theme, and no opinions about how your paywall should look. Your stylesheet is the whole story.
What the framework does provide is a small set of mechanisms your CSS can rely on: a color-scheme class that follows the device, a background color that reaches the native SDK, insets that know which screen and which presentation the paywall is in, and a platform stylesheet applied at serve time.
Plain CSS
Import a stylesheet from a route and write whatever you like:
import "./theme.css";Tailwind works, CSS modules work, and a single hand-written theme.css works. Nothing is injected into your styles, and nothing of yours is overridden.
Color scheme
The device decides light or dark, and the framework maintains a dark/light class on <html>. Style off that class 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 reads the browser's setting; the class reflects what the device reports through the SDK, which is the real interface style, and it's what the studio's theme toggle drives. A paywall styled on the media query looks right on your machine and wrong on the device.
Using Tailwind? Point the dark: variant at the class so it follows the SDK:
@custom-variant dark (&:where(.dark, .dark *));Read the current scheme in JavaScript with useColorScheme().
Background color
background in config sets the paywall's background, in light and dark:
background: { light: "#ffffff", dark: "#0d0f12" }One value covers both sides of the load: it paints the page background, and it's sent to the native SDK, which paints the same color behind the webview and derives its loading spinner from it. Set it to whatever your page background is, and a native paywall has no flash of a different color while it loads. On the web the document paints the light color until React mounts, so a dark-mode visitor to a web funnel can still see one.
Pages need --sw-background too
background covers the load: the document before React mounts, and the native color behind the webview. It does not paint the pages themselves. Every route is opaque so that a page sliding in never shows the stack through it, and each one paints --sw-background, which defaults to the system Canvas color.
Set both, to the same color, or your configured background is covered by system white or black on every route:
:root {
--bg: #fdfef6;
--sw-background: var(--bg); /* what the pages paint */
}
:root.dark { --bg: #1c1b19; }Every example project and the superwall create scaffold set it this way, next to --sw-page-inset-bottom: 0px, which is there because their layout ends in a "Built with" footer below the pages (see how the paywall is laid out); remove that line along with the footer. Set --sw-background: transparent only when the paywall is meant to show what is behind it.
Insets
A paywall is inset from the screen edges by default. Everything a layout or a page renders starts clear of the status bar, the camera cutout, the home indicator and, on a rotated phone, the cutout's side, without a line of CSS: the framework pads the element that wraps the whole paywall by the safe area, so a page writes padding: 16px 20px and a footer at the bottom of a layout sits above the home indicator on its own. Pages still scroll edge to edge: each page's scroll container reaches back out under the insets and carries them as its scroll padding, the way a native scroll view runs under the bars with a content inset, so content passes beneath the status bar and the home indicator and comes to rest clear of them.
insets in config chooses what that padding is:
insets: "safe-area" // the default
insets: "none" // flush to every edge — a full-bleed paywall
insets: 24 // pixels, all four edges
insets: { top: "none", bottom: "safe-area" } // per edge; an omitted edge stays "safe-area"Whatever it says, the framework keeps the safe area itself available as --sw-safe-area-inset-top|right|bottom|left, for the one kind of element that means to sit over the bars: chrome over a bleed, positioned absolutely in the layout.
/* a close button in layout.tsx over a hero that runs under the status bar (insets: { top: "none" }) */
.close { position: absolute; top: calc(var(--sw-safe-area-inset-top) + 8px); right: 16px; }
/* a CTA pinned over scrolling content, when insets are "none" at the bottom */
.footer { position: sticky; bottom: 0; padding-bottom: calc(var(--sw-safe-area-inset-bottom) + 12px); }Chrome inside the inset area needs none of this: a close button in layout.tsx is position: absolute; top: 4px; right: 16px, and it is already below the bar, because the framework's content box is the containing block layout chrome positions against. Turn an edge off only for something that must rest under the bar, such as a hero whose top edge is the screen edge; never to fix scrolling or a cut-off footer, since pages already scroll under the insets.
How the safe area is resolved
The safe area comes from two sources:
- The host's own
env(safe-area-inset-*), wherever the webview reports one. On an iOS device that is the exact value for the phone in hand. - A floor for the screen the host said it is, wherever it reports nothing. Previews report 0, so does Android WebView, and so do some webview contexts, which is exactly where a bare
env()puts a close button in the status bar. The floor is a minimum for the class of device, so the two are combined withmax()and a real value is never shrunk.
The floors follow what the SDK tells the paywall about the device and what config.ts says about the presentation:
| Host | Top | Bottom | Sides |
|---|---|---|---|
| iPhone with a Dynamic Island | 59px | 34px | 0 |
| iPhone with a notch | 44px | 34px | 0 |
| iPhone with a home button | 20px | 0 | 0 |
| iPhone, landscape | 0 | 21px | 59px / 44px, both sides |
| iPad | 24px | 20px | 0 |
| Android | 24px | 0, the SDK keeps the webview above the navigation bar | 0 |
presentation.style modal or drawer | 0, the sheet starts below the status bar | as above on a phone, 0 on iPad where the sheet floats | 0 |
presentation.style popup | 0 | 0 | 0 |
| Web | 0, the browser owns its chrome; env() still applies in a standalone web app | 0 | 0 |
How the paywall is laid out
The router renders this around your layout and pages:
[data-sw-root] flex column, min-height: 100dvh, padding: the insets, paints --sw-background
└ [data-sw-content] position: relative, flex column — what absolute chrome positions against
└ <Layout> your layout.tsx, a flex child
└ [data-sw-routes] the page stack, flex: 1 1 auto; negative margins pull it back out under the insets
└ [data-sw-route] one per page: position: absolute; inset: 0; padding: the insets; overflow: auto — the edge-to-edge scroll container
└ <Page />What follows from it:
- Nothing of yours is viewport-sized. The root is. A layout's shell is
flex: 1 1 auto, notmin-height: 100dvh; a shell that insists on the full viewport height overflows the insets and scrolls. A page fills its route withmin-height: 100%. - Pages scroll; the document doesn't. The scroll container is the page's route, so
window.scrollTodoes nothing; scroll the route or usescrollIntoView. Each page keeps its own scroll position when covered. position: absolutein a layout resolves against the content box, which starts after the insets:top: 4pxis 4px below the status bar with no positioned ancestor of your own.position: sticky; bottom: 0as a page's last child pins a CTA above the home indicator, because sticky honours the scroll container's padding, while content scrolls under it to the screen edge.top: 0sticks a header below the status bar the same way.- A layout footer below the pages (a flex child after
{children}) needs:root { --sw-page-inset-bottom: 0px; }so the pages stop at it instead of reaching under it. position: fixedhas no place in a paywall. It resolves against the viewport, ignoring the insets, and during a page transition the moving page becomes its containing block, so fixed chrome inside a page rides along and snaps when the animation ends. Chrome goes in the layout (absolute) or in the page (sticky).
The variables
insets in config is the front door; underneath it are three layers of CSS variables, each yours to redefine on :root when a stylesheet needs more than the config can say. The framework's own declarations have the specificity of a bare :root and land before your stylesheet, so yours always win:
| Variable | What it is |
|---|---|
--sw-inset-* | What the paywall is padded with. insets in config writes these; the default is the safe area. |
--sw-page-inset-* | How far each page's scroll container reaches back under the insets, and its scroll padding. Equal to --sw-inset-*; set an edge to 0px when the layout puts something of its own between the pages and that edge. |
--sw-bleed, --sw-page-bleed | The four negative insets in inset: order, for an overlay that must cover the bars: position: absolute; inset: var(--sw-bleed) in a layout, var(--sw-page-bleed) inside a page's positioned wrapper. |
--sw-safe-area-inset-* | The safe area: max() of the host's env() and the floor. What chrome over a bleed reads. The same names the visual editor's paywalls use. |
--sw-safe-area-floor-* | The floor for the host the paywall is in, from the table above. Raise one to teach the framework about a device while keeping env() live. |
:root[data-sw-platform="android"] { --sw-inset-top: 32px; } /* one platform's padding */
:root { --sw-safe-area-floor-top: 62px; } /* a taller floor, env() still live */
:root { --sw-inset-left: max(var(--sw-safe-area-inset-left), 20px); } /* a gutter that grows in landscape */Overrides go on :root, not on a descendant: the variables are resolved on the root element, so a floor set on .shell would not reach --sw-inset-top. A value set by insets in config is written inline on <html> and beats a stylesheet's :root rule; use one or the other per edge.
What the framework stamps on <html>
The floors key on attributes the framework maintains on the root element, and your CSS can key on the same ones:
| Attribute | Values | From |
|---|---|---|
data-sw-platform | ios, android, web | the device the host reports, live |
data-sw-idiom | phone, tablet, desktop | the device the host reports, live |
data-sw-cutout | none, notch, island | the iPhone model the host reports, live; absent on anything else |
data-sw-presentation | fullscreen, modal, drawer, popup | presentation.style in config.ts, at build |
An attribute the host has said nothing about is absent rather than guessed, and the studio sets them the way a device would, so switching devices there moves the insets with it.
:root[data-sw-platform="android"] .cta { border-radius: 999px; }
:root[data-sw-idiom="tablet"] .page { max-width: 34rem; }Scrolling
Scroll behavior belongs to the platform. scrollEnabled in config turns page scrolling on or off, and the platform stylesheet implements it, so let the page itself scroll rather than building nested scroll containers.
Fonts
The system font stack is what makes a webview read as native. When a design calls for brand type, a relative-path @font-face is the entire setup. See custom fonts in Assets.
The platform stylesheet
Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands before your styles in the cascade, so your ordinary declarations win. A few platform rules are !important, box-sizing and cursor among them, and need !important of your own to override. Previews apply the same stylesheet, which is what makes local and published render identically.
Set SUPERWALL_RUNTIME_URL in the project .env only if you need previews to use a local build of that platform layer.
Check it in the studio
The studio renders a paywall at device sizes with the controls a device would supply, a theme toggle for both color schemes, presets from phone through desktop (or any width in responsive mode), and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own.
How is this guide?