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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/permission-primer.jsonPrompt
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 block —
deniedmeans the person said no and the system will still ask again;blockedmeans 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 back —
visibilitychange, pluspageshowwithpersistedfor 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
onDismissreports 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 arearia-disabledwith a guard rather than nativelydisabled, which would blur the user mid-press.
Share Sheet
The system share surface: a subject header you can pull down to dismiss, a swipeable recents rail, a 4-up app grid and an activity list, on one scrollable sheet with safe-area padding.
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.