AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, Check, ChevronDown, Clock3, LoaderCircle, Lock, Sparkles, TriangleAlert } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Upgrade Prompt
 *
 * 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. It answers the four questions a person actually has at that instant —

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/upgrade-prompt.json

Prompt

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 inert attribute rather than a pointer-events-none fake, 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.

On This Page