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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/app-update-sheet.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AppUpdateSheet" component (react-dom's
createPortal + lucide-react, no Radix, no vaul — the edge anchoring, the safe
area and the gesture are the whole point and must be self-contained). It is the
sheet an app raises from the bottom edge when the build on the device is behind
the one in the store: what changed, which version replaces which, and one 48px
action in the thumb arc. It is NOT a centred desktop dialog wearing a sheet skin.
Contract
- "use client". forwardRef<HTMLDivElement, AppUpdateSheetProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "title">; the ref points at the
panel, className merges through cn(), the rest of the div props spread onto it.
- Data: AppUpdateNote = { id: string; text: string;
type?: "new" | "improved" | "fixed" | "security" }.
- Props:
- version (required) — the build being offered. installedVersion?, size?,
releasedAt?, requirement? are all DISPLAY STRINGS the consumer formats.
releasedAt is deliberately not a Date: the component owns no clock, reads no
wall time at render, and never turns a timestamp into "3 days ago" behind
your back.
- notes?: AppUpdateNote[] — [] renders the empty line, not an empty box.
- variant?: "notes" | "compare" | "minimal" (default "notes").
notes = version chips + the tagged release-notes list.
compare = a two-column Installed / Available grid with size, released and
platform-floor rows under it, then the same list.
minimal = heading, chips, buttons; about 180px of screen for a routine nudge.
- severity?: "optional" | "required" (default "optional"). Required is a gate:
no Later, no handle, role="alertdialog".
- status?: "idle" | "downloading" | "ready" | "failed" (default "idle") and
progress?: number (0..1; omitted means indeterminate). The download belongs
to the consumer — only it knows what the bytes are doing — so these are
props, not internal state. Clamp progress through one helper that rejects
NaN.
- errorMessage? replaces the default failure line while status is "failed".
- open?, defaultOpen?, onOpenChange? — controlled and uncontrolled both
supported; controlled when `open` is passed, otherwise the sheet owns it.
- onUpdate? (primary while idle or failed), onInstall? (primary once ready,
falling back to onUpdate), onPostpone? (the Later button ONLY — the backdrop,
Esc and the flick report through onOpenChange, so a consumer can tell an
explicit postponement from a dismissal).
- trigger?, appIcon?, title? (defaults to "Update required" on a gate, "Update
available" otherwise), message?, showHandle? (defaults true, and false on a
gate — a handle promises a dismissal that is not coming), autoFocus?
(default true), container?, labels?: Partial<AppUpdateSheetLabels>.
- Every string the sheet can say lives in `labels`: the four button states, the
notes heading, the empty line, the compare captions, the required hint, the
failure line, the four note tags. That is the only i18n seam, and there is no
prose anywhere else in the component.
- trigger uses asChild semantics: a valid element is cloned so it keeps its own
type, styling and handlers while receiving aria-haspopup="dialog",
aria-expanded, data-state and a merged onClick that bails when the consumer
already called preventDefault; anything else is wrapped in a default button.
Omit trigger entirely and drive `open` from your version check.
- container is the portal target. Default document.body with a fixed layer — a
real full-screen sheet. Pass a relative, overflow-hidden element and the layer
becomes absolute inside it, which is what makes several sheets previewable
side by side in phone frames.
- Mirror severity / status / variant onto the panel as data-severity,
data-status and data-variant, and give the notes region a data-slot, so both
can be skinned without forking the component.
Behavior
- Position is one number: `offset`, 0 = fully raised, 1 = fully below the edge,
expressed as a fraction of the sheet's own height so it maps straight to a
translate3d percentage and needs no measurement to render.
- Enter, settle and exit all run through one effect that pushes offset to its
target inside a DOUBLE requestAnimationFrame, so the parked frame is really
painted before the transition starts (otherwise the browser coalesces both
style changes and the sheet teleports). A settle counter in that effect's deps
makes every gesture end re-run it even when the target did not change. The
portal stays mounted for the 280ms exit, then unmounts — immediately under
reduced motion.
- Gesture: Pointer Events only, never separate mouse/touch handlers. A press
becomes a drag after 4px of movement, and only if vertical movement exceeds
horizontal; a clearly horizontal gesture is abandoned for good rather than
re-tested every frame, or a diagonal swipe stutters between two
interpretations. On arming, call setPointerCapture on the element that started
the gesture and release it on that same node; ignore every pointerId except
the one that owns the drag.
- Only the handle and the header start a drag (they carry a data attribute and
touch-action: none). A press on a button belongs to that button; a press in
the notes belongs to the list's own scrolling (touch-action: pan-y +
overscroll-contain). Nothing calls preventDefault, so no listener has to be
registered non-passively.
- SEVERITY DECIDES THE PHYSICS. Optional: 1:1 downwards, rubber-banded upwards
(28% of the excess, capped at 5% of the height); release dismisses when the
smoothed velocity exceeds 0.5 px/ms OR the sheet is past 35% of its own
height, otherwise it springs back. Velocity is smoothed 30/70 so one jittery
frame is not a fling. Required: damped in BOTH directions and capped at 5% —
it moves under the thumb, because silence reads as a dead screen, and it never
leaves. A release past about 1% of travel wobbles the panel once and announces
the reason; a two-pixel twitch is not an attempt and gets no answer.
- Later, Esc and the backdrop are the same dismissal as the flick, and on a gate
all four are refused in exactly the same way. A dismissal only calls
onOpenChange(false): if a controlled consumer refuses, the sheet springs back
to its stop instead of sitting stranded halfway down.
- The primary button relabels itself per status — Update now / Downloading… /
Restart to install / Try again — with a download, spinner and restart glyph.
While the bytes move it reports aria-disabled + aria-busy and the handler
bails; never the native disabled attribute, which blurs the node the instant
it flips and would drop focus onto <body> the moment the button started the
work it was pressed for.
- The one-shot guard is a ref read AND written synchronously inside the click
handler, so a double tap cannot start two downloads while the consumer's
status prop is still catching up. It re-arms on every status change AND on a
600ms timer, because a consumer whose handler only opens the store never
reports a status change, and a guard waiting for one would leave the button
inert for the rest of the session.
- Progress: determinate renders a percentage and a width transition;
indeterminate renders a travelling segment. Under reduced motion the segment
parks and the caption plus aria-busy still say what is happening.
- Cleanup: the refusal timer, the wobble timer, the re-arm timer and the drag
rAF are all cancelled on unmount; the exit timer is cleared when the sheet
reopens; matchMedia is subscribed through useSyncExternalStore, so the
preference can be flipped while the sheet is up and the subscription is torn
down with the component.
- Body scroll lock, and only when the sheet owns the whole screen (no
container): the reentrancy count and the pre-lock snapshot live in
document.body data attributes, never module-level variables, so two
independently installed copies cooperate instead of leaving the page frozen
with nothing on screen to explain it. Measure the scrollbar delta rather than
predicting it — under scrollbar-gutter: stable nothing is reclaimed and the
usual guess shifts the page sideways exactly as the sheet rises.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card / text-card-foreground panel,
rounded-t-2xl + border-t + shadow-2xl, bg-muted tiles, text-muted-foreground
captions, hairline borders, ring-ring focus rings. The primary action and the
new-version chip INVERT (bg-foreground / text-background) instead of taking a
colour. text-destructive is spent on exactly two things: the Required chip and
a real failure. Never a hex, never oklch().
- Type ladder: heading 15/600, notes 13/400, captions, chips and tags 10–11px,
every number tabular-nums. Radii: 2xl for the panel, lg for inner blocks,
rounded for chips.
- Safe area: paddingBottom / Left / Right are
max(var(--safe-area-inset-EDGE, env(safe-area-inset-EDGE, 0px)), floor), so
the buttons clear the home indicator, a device frame or a test can simulate an
inset by setting the custom property, and one override drives this and every
other safe-area-aware component.
- Layout: the panel is a max-h-full flex column of shrink-0 blocks with exactly
ONE shrinking block — the notes region (max-h-64, min-h-0, overflow-y-auto).
Do not give it flex-1: in an auto-height column a flex-basis:0 item
contributes no height and the list collapses to a single line.
- Accessibility:
- role="dialog", or "alertdialog" on a gate; aria-modal; aria-labelledby the
heading; aria-describedby an sr-only summary ("Installed 4.2.1 · Available
4.6.0 · 48.2 MB · 12 Aug") plus the optional message. The chip row and the
compare grid are aria-hidden — they are that one string's visual echo, and
unhidden they are read as a pile of loose numbers.
- Focus moves to the panel itself (tabIndex -1) on open, so the heading, the
version summary and the severity are announced before anything is reachable
and a stray Enter cannot start an update nobody has read about yet. On close
it returns to the element that had it, if that element is still isConnected,
otherwise to whatever the trigger is now — never left to fall to <body>.
- Tab is trapped: first and last wrap, and focus sitting on the panel itself
wraps too, or Shift+Tab steps straight out of the trap the sheet just
claimed with aria-modal.
- Keyboard map: Tab / Shift+Tab walk the notes region, the primary action and
Later; Arrow keys, PageUp / PageDown and Home / End scroll the notes
natively once that region has focus (it is tabIndex 0 with role="region" and
a label — nothing is rebound); Enter / Space press the focused button; Esc
postpones, or is refused exactly like the flick.
- The download phase is DERIVED from the status prop into a polite sr-only
live region — no effect, no announcement storm, and no percentage read out
per frame (the progress bar's aria-valuenow carries that). A refusal takes
the region over for 2.4s and then hands it back; clearing it is what lets an
identical refusal be announced again.
- prefers-reduced-motion: the wobble becomes a ring, the spinner and the
travelling segment stop, the rise and the width transition are dropped. The
gesture still tracks the thumb — that is direct manipulation, not decoration
— and every sentence stays exactly where it was.
Customization levers
- variant is the density knob: "minimal" for a launch nudge, "notes" for a
normal release, "compare" when the decision is about the build (managed
fleets, beta channels, an OS floor). A fourth is a new branch in the body, not
a new component — the header, the actions and the state machine are shared.
- severity is the one behavioural switch: flipping it to "required" removes
Later and the handle, swaps the role to alertdialog and inverts the drag
physics. Escalate a stale optional prompt by passing "required" on the nth
launch; nothing else in your code has to change.
- Feel lives in four numbers: CLOSE_RATIO (how far is "on purpose"),
FLING_VELOCITY (how fast counts as a flick) and RUBBER_BAND /
RUBBER_BAND_MAX (how much a refusal moves). Raise the ratio to 0.5 for a sheet
people dismiss by accident; drop RUBBER_BAND_MAX to 0.02 for a stiffer gate.
- Sizes: the notes cap (max-h-64) is the only height in the component, and the
panel follows its content up to max-h-full. Raise the cap for long changelogs,
or remove it and let the panel cap do the work.
- labels is the i18n seam and also the tone seam: "Update now" / "Not now",
"What is new" / "In this release", four note tags in your own vocabulary.
- Colour: keep the inverted primary and let the destructive token carry the
Required chip and the failure line. If your brand demands a coloured CTA,
change bg-foreground / text-background to bg-primary / text-primary-foreground
in one place — do not tint the chips, the tags or the progress bar with it.
- The status machine is yours to drive: wire it to a background download, to a
store callback, or leave it at "idle" and treat the sheet as a link out. Keep
the pairing honest — a "downloading" status with no progress means
indeterminate, not stuck.Concepts
- The bottom edge is the argument — the sheet lives at the edge because that is where the thumb is: release notes at the top where they can be skimmed, the 48px primary action at the bottom where it can be pressed one-handed, and
env(safe-area-inset-bottom)between that button and the home indicator. Rebuilt as a centred modal it would simply be a worse dialog; the edge, the safe area and the flick are what make it a mobile component rather than a small-screen skin of a desktop one. - Severity decides the physics — an optional update tracks the thumb 1:1 downwards and leaves on a flick past 0.5 px/ms or 35% of its own height. A required one is damped in both directions and capped at 5%: it moves, springs back, wobbles once and says why. Refusing a gesture silently reads as a broken screen, so the refusal is a rendered, announced, self-clearing answer instead of an ignored event.
- A comparison, not a version number —
installedVersionagainstversion, with size, release date and platform floor, is what turns the prompt into a decision instead of a demand. The chips and the compare grid arearia-hidden; onesr-onlysentence carries the same comparison to assistive tech, because a screen reader walking a grid of loose numbers learns nothing from it. - The download belongs to the consumer —
statusandprogressare props, so the four states are first-class branches that can be rendered side by side without waiting on a timer, and the sheet never pretends to know what the bytes are doing. The phase is derived into a polite live region at render time (no effect, no announcement storm), while the percentage stays on the progress bar wherearia-valuenowbelongs. - A guard that re-arms — the one-shot protection is a ref read and written synchronously inside the click handler, and it clears on the next status change and on a 600ms timer. A guard that only waited for a status change would leave the button permanently inert for the very common consumer whose handler just opens the store and never reports anything back.
- Every gesture has a button — a flick down equals Later equals Esc equals the backdrop, and on a gate all four are refused identically. Focus lands on the panel so the heading and the version summary are announced before anything can be pressed, the Tab ring wraps (including from the panel itself), and under
prefers-reduced-motionthe wobble becomes a ring while every sentence stays exactly where it was.
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.
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.