Upgrade Prompt
An in-feature paywall — usage note, benefit bullets, a two-tier mini compare, an async upgrade CTA and a "remind me tomorrow" snooze that round-trips through the host, as a banner or as an overlay that blurs only its own box.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/upgrade-prompt.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "UpgradePrompt" component (lucide-react
icons, a shadcn Button and Badge). It is the paywall moment that happens INSIDE
a feature: the allowance ran out mid-task and the app has to say so without
throwing away what the user was doing.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
"children" and "title" (both are redefined).
- variant?: "banner" | "overlay" (default "banner"). banner sits in the flow;
overlay pins the panel over `children` and nothing else.
- children?: ReactNode — the gated feature. Only the overlay variant renders it.
- locked?: boolean (default true) — whether the wall is up. Hosts flip this and
keep the component MOUNTED; conditionally rendering it instead would unmount
the feature and lose the half-typed draft inside it.
- title?, description?, planName?: string.
- usage?: { used: number; limit: number; unit?: string; resetsAt?: string |
number | Date }.
- benefits?: string[], maxBenefits?: number (default 3).
- plans?: Plan[] where Plan = { id, name, price (already formatted by the
caller — never guess a currency), period?, allowance?, current? }.
- upgradeLabel?: string (default `Upgrade to <target plan>`, else "Upgrade
plan"), upgradeHref?: string, onUpgrade?: () => void | Promise<void>.
- dismissible?: boolean (default true), snoozeHours?: number (default 24),
snoozeLabel?: string, snoozedUntil?: string | number | Date | null,
onSnooze?: (until: Date) => void.
- now?: number | Date, locale?: string (default "en-US"),
density?: "auto" | "full" | "compact", overlayBlur?: "none" | "sm" | "md".
- Export a helper `isUpgradePromptSnoozed(snoozedUntil, now = Date.now())` so
the app can gate the feature with exactly the arithmetic the prompt drew.
Behavior — containment (the reason this is not a modal)
- The overlay variant renders `<div class="relative isolate">` with the feature
as a normal child and a scrim as `absolute inset-0 rounded-[inherit]` on top.
No portal, no body scroll lock, no page-wide backdrop: the blur is a
backdrop-filter clipped to the scrim's own box, and `isolate` makes the
feature part of that box's backdrop so it is what gets blurred.
- The feature subtree carries `inert` while the wall is up. That removes it from
the tab order AND from the accessibility tree in one attribute — a paywall the
keyboard can walk straight through is not a paywall.
- `inert` strands any caret that was inside it, so on the false -> true edge, if
document.activeElement is inside the wrapper but not inside the panel, move
focus to the panel (tabIndex={-1}). Do this on the EDGE only; on mount the
check can never match, so it can never steal focus from elsewhere.
- The panel is `role="region"` with aria-labelledby/aria-describedby, NOT
role="dialog": there is no focus trap and no Escape contract, and claiming
dialog semantics without them is a lie. Escape deliberately does nothing —
"remind me later" is a commitment the host has to persist, so it must be an
explicit press.
- Panel is `max-h-full` with ITS OWN overflow-y-auto; the scrim stays
overflow-hidden. A centred flex item inside a scroll container clips its own
top once it overflows.
Behavior — snooze as a round trip
- Pressing snooze computes `until = effective now + snoozeHours` and calls
onSnooze(until). Hand the host an ABSOLUTE INSTANT, never a duration: that is
what goes in a database row and it survives the tab being closed for a week.
- The local hide is a BOOLEAN, not a stored deadline — the component owns no
timer, so a deadline it stored itself could never expire on screen. It holds
for the session and is optimistic.
- `snoozedUntil` is the host's answer. Track the last value seen and, whenever
it CHANGES, drop the optimistic boolean — in both directions: a persisted
snooze keeps the prompt away, and clearing it brings the prompt straight back
without a remount. Do this as a render-phase comparison, not an effect.
- Visible = locked && !dismissedLocally && !(snoozedUntil is in the future).
- Clock unknown (see below) + a snoozedUntil present => treat as snoozed. One
frame of a missing paywall beats one frame of a paywall the user waved away.
Behavior — one checkout at a time
- onUpgrade may be sync or async. Detect a thenable; a synchronous handler has
already done its job (it usually navigated) so no pending state is entered.
- While a promise is in flight: `pending` swaps the CTA label for "Opening
checkout…" plus a spinner, and BOTH buttons take aria-disabled (not the native
disabled attribute, which would throw focus away). A second click is dropped.
- Guard the settle with a monotonic run id and an alive ref, so a resolve that
lands after unmount or after a newer run cannot write state.
- A throw or a rejection sets one muted failure line that says what did NOT
happen — "Couldn't start the upgrade. Nothing was charged — try again." A
half-charged account is the one thing a paywall must never imply.
- upgradeHref renders the CTA as `<Button asChild><a href=…>`. A link cannot be
pending, so the whole async lock is skipped and onUpgrade only records the
click. With neither a handler nor an href the CTA renders natively disabled —
a dead primary button is a config error, not a decoration.
Behavior — reading the numbers, and time
- ratio = used / limit; the padlock icon, the destructive accent and the
destructive meter fill appear only when used >= limit. Below that it is a
NUDGE: sparkle icon, primary meter, same layout. Same component, two moods.
- Round percentages honestly: 99.6% prints as 99%, never 100%; anything above 0
prints at least 1%. Show "over by N" when used exceeds limit, and clamp the
meter fill at 100%.
- The meter is role="meter" with aria-valuemin/max/now and an aria-valuetext
that spells out the sentence. role="meter" is children-presentational, so the
numeric line lives as a SIBLING of it, never inside.
- Relative time ("Resets in 3 days", Intl.RelativeTimeFormat with numeric:
"auto") is CLIENT-ONLY unless `now` is injected: read Date.now() in an effect
and render nothing until it lands. A statically rendered page carries a
build-time clock, and hydrating "resets in 3 days" from last Tuesday is worse
than showing it a frame late.
Behavior — density and announcements
- density="auto" observes the component's own box with a ResizeObserver and
switches to compact below ~320px wide, or below ~300px tall for the overlay
(an overlay has to fit the feature it covers). Compact drops the description,
the benefits and the tier compare and keeps the numbers and the buttons.
Bail out of setState when the rounded box is unchanged, and disconnect the
observer on unmount.
- The benefits list shows `maxBenefits` and collapses the rest behind a real
<button aria-expanded aria-controls> — not a decorative "+2 more".
- One sr-only role="status" region, mounted even while the prompt is hidden so
the wall that appears mid-session announces into a region that already
existed. Announce on the hidden -> visible EDGE only, and never on first
paint: a prompt that was already there when the page loaded is read as part
of the page. The usage number moves on every request, so it must never be
able to fire an announcement on its own. Failures always announce.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted, bg-muted/40, bg-background/60,
border, border-border, border-primary/40, bg-primary/5, bg-primary/10,
text-primary, bg-destructive/10, text-destructive, text-muted-foreground,
ring. No hex, no rgb(), no oklch().
- Merge every className through cn(). Spread the remaining props onto the root
and expose data-variant / data-state ("locked" | "snoozed" | "unlocked") /
data-density for host styling.
- The banner is a rounded bordered card; when it is hidden it collapses to an
sr-only shell that still carries the live region.
- Entrance is one hoisted keyframe (React 19 `<style href precedence>`, deduped
across instances): 6px rise + fade, 260ms. It is decorative, so it is
motion-reduce:[animation:none], and the meter's width transition is
motion-reduce:transition-none. Nothing about the paywall depends on motion.
- Focus-visible rings on the disclosure button and on the panel itself.
Customization levers
- Which blocks appear: omit `usage` for a pure feature gate, omit `plans` to
drop the tier compare, omit `benefits` for the smallest honest paywall. Every
block is independent.
- Wall vs nudge: the icon/accent split is driven only by used >= limit. Feed it
a below-limit usage to get the same layout as a friendly heads-up.
- Density thresholds (320 / 300) and the maxBenefits cap: raise them for a
chattier panel, lower them for a tighter one, or pin density="full" |
"compact" and delete the ResizeObserver entirely.
- Snooze span: snoozeHours drives both the emitted instant and the default
label ("in an hour" / "in N hours" / "tomorrow" / "in N days" / "next week"),
so the button can never promise something different from what it does.
dismissible={false} removes the exit for a hard gate.
- Blur strength: overlayBlur "none" | "sm" | "md" — "none" swaps the blur for a
more opaque scrim, which is the right call when the content behind is
sensitive rather than teasing.
- Copy: title / description / upgradeLabel / snoozeLabel are all overridable,
and `locale` drives both the number formatting and the reset wording.
- The plan cells are one small JSX branch — add a per-tier badge, a discount
line, or a third column, keeping min-w-0 + truncate so a long tier name
cannot widen the panel.Concepts
- Scoped overlay — the wall is a scrim inside the component's own isolated stacking context, so the backdrop blur can only reach the pixels of the feature being gated; nothing is portalled, the page never scroll-locks, and two gated panels on one screen do not fight over the body.
- Inert handoff — the gated subtree takes the
inertattribute rather than apointer-events-nonefake, which drops it out of both the tab order and the accessibility tree; because that strands any caret inside it, focus is moved to the panel on the rising edge only. - Snooze round trip — "remind me tomorrow" is a promise no component can keep, so it emits an absolute instant, hides itself optimistically for the session, and hands the truth over the moment the host echoes that instant back through
snoozedUntil— clearing the value brings the wall straight back with no remount. - One-shot checkout — the upgrade handler is locked while its promise is in flight, guarded by a run id and an alive ref so a late resolve cannot write state, and a rejection states what did not happen rather than leaving the user wondering whether a card was charged.
- Wall versus nudge — the same panel renders a padlock and a destructive meter only when the allowance is genuinely gone; below the limit it is a sparkle and a primary meter, and 99.6% is printed as 99% so the reader is never told they are out when they are not.
- Self-measuring density — the panel observes the box it is sitting in and drops its optional blocks when that box is too small, because an overlay that overflows the feature it covers has escaped the very containment that makes it an overlay.
OCR Viewer
A scanned page beside the text read off it, kept in sync both ways — hover or focus a line to light up its source box, click a box to focus its line, with low-confidence words underlined in place.
Usage Alerts
A budget alert-rule manager — threshold tripwires on a scale that runs past 100%, per-rule channels, a one-shot test send with a real in-flight state, and a fired-alert history waiting to be acknowledged, in four contract-driven states.