Mobile

Rating Prompt

An in-app review request that rises from the bottom edge, forks a happy answer to the store and an unhappy one into a private message box, and refuses to render once the remembered outcome says it has already asked.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, CircleAlert, LoaderCircle, MessageSquare, Star, X } from "lucide-react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"

/* -------------------------------------------------------------------------- *
 * Rating Prompt — the in-app "enjoying this?" moment.
 *
 * Three things make it a component instead of a dialog with copy in it:
 *
 * 1. **The fork.** The first question is a gate, not a rating. A happy answer

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "RatingPrompt" component: the in-app
"enjoying this?" moment, done so it cannot collect a one-star review. React +
lucide-react only — no gesture library, no animation library, no portal.

Contract
- "use client". forwardRef<HTMLDivElement, RatingPromptProps> extending
  React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root
  (which is the overlay wrapper for the edge-anchored variants, and the card
  itself for the inline one).
- Exported types: RatingPromptVariant = "sheet" | "banner" | "card";
  RatingPromptStep = "ask" | "rate" | "vent" | "thanks";
  RatingPromptOutcome = "rated" | "feedback" | "later" | "never";
  RatingPromptMemory = { outcome: RatingPromptOutcome; at: number };
  RatingPromptDecision = { outcome: RatingPromptOutcome; comment?: string }.
- Props:
  - variant = "sheet". "sheet" = modal, rises from the bottom edge over a scrim.
    "banner" = a floating card above the tab bar, not modal. "card" = inline in
    a list, no overlay at all. Same state machine in all three.
  - open / defaultOpen = false / onOpenChange — the controlled + uncontrolled
    triad. A controlled consumer that refuses a close request must leave the
    panel exactly where it was, never parked half off-screen.
  - defaultStep = "ask" — where a fresh round starts. "rate" or "vent" skips the
    fork gate when you already know how this user feels.
  - appName = "this app", storeName = "App Store" — substituted into every
    label at the {app} and {store} placeholders.
  - memory?: RatingPromptMemory | null, now?: number (epoch ms),
    snoozeDays = 30 — the never-ask-again input. See "The memory" below.
  - onRate?: () => void | Promise<unknown> — open the store URL or call the
    native review API here. onFeedback?: (comment: string) => void |
    Promise<unknown> — receives the trimmed message. Both may reject.
  - onDecision?: (decision: RatingPromptDecision) => void — fires exactly once
    per round with what to remember. It carries NO timestamp: the host stamps it
    with its own clock when it persists it, so nothing in render depends on a
    wall clock.
  - allowNever = true (show the permanent opt-out), commentMaxLength = 400,
    thanksDuration = 2000 (0 keeps the receipt up), labels?: Partial<Labels> for
    i18n (one flat object holding every string the component can render;
    nothing else in it is prose).
- Also export a pure policy function
  shouldAskForRating(memory, { now, snoozeDays }) so the host can answer the same
  question before it even mounts the prompt.

The memory — the reason this is a component and not a dialog
- Store review APIs are rationed (iOS allows three prompts per year) and "don't
  ask again" has to be permanent. So the remembered outcome is an INPUT: when it
  says this user is done being asked, the component renders null and `open`
  cannot override it. The guarantee lives in the contract instead of in a
  callback someone forgets to write.
- Read the memory when a round STARTS, never on every render. Hosts store the
  decision the moment onDecision fires, and a live re-read would make the
  receipt vanish under the user's thumb one frame after they answered.
- Policy: no memory -> ask. "rated" / "never" -> never again (someone who rated
  has nothing left to give, and an opt-out that expires is a lie). "later" /
  "feedback" -> ask again once snoozeDays have passed. `now` is injected, not
  read off a clock; without it only the permanent outcomes suppress, because a
  snooze cannot be measured without an instant. A clock that went backwards
  (now earlier than the stored instant) stays quiet rather than reading the
  negative gap as an expired snooze.

Behavior — the fork
- step "ask" is a GATE, not a rating: two equal buttons ("Not really" /
  "Loving it"), each its own full-height touch target. Loving it -> step "rate";
  not really -> step "vent". Nothing is reported yet — the gate is where the
  one-star review gets intercepted, so it must be answerable without commitment.
- step "rate": one inverted CTA that runs onRate. On success the round ends with
  outcome "rated" and the receipt; on rejection an inline role="alert" line
  offers another go and the step stays exactly as it was.
- step "vent": a textarea (maxLength, live counter) and a send button. Success
  ends the round with outcome "feedback" plus the trimmed comment. On failure
  the DRAFT SURVIVES — a lost complaint is worse than a failed request — and the
  button becomes a retry.
- step "thanks": a receipt that says the prompt will stay away for a while,
  closing itself after thanksDuration and also offering a real Done button.
- Every exit that is not an answer reports "later" (the X, Esc, the scrim, the
  flick down, "Ask me later"); "Don't ask again" reports "never".
- Async discipline: the one-shot lock is a ref read AND written synchronously in
  the handler, so a double tap cannot start two requests. The decision is
  reported even if the prompt was dismissed mid-request (it happened), but no
  state is written after unmount — check a mounted ref first. Re-opening resets
  step, draft and error, but never the in-flight lock.
- The send button never takes the native disabled attribute: aria-disabled plus
  a guard in the handler, and pressing it while empty puts the caret back in the
  box instead of doing nothing silently.

Behavior — why this is a mobile component
- It is anchored to the bottom edge, inside the thumb arc, and pads the home
  indicator with env(safe-area-inset-bottom). The sheet is position: absolute
  inset-0 over its nearest positioned ancestor rather than portalled to the
  body: wrap it in your own `fixed inset-0 z-50` layer for a real screen, which
  also makes it demoable inside a phone-shaped box.
- Flick to dismiss: Pointer Events only (never separate mouse and touch
  handlers). The grab area is the grip plus the whole heading block with
  touch-action: none so the browser does not turn the drag into a page scroll;
  a press that lands on a button or the textarea is that control's, not the
  panel's. Capture is taken with setPointerCapture on the node the gesture
  started on, only after 4px of movement (below that a tap is still a tap), and
  released on that same node. Downward movement is 1:1; upward is damped to 25%
  and capped at 24px, because the panel is already at its stop. Release past
  72px, or with a downward velocity over 0.55px/ms, dismisses; anything else
  eases home. A pointercancel settles home rather than counting as a dismissal.
- The moving pixels are written straight to the DOM inside a rAF (transform on
  the panel, opacity on the scrim). Only the drag's start and end touch React
  state — a 60fps drag must not re-render the subtree.
- Entrance / exit animate the `translate` property, never `transform`, because
  the drag owns the inline transform: they are separate properties and compose,
  so a sheet flicked halfway down keeps its offset and slides the rest of the
  way out instead of snapping back to 0 first.
- The software keyboard is a layout input. Subscribe to visualViewport (resize +
  scroll) and lift the whole stack by window.innerHeight - viewport.height -
  viewport.offsetTop, ignoring anything under ~96px (that is the URL bar
  collapsing, not a keyboard) and rounding the result so a sub-pixel scroll
  cannot re-render forever. With the keyboard up the safe-area padding is
  dropped — there is no home indicator left to clear. Unsubscribe on unmount.

Rendering & styling
- Semantic tokens only, monochrome by default: bg-card / text-card-foreground
  panels, border, bg-muted for the step icon chip, text-muted-foreground for the
  body and the snooze links, bg-background/70 + backdrop-blur for the scrim. The
  primary action INVERTS (bg-foreground text-background) rather than taking a
  colour; the only real colour is text-destructive on the failure line.
- Shape ladder: rounded-t-2xl for the sheet, rounded-2xl for the banner and the
  card, rounded-lg inside, rounded on the text links. Type ladder: 14px/600
  title, 12px body, 11px links, 10px counter with tabular-nums.
- Touch: every control is min-h-11 (44px) INCLUDING the close button, and labels
  wrap rather than truncate, so a long store name grows the button instead of
  clipping its own text. Nothing depends on hover.
- Accessibility:
  - sheet = role="dialog" + aria-modal + aria-labelledby/-describedby, focus
    moved to the panel (tabIndex -1) on the OPEN TRANSITION, a Tab trap that
    wraps at both ends, and focus handed back to the opener on close — but only
    if that node is still in the document, because focus() on a detached node
    drops focus onto <body>. A prompt that is already open on the very first
    paint (server-rendered, or several previews on one page) deliberately does
    not take focus. banner and card = role="region" with the same labelling, no
    focus trap and no focus stealing: they are ambient, not modal.
  - Every step swap unmounts the control that caused it, so focus is handed to a
    deliberate successor: the message box on the vent step, the panel otherwise,
    and only when focus was really orphaned.
  - Keyboard map: Tab / Shift+Tab cycle inside the sheet, Enter and Space
    activate (they are real buttons), Esc does exactly what the flick down does.
    Every gesture has an equal button, in every state.
  - A polite sr-only role="status" announces the start of a send and the
    receipt; the drag and the typing say nothing. The failure line is
    role="alert".
  - prefers-reduced-motion (subscribed via useSyncExternalStore over matchMedia,
    never read during render): entrance, exit and the ease-home transition are
    dropped, the spinner stops spinning. The flick still works — direct
    manipulation is not decoration — and every state is still reachable.
- Cleanup: the drag rAF, the exit timer and the receipt timer are cancelled on
  unmount and on dependency change; both media/viewport subscriptions
  unsubscribe with the component.

Customization levers
- Copy is the product here: `labels` replaces every string at once (the two gate
  answers are the ones worth A/B testing), and appName / storeName are
  substituted into {app} / {store}.
- Policy: snoozeDays is how patient you are, allowNever is whether the permanent
  opt-out exists at all (dropping it means every exit comes back — a product
  decision, not a cosmetic one), thanksDuration = 0 makes the receipt wait for
  the button.
- Placement: variant picks modality. The banner's distance from the bottom edge
  is one class — className="pb-[calc(4.5rem+env(safe-area-inset-bottom))]" to
  clear a tab bar — because the consumer's className is merged last. The sheet
  becomes screen-level by passing className="fixed" (it merges over the default
  absolute).
- Feel: the two numbers that matter are the 72px dismiss threshold and the
  0.55px/ms fling; raise both for a prompt you do not want swiped away by
  accident. The 25% upward damping is what makes it feel like a physical stop.
- Skip the gate with defaultStep when you already have the sentiment (a
  thumbs-up elsewhere in the app, a five-star delivery rating), and target
  data-step / data-variant / data-slot="rating-prompt-grip" to skin a step or
  the grab area without touching the structure.
- To add a store-specific second hop (Play Store in-app review vs an App Store
  URL), keep onRate as the single seam: it is async, so the receipt already
  waits for whatever it does.

Concepts

  • Vent-or-review fork — the first screen is a gate with two equal answers, and nothing is reported until one of them is pressed. A happy tap goes to the store; an unhappy one opens a message box that says, in the copy, that nothing is posted publicly. The whole component exists for that branch: sending an annoyed user straight to a public review page is how apps collect one-star ratings they can never take back.
  • The memory outranks open — the remembered outcome is an input, not a callback. rated and never suppress the prompt for good; later and feedback are snoozes that expire after snoozeDays. It is read at the start of each round rather than on every render, so a host that stores this round's decision the instant it arrives does not yank the receipt away mid-answer. The same rule is exported as the pure shouldAskForRating(memory, { now, snoozeDays }) so a host can ask before mounting anything, and now is injected rather than sampled from a clock — a component whose output depends on the second it rendered cannot be tested or replayed.
  • Flick, with a button for every state — the drag is Pointer Events with setPointerCapture on the node the gesture started on, a 4px start threshold, 1:1 downward travel, 25% damped upward travel, and a release rule of 72px-or-a-fling. It is also completely optional: Esc, the close button and the two snooze links reach every outcome the gesture reaches, so nothing is unreachable from a keyboard.
  • The keyboard is a layout input — the vent step opens the software keyboard, which covers the bottom of the layout viewport where a bottom sheet lives. Subscribing to visualViewport and lifting the stack by what it covers (ignoring the ~96px URL-bar collapse) is the only way to keep the box visible; the safe-area padding is dropped at the same moment, because the home indicator is behind the keyboard.
  • Translate and transform are two properties — entrance and exit animate translate while the finger owns transform. They compose, so a sheet flicked halfway down keeps the offset it was left at and slides out from there. A dismissal that a controlled parent refuses springs back instead: a panel parked half off-screen would be the worst failure this component could ship.
  • One shot, and the draft survives — the submit lock is a ref read and written synchronously inside the handler, because a state flag lets a double tap through. A rejection keeps the typed message and the step, so a failed send costs a retry rather than the whole complaint, and the decision still reaches the host even if the prompt was dismissed while the request was in the air.

On This Page