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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/rating-prompt.jsonPrompt
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.ratedandneversuppress the prompt for good;laterandfeedbackare snoozes that expire aftersnoozeDays. 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 pureshouldAskForRating(memory, { now, snoozeDays })so a host can ask before mounting anything, andnowis 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
setPointerCaptureon 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
visualViewportand 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
translatewhile the finger ownstransform. 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.
Permission Primer
A pre-permission sheet that spends the phone's one-shot system dialog on purpose — reasons first, a swipe or a button to refuse, and a Settings round trip that re-checks itself when the app comes back.
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.