Paywall Sheet
A subscription paywall that rises from the bottom edge — plan radio rows, trial framing, restore purchases and the auto-renew fine print under a pinned, safe-area action bar.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/paywall-sheet.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PaywallSheet" component (lucide-react for
Check / LoaderCircle / RotateCcw / X; no portal, no Radix, no vaul — the sheet is
the screen and the gesture layer has to be self-contained).
Contract
- Plan: { id; name; price (already formatted by the consumer — no currency or
locale logic inside the component); period?; unitPrice?; trialDays?; badge?;
note?; recommended? }.
- Props: open: boolean and onOpenChange: (open: boolean) => void (fully
controlled; the panel stays mounted through its exit animation);
plans: Plan[] (an empty array is a first-class state, not a crash);
value? / defaultValue? / onValueChange? for the selected plan id — controlled
and uncontrolled both supported; onPurchase: (planId: string) => void;
onRestore?: () => void (omit it and no restore button is rendered, but the
app stores require one for subscriptions); variant?: "cards" | "segmented" |
"trial" (default "cards"); status?: "idle" | "purchasing" | "restoring" |
"error" (default "idle") and errorMessage?; title?, description?,
highlights?: string[], fineprint?: ReactNode, links?: { label; href }[];
dismissible? (default true); modal? (default true); ctaLabel?, restoreLabel?,
emptyLabel?, planGroupLabel?; className merged with cn() onto the panel;
remaining div props spread onto the panel; forwardRef to the panel.
- The purchase lifecycle is a PROP, never internal state: it belongs to
StoreKit / Play Billing / RevenueCat. The sheet renders it and guards against
double presses; it never pretends to own a transaction.
- Selection is derived, never synced in an effect: selected = (controlled value
?? internal state), and if that id is no longer in `plans` (the offer
reloaded, the store dropped a SKU) it falls back to the plan marked
`recommended`, then to the first one. A CTA aimed at a product that does not
exist any more is the failure this prevents.
- Positioning: the root is `absolute inset-0` inside its nearest positioned
ancestor, NOT a portal to document.body. A paywall is a whole screen and the
consumer already owns that screen — wrap it in a `fixed inset-0` layer for a
real overlay, or render it inside a phone frame / route as-is. Nothing here
locks page scroll or takes over the document.
Behavior
- Rise / settle / exit is one number: `offset`, 0 = resting on the bottom edge,
1 = fully below it, as a fraction of the panel's own height, so it maps
straight to a translate3d percentage and needs no measurement. Entry, spring
back and exit all run through one effect inside a double requestAnimationFrame
(a single frame gets coalesced and nothing animates), with a settle counter in
its deps so every released gesture re-runs it.
- Pull-to-dismiss lives on the grab zone (handle + header) only, which is
`touch-action: none`; the body below it scrolls with `touch-pan-y` and
`overscroll-contain`. Because the two regions are disjoint there is no
scroll-versus-drag arbitration to get wrong. Pointer events only, one
pointerId, setPointerCapture on the node that started the drag, and a press
landing on the close button (data-paywall-no-drag) never becomes a drag.
- A press becomes a drag after 3px, and only if the vertical movement exceeds
the horizontal one; otherwise the gesture is abandoned for good. Release
dismisses on displacement OR velocity: past 35% of the panel height, or over
0.5 px/ms downward. An upward pull is refused (offset clamps at 0). When
dismissible is false the pull is damped to 30% of the excess and capped at 6%
of the height — it moves under the finger and cannot tear off.
- Every gesture has a twin: the close button, the backdrop and Esc all request
the same close, and Esc is handled on the panel's own onKeyDown with
stopPropagation (never a window listener, which cannot tell which layer is on
top). dismissible=false swallows all three and renders no close button.
- Plans are a real radio group: role="radiogroup" with role="radio" +
aria-checked children, roving tabindex (only the selected row is tabbable),
ArrowUp/Down/Left/Right with wrapping, Home/End, and selection follows focus.
With exactly ONE plan there is nothing to choose, so it renders as a static
summary card instead of a radio group with a single option.
- Changing the plan silently changes the price, so the sentence under the CTA
("Free for 7 days, then $39.99 per year. Cancel anytime.") is a polite live
region and the CTA's aria-describedby. It re-derives from the selected plan
and is announced on change, not on mount.
- The CTA label is derived: trialDays above 0 gives "Start N-day free trial", a
plan with a period gives "Subscribe · price", a one-off gives "Unlock ·
price". ctaLabel overrides it.
- Double-tap guard: the press handler compares the event's own timeStamp against
a ref and ignores anything within 600ms, writing that ref back synchronously
in the same handler. This is the button that charges money — state would still
be stale on the second press of a double tap. Restore keeps its own ref.
- status="purchasing" / "restoring" locks the CTA, the restore button and plan
selection with aria-disabled plus a handler guard — never the native disabled
attribute, which blurs the node the user may be standing on and drops focus to
the body. status="error" renders errorMessage in a role="alert" directly above
the CTA, and retrying is allowed.
- Empty offer: plans=[] renders a dashed refusal panel, the CTA reports
aria-disabled, and RESTORE STAYS LIVE — a paying customer must never be locked
out by a failed price lookup.
- Focus: opening moves focus to the panel (tabIndex=-1) so the title and
description are announced, Tab is trapped inside while modal is true
(aria-modal claims the background is unreachable), and closing hands focus
back to the opener only if it is still isConnected. A sheet that was ALREADY
open on its first render is an embed (a preview, a server-rendered upsell
screen) and must not yank focus during hydration.
- The trial timeline is expressed in RELATIVE days derived from trialDays
("Today" / "Day 6" / "Day 7"). The component never reads a clock, so it
renders identically on the server, in a snapshot and at 23:59 in another
timezone.
- prefers-reduced-motion is subscribed through matchMedia (not read once):
transitions become none and the exit unmounts immediately. Dragging,
dismissing, buying and restoring all still work — only the decoration stops.
- Cleanup: the drag rAF is cancelled and pointer capture released at the end of
every gesture and again on unmount; the deferred-unmount timer is cleared on
dependency change; the matchMedia subscription is torn down by
useSyncExternalStore.
Rendering & styling
- Semantic tokens only, monochrome first: panel bg-card / text-card-foreground
with border and shadow-2xl, backdrop bg-background/80 + backdrop-blur-sm,
quiet surfaces bg-muted / text-muted-foreground, handle bg-muted-foreground/40,
focus rings focus-visible:ring-2 ring-ring. The two highest-priority surfaces
INVERT instead of taking a colour: the CTA and the selected chip are
bg-foreground text-background. Colour appears once, for real semantics — the
declined alert is text-destructive on bg-destructive/10 with
border-destructive/40. No hex, no rgb(), no palette class names.
- Radius ladder 2xl / lg / sm: rounded-t-2xl panel, rounded-lg plan rows,
switcher and CTA, rounded badges. The type scale is small and tight: 16px/700
title, 14px/600 plan name, 13px body, 11px meta, prices tabular-nums, and the
segmented headline price text-3xl with tight tracking.
- Layout is three bands: a non-scrolling grab zone, a scrolling body (highlights,
plans, fine print) and a pinned action bar. The legal fine print scrolls UNDER
the action bar, so the price and the button never leave the thumb arc.
- Safe area: the action bar pads with
pb-[max(0.75rem,env(safe-area-inset-bottom))] so the CTA clears the home
indicator, and the panel pads left/right with env(safe-area-inset-left/right)
for landscape on a notched phone.
- Touch targets: 48px CTA, 44px close button, 44px minimum on the switcher chips,
the restore button and the legal links. Nothing depends on hover; every hover
style has a focus-visible twin.
- Long copy wraps, it never truncates — a clipped plan name or price is a lie.
Only the CTA label truncates, and it is derived from copy you control.
Customization levers
- Presentation: `variant` is the whole layout axis — "cards" for a plan stack,
"segmented" for a period switcher over one big price, "trial" for a
relative-day timeline of what the free trial does. All three share the same
radio-group semantics, so switching is a one-word change.
- Density and chrome: drop `highlights` for a short sheet, drop `fineprint` when
the store copy lives elsewhere, drop `links` for a non-store build, omit
`onRestore` for one-off purchases. `max-h-[88%]` on the panel is the single
knob for how tall the sheet gets.
- Modality: dismissible=false turns it into a hard paywall (expired
subscription, region lock); modal=false drops aria-modal and the Tab trap for
an embedded preview or a tablet split view.
- Gesture feel: the 3px arm threshold, the 0.35 dismiss ratio, the 0.5 px/ms
fling threshold, the 300ms settle, the 0.3 / 0.06 rubber band and the 600ms
re-press guard are named constants at the top of the file.
- Copy and i18n: ctaLabel, restoreLabel, emptyLabel, planGroupLabel, title,
description and every plan string are props; the only baked-in strings are the
billing sentence and the timeline steps, both assembled in two small pure
functions you can translate in place.
- Money: prices are strings the consumer formats, so Intl.NumberFormat, store
localised prices and A/B tested pricing all drop in without touching the
component.Concepts
- Thumb-zone action bar — the price and the button that charges it are pinned in the bottom band, padded past the home indicator with
env(safe-area-inset-bottom), while the auto-renew fine print scrolls underneath. On a phone the alternative is the failure everyone has met: scrolling through three screens of legal copy to find the button, or a CTA sitting under the gesture bar. - Price follows selection, out loud — tapping a different plan silently changes what the one big button will charge. The sentence under it is a polite live region and the button's
aria-describedby, so that change is announced instead of being visible only to people looking at the pixels. - Gesture with a twin — pull-down dismisses, and so do the close button, the backdrop and Esc. The pull is scoped to the grab zone, which is
touch-action: none, so it never fights the scrolling body underneath and no arbitration between the two is needed. - Double-tap guard on the money button — one press writes the event's own timestamp into a ref and reads it back synchronously; a second press inside 600ms is dropped. Anywhere else this is polish; on a purchase button it is the difference between one charge and two.
- Restore is the escape hatch — when the store call fails and
plansarrives empty, the sheet still opens, the CTA reportsaria-disabled, and restore stays live. Someone who has already paid must never be locked out by a failed price lookup. - Relative-day trial timeline — "Today · Day 6 · Day 7" is derived from
trialDays, never from a clock read at render. That means the same markup on the server and the client, no hydration drift, and no timezone that turns "you will be charged on day 7" into a lie. - Hard paywall without a jail —
dismissible={false}removes the close button and swallows Esc and the backdrop, but the pull is damped rather than frozen: it still moves a few pixels under the finger, which reads as "this one is not going anywhere" instead of "the app froze".
App Update Sheet
A bottom-edge update prompt with release notes, an installed-versus-available comparison and a download state machine — postponable, or a required gate that refuses to be dismissed.
Filter Sheet
A bottom-edge filter panel with grouped facets, a staged draft, a live result count and an Apply bar padded clear of the home indicator.