Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ArrowRight, CircleAlert, Download, LoaderCircle, RotateCw, ShieldAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** Rise / settle / exit duration (ms). Also how long the exit is held before the sheet unmounts. */
const SETTLE_MS = 280
/** How long the refusal wobble runs on a gate that will not be dismissed. */
const SHAKE_MS = 380
/** Movement (px) on a drag zone before a press becomes a drag. Below it, a press is still a press. */
const DRAG_START_PX = 4
/** Released past this fraction of the sheet's own height, a postponable sheet dismisses. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/app-update-sheet.json

Prompt

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 numberinstalledVersion against version, 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 are aria-hidden; one sr-only sentence 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 consumerstatus and progress are 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 where aria-valuenow belongs.
  • 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-motion the wobble becomes a ring while every sentence stays exactly where it was.

On This Page