Permission Prompt
A pre-permission screen for camera, mic or notifications: explains why, then shows granted, denied or how-to-fix states.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/permission-prompt.json'use client'
import { useId, useState } from 'react'
import './permission-prompt.css'
type Kind = 'camera' | 'microphone' | 'notifications' | 'location'
const COPY: Record<Kind, { t: string; why: string; yes: string[]; no: string; icon: React.ReactNode; setting: string }> = {
notifications: { t: 'Get notified when it’s your turn', why: 'We’ll only ping you for replies and mentions, never marketing.', yes: ['Replies to your comments', 'Mentions and assignments'], no: 'No promotions or digests', setting: 'Notifications', icon: <path d="M6 9a6 6 0 0 1 12 0c0 6 2 7 2 7H4s2-1 2-7M10 20a2 2 0 0 0 4 0" /> },
camera: { t: 'Use your camera for the scan', why: 'We read the document on your device and keep only the result.', yes: ['Scan receipts and IDs', 'Processed on this device'], no: 'No photos are stored', setting: 'Camera', icon: <><path d="M4 8h3l2-2.5h6L17 8h3v11H4z" /><circle cx="12" cy="13" r="3.5" /></> },
microphone: { t: 'Turn on your microphone', why: 'Needed for voice notes. Recording only runs while you hold the button.', yes: ['Voice notes and calls'], no: 'Never listens in the background', setting: 'Microphone', icon: <><rect x="9" y="3" width="6" height="11" rx="3" /><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" /></> },
location: { t: 'Show stores near you', why: 'We use your location once to sort the list, then forget it.', yes: ['Nearest stores first', 'Accurate pickup times'], no: 'No location history kept', setting: 'Location', icon: <><path d="M12 21s-6.5-6-6.5-11a6.5 6.5 0 0 1 13 0c0 5-6.5 11-6.5 11z" /><circle cx="12" cy="10" r="2.3" /></> },
}
export interface PermissionPromptProps {
kind?: Kind
request: () => Promise<'granted' | 'denied'>
onSkip?: () => void
fill?: 'dark' | 'light'
className?: string
}
/** Contextual pre-permission explainer with result states. */
export function PermissionPrompt({ kind = 'notifications', request, onSkip, fill = 'dark', className }: PermissionPromptProps) {
const [state, setState] = useState<'ask' | 'waiting' | 'granted' | 'denied' | 'later'>('ask')
const c = COPY[kind]
const tid = useId()
const ask = async () => { setState('waiting'); setState(await request()) }
return (
<section className={['sd-prm', className].filter(Boolean).join(' ')} data-fill={fill} aria-labelledby={tid}>
<div className="sd-prm__ic" data-ring={state === 'waiting' || undefined} aria-hidden>
<svg viewBox="0 0 24 24">{c.icon}</svg>
{(state === 'granted' || state === 'denied') && <span className="sd-prm__badge" data-no={state === 'denied' || undefined}>{state === 'granted' ? '✓' : '!'}</span>}
</div>
<div key={state} className="sd-prm__body" role="status">
{(state === 'ask' || state === 'waiting') && (
<>
<h3 id={tid}>{c.t}</h3>
<p>{c.why}</p>
<ul className="sd-prm__list">{c.yes.map((y) => <li key={y}>{y}</li>)}<li data-no="">{c.no}</li></ul>
<button type="button" className="sd-prm__go" disabled={state === 'waiting'} onClick={ask}>{state === 'waiting' ? 'Waiting for your browser…' : `Allow ${c.setting.toLowerCase()}`}</button>
<button type="button" className="sd-prm__alt" onClick={() => { setState('later'); onSkip?.() }}>Not now</button>
</>
)}
{state === 'granted' && <><h3 id={tid}>You’re all set</h3><p>{c.setting} is on. You can change this any time in settings.</p><button type="button" className="sd-prm__alt" onClick={() => setState('ask')}>Done</button></>}
{state === 'denied' && (
<>
<h3 id={tid}>{c.setting} is blocked</h3>
<p>Your browser said no, so we can’t ask again. To turn it on:</p>
<ol className="sd-prm__steps"><li>Click the icon left of the address bar</li><li>Find {c.setting} and choose Allow</li><li>Reload this page</li></ol>
<button type="button" className="sd-prm__go" onClick={() => setState('ask')}>I’ve changed it</button>
</>
)}
{state === 'later' && <><h3 id={tid}>No problem</h3><p>We’ll ask again only when you use a feature that needs it.</p><button type="button" className="sd-prm__alt" onClick={() => setState('ask')}>Undo</button></>}
</div>
</section>
)
}.sd-prm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-prm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-prm :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-prm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-prm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-prm { width: min(100%, 310px); padding: 22px 20px 18px; border-radius: 24px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); text-align: center; }
.sd-prm__ic { position: relative; display: grid; place-items: center; width: 64px; height: 64px; margin: 0 auto 14px; border-radius: 20px; background: var(--soft); }
.sd-prm__ic svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.sd-prm__ic[data-ring]::after { content: ''; position: absolute; inset: -6px; border-radius: 24px; box-shadow: inset 0 0 0 1.5px var(--line); animation: sd-prm-ring 1.6s ease-out infinite; }
.sd-prm__badge { position: absolute; right: -6px; bottom: -6px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 99px; background: var(--strong); color: var(--on); font-size: 13px; font-weight: 700; box-shadow: 0 0 0 3px var(--bg); animation: sd-prm-pop 300ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-prm__badge[data-no] { background: var(--bg); color: var(--fg); box-shadow: 0 0 0 3px var(--bg), inset 0 0 0 1.5px var(--fg); }
.sd-prm h3 { margin: 0 0 6px; font-size: 17px; letter-spacing: -0.01em; }
.sd-prm p { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--mute); }
.sd-prm__list { list-style: none; margin: 14px 0 18px; padding: 0; text-align: left; font-size: 13px; }
.sd-prm__list li { display: flex; gap: 8px; padding: 5px 0; }
.sd-prm__list li::before { content: '✓'; font-weight: 700; }
.sd-prm__list li[data-no]::before { content: '×'; color: var(--mute); }
.sd-prm__go { display: block; width: 100%; height: 44px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-size: 14px; font-weight: 600; }
.sd-prm__go:disabled { opacity: 0.6; }
.sd-prm__alt { display: block; width: 100%; margin-top: 6px; padding: 10px; border-radius: 12px; font-size: 13.5px; color: var(--mute) !important; }
.sd-prm__steps { margin: 14px 0 16px; padding: 12px 14px; border-radius: 12px; background: var(--soft); text-align: left; font-size: 13px; line-height: 1.6; counter-reset: s; list-style: none; }
.sd-prm__steps li { counter-increment: s; display: flex; gap: 8px; }
.sd-prm__steps li::before { content: counter(s); display: grid; place-items: center; width: 18px; height: 18px; flex: none; margin-top: 2px; border-radius: 99px; background: var(--bg); font-size: 11px; font-weight: 700; }
.sd-prm__body { animation: sd-prm-in 260ms ease-out; }
@keyframes sd-prm-ring { from { opacity: 1; transform: scale(0.95); } to { opacity: 0; transform: scale(1.25); } }
@keyframes sd-prm-pop { from { transform: scale(0); } }
@keyframes sd-prm-in { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) { .sd-prm *, .sd-prm *::before, .sd-prm *::after { animation: none !important; transition: none !important; } }Overview
Ask in context before the browser prompt: a short reason, what you will and won’t do, and Allow / Not now. The result card changes with the answer.
If denied, it shows the exact steps to re-enable in browser settings instead of asking again.
When to use
- Before any browser permission prompt, at the moment it is needed.
When not to use
- On first page load before the user has done anything.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/permission-prompt.json$
npx shadcn@latest add https://sundaeui.dev/r/permission-prompt.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/permission-prompt.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/permission-prompt.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy permission-prompt.tsx and permission-prompt.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { PermissionPrompt } from '@/components/sundae/permission-prompt'
<PermissionPrompt />Props
| Prop | Type | Default |
|---|---|---|
kindWhat to ask for. | 'camera' | 'microphone' | 'notifications' | 'location' | 'notifications' |
request*Calls the real browser API. | () => Promise<'granted' | 'denied'> | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The card is a dialog-like region with a heading; the result is announced.
- Buttons say exactly what happens (“Allow notifications”).
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-prm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Permission Prompt free?
- Yes. Permission Prompt is free for personal and commercial projects, with full source on this page.
- How do I install Permission Prompt?
- Run npx shadcn@latest add https://sundaeui.dev/r/permission-prompt.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Permission Prompt support keyboard and screen readers?
- The card is a dialog-like region with a heading; the result is announced. Buttons say exactly what happens (“Allow notifications”).
- Does Permission Prompt respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Notification Center
Components · Refined Cookie Consent
Components · Refined- Pro
Feature Tour
Components · Refined Share Sheet
Components · Refined- Pro
Story Viewer
Components · Refined - Pro
Voice Recorder
Components · Refined