Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUpRight, Ban, Check, ChevronRight, Loader2, Settings2, Shield, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long the sheet slides out for — and how long the dismissal is deferred so the slide is seen. */
const EXIT_MS = 260
/** Movement (px) before a press on the grip becomes a drag. Under it, it is still a tap. */
const DRAG_START_PX = 6
/** Share of the sheet's own height that has to be dragged away for a release to dismiss. */
const DISMISS_RATIO = 0.28
/** px/ms. A flick faster than this dismisses from wherever the sheet happens to be. */
const FLING_VELOCITY = 0.5

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/permission-primer.json

Prompt

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

Build a React + TypeScript + Tailwind "PermissionPrimer" component — the screen a
phone app shows BEFORE it triggers the system permission dialog, because that
dialog can only be spent once. React + lucide-react only: no dialog library, no
gesture library, no animation library, and no permission API of its own — every
request leaves through a handler the host supplies.

Contract
- "use client". forwardRef<HTMLDivElement, PermissionPrimerProps> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "title">; the rest props spread onto
  the root.
- Content props: subject (required — the capability's human name, "Photos"),
  title?, description?, icon?, footnote?, reasons?: { key; icon?; text }[] and
  settingsPath?: string[] (a breadcrumb of where the switch lives).
- Machine: status / defaultStatus = "prompt" / onStatusChange — controlled and
  uncontrolled both supported. Status is
  "prompt" | "granted" | "denied" | "blocked" | "unsupported", where denied is a
  refusal the system will still let you re-ask and blocked is terminal.
- Handlers, all optional, all owned by the host:
  - onRequest(): outcome | void | Promise<outcome | void> — where the real system
    dialog is triggered. Returning an outcome commits it; returning nothing means
    the host drives status itself; a rejection means the request never reached
    the OS. A synchronous answer must not flash a pending state, so branch on
    thenable-ness rather than wrapping everything in Promise.resolve.
  - onOpenSettings() — the deep link. Without it, no Settings button is drawn.
  - onRecheck(): status | void | Promise<status | void>.
  - onDismiss(reason: "decline" | "swipe" | "escape" | "close" | "granted").
    Without it there is no "Not now", no close key and no swipe: a control that
    cannot do anything must not be drawn.
- variant: "sheet" | "card" | "banner" = "sheet" — three placements for one ask,
  not three skins. sheet rises from the bottom edge and can be swiped away; card
  sits in the flow of a settings screen with no edge and no gesture; banner is a
  one-line second nudge with a single action and a 44px close key.
- Knobs: dismissible = true, swipeToDismiss = true, safeArea = true,
  autoDismissMs = 1600 (0 keeps the confirmation up), labels — every user-facing
  string in one object with {subject} substituted at render, so translating the
  component is editing one map.
- Mirror data-variant / data-status / data-phase onto the root and mark the drag
  zone, the reason list and the action stack with data-slot, so a consumer can
  skin by state without forking the component.

Behavior
- The ask is a budget, not a button:
  - The primer never asks on its own. Only the primary button calls onRequest.
  - The one-shot is a ref read AND written synchronously inside the handler, so
    two taps in the same frame spend one ask. State cannot do this job: it is a
    frame late.
  - A request is refused outright when the status is already granted, blocked or
    unsupported — the OS would auto-deny it and burn nothing but goodwill.
  - A second ask after a denial is the "Ask again" button, i.e. a human pressing
    it, never an effect.
- Five states are five bodies, and the header never changes:
  - prompt: reasons + footnote + [Not now] [Allow {subject}].
  - requesting (a phase, not a status): the panel stays up because the system
    dialog is drawn over it, the reasons dim, a live line says the dialog only
    asks once, and every control reports aria-disabled.
  - granted: an inverted tick, a one-line confirmation, and after autoDismissMs
    an automatic onDismiss("granted").
  - denied: a soft line plus [Not now] [Ask again].
  - blocked: the recovery — the settingsPath breadcrumb and [Open Settings].
  - unsupported: a refusal with no ask at all; with no onDismiss, no buttons.
  - A rejected request is not a status: it leaves the machine where it was and
    adds one destructive-coloured line under it, with the same button to retry.
    In the banner that line takes over the one line the variant has, outranking
    the status copy — otherwise a failed retry from "denied" would repeat the
    denied sentence and read as a button that did nothing.
- Foreground reconciliation, which is the whole point of the blocked state: the
  switch is flipped in another app, so while the status is blocked the component
  listens for document visibilitychange (and pageshow with event.persisted, for
  the bfcache restore iOS Safari serves instead) and calls onRecheck on the way
  back. The re-check is itself guarded by a ref, so a burst of events runs one.
  Both listeners are removed when the status leaves blocked and on unmount.
- The gesture (sheet only) is pull-down-to-dismiss:
  - pointerdown on the grip — the handle plus the header — records the press and
    takes setPointerCapture on that same element immediately, because the finger
    leaves that strip within a few pixels and the moves would stop arriving.
    touch-action: none on the grip declares the intent, so nothing has to call
    preventDefault on a listener React registered passively. Pointer Events only,
    never a mouse/touch pair.
  - The drag is claimed after 6px and dropped for good if the movement is mostly
    sideways (that belongs to whatever the sheet sits on). On claiming, re-
    baseline the start point so the sheet does not jump the lock distance.
  - Downward travel is 1:1; upward is damped to 0.3x and capped at 28px, because
    there is nothing above the sheet to open into.
  - Release dismisses past 28% of the sheet's own measured height, or on a flick
    faster than 0.5px/ms (velocity smoothed 0.3/0.7 so one jittery frame is not a
    fling, and only read as a flick when the last move was under 120ms ago —
    nothing decays that reading while the finger sits still, so a pull-then-pause
    is a decision to keep the sheet). Anything else springs back, and a
    pointercancel always springs back.
  - The offset is state written from one coalesced rAF, cancelled on release and
    on unmount.
  - Dismissal is a request, never self-hiding: the sheet plays a 260ms slide out
    and then reports. If the host keeps it mounted the sheet eases back into
    place, because a refused dismissal has to be visible.
- Nothing depends on the gesture: "Not now" and Escape produce exactly the same
  dismissal, and the reason says which path it was. Escape is handled on the
  component's own subtree with stopPropagation, never on window, so an Escape
  inside a drawer holding this primer closes one layer, not two.
- Cleanup: the exit timer, the auto-dismiss timer, the drag rAF, the pointer
  capture (released on the element that took it, on pointerup and pointercancel)
  and both foreground listeners are torn down on unmount and on dependency
  change. A mounted-ref guards every async settle, so a promise resolving after
  unmount commits nothing.

Rendering & styling
- Semantic tokens only, monochrome first: bg-card + border + shadow-sm for the
  surface, bg-muted for the icon chip, the status line and the breadcrumb steps,
  text-foreground / text-muted-foreground for copy. The primary action INVERTS
  (bg-foreground text-background) instead of taking a colour, and so does the
  granted chip. Colour is spent on one thing only: text-destructive for a request
  that never reached the OS. Radius ladder 16/12/8 — rounded-t-2xl sheet,
  rounded-2xl card and banner, rounded-lg buttons and chips, rounded breadcrumb
  steps.
- Type scale small and tight: 14px heading, 12–13px body and buttons, 11px
  footnote and breadcrumb. Headings and copy wrap with wrap-anywhere so an
  unhyphenated identifier cannot widen a 390px column.
- Touch: the sheet's buttons are min-h-12 and full width, stacked with the
  primary one LAST — last in the DOM, last in the reading order and lowest on the
  screen, which on a phone is the spot under the thumb. Card and banner controls
  are min-h-11 and the banner's close key is a 44px square. Nothing depends on
  hover.
- Safe area: the sheet pads with env(safe-area-inset-bottom / left / right) and
  the banner with the left/right insets, because both touch a screen edge. It is
  a prop, so the same component behaves inside a parent that already pays them.
- Motion: only the transform is animated (260ms, cubic-bezier(0.32,0.72,0,1)),
  and the transition is switched off while the finger is down so the sheet tracks
  it exactly. Under prefers-reduced-motion — subscribed through
  useSyncExternalStore, not read once — the slide is dropped and the dismissal
  fires immediately, while the drag itself keeps working: direct manipulation is
  not decoration.
- Accessibility:
  - role="group" with aria-labelledby / aria-describedby, deliberately NOT
    role="dialog": there is no backdrop, no focus trap and no scroll lock, and
    claiming aria-modal without them lies to a screen reader. Wrap it in a real
    dialog when the moment deserves one.
  - One polite live region, mounted from the start and empty in the untouched
    prompt state (a region that appears together with its text is announced
    unreliably). The header never changes, so only the status line speaks. In the
    banner the second line IS that region, so nothing is said twice.
  - aria-busy on the root while a request or a re-check is in flight.
  - Unavailable controls are aria-disabled plus a guard in the handler, never the
    native attribute: the browser blurs a disabled node instantly and the user is
    standing on that button.
  - Focus succession: a status change swaps the whole body, so the component
    records whether focus was inside before the swap and, if the control the user
    was on did not survive it, moves focus to the new primary action rather than
    letting it fall to <body>.
  - Decorative nodes (handle, chips, glyphs) are aria-hidden; the breadcrumb is
    an ordered list with an accessible name.

Customization levers
- The whole ask is data: subject + title + description + reasons + footnote. Two
  or three reasons is the working range, and one of them should say what you do
  NOT do with the access — that is the line that moves consent rates.
- variant is the moment: sheet for the first ask, card for a settings screen or
  an inline pre-flight, banner for the softer second nudge after a decline.
- Physics live in four constants: DRAG_START_PX (6), DISMISS_RATIO (0.28),
  FLING_VELOCITY (0.5) and EXIT_MS (260). Raise the ratio if the sheet sits on a
  scroller people flick; pass swipeToDismiss={false} to keep only the button
  path.
- labels is the i18n seam and the tone seam at once — every string, with
  {subject} substituted; keep the retry label a verb the user chose ("Ask again")
  rather than a system word ("Retry").
- autoDismissMs = 0 keeps the confirmation on screen when the grant unlocks
  something worth reading; safeArea = false when a parent already pays the inset.
- The primer does not position itself: give the sheet
  className="fixed inset-x-0 bottom-0 z-40" for a real screen, or drop it into a
  bottom drawer and let that own modality. Its parent should clip overflow, so
  the exit slide is not painted over the page below.
- Wire the outcomes honestly: map your platform's answers onto denied vs blocked
  (Android's shouldShowRequestPermissionRationale, iOS's .denied after the first
  ask). Collapsing them into one state is what produces apps that keep asking for
  something the system will never ask about again.

Concepts

  • The ask is a budget — a phone shows a permission dialog once (Android grants one more, then stops), so the primer treats it as a scarce resource: it never triggers one from an effect, refuses to trigger one the OS would auto-deny, and guards the trigger with a ref that is read and written in the same handler, so two taps in one frame spend one ask.
  • Soft decline vs hard blockdenied means the person said no and the system will still ask again; blocked means it never will. They need different screens, because the repair for the second one lives outside the app. Collapsing them into a single denied state is what produces apps that keep offering a button the OS has already disconnected.
  • Foreground reconciliation — a blocked permission is fixed in Settings, while your app is not running. So instead of polling, or subscribing to a permissions API that has no answer for Photos or Contacts, the component listens for the app coming backvisibilitychange, plus pageshow with persisted for the bfcache restore — and asks the host to re-read the truth exactly then.
  • The swipe is a shrug, the button is an answer — pull-down and “Not now” both dismiss, but onDismiss reports which, so a host can back off gently after a swipe and properly after a refusal. Every gesture path has a button and a key that do the same thing; the gesture is only ever the faster one.
  • Thumb-stacked actions — on a sheet the buttons are full width and the primary one is last: last in the DOM, last in the reading order and lowest on the screen, which is the spot the thumb owns. Under it sits env(safe-area-inset-bottom), so the button and the home indicator never share pixels.
  • Focus succession over <body> — a status change replaces the entire body of the primer, including the button being pressed. The component notes that focus was inside, then hands it to the new primary action once the swap is committed; unavailable controls are aria-disabled with a guard rather than natively disabled, which would blur the user mid-press.

On This Page