Permission Primer
The page dims and an explanation rises, with a small diagram filling in to show what the permission would actually put on screen.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Permission Primer
*
* The card that earns the system prompt. The page dims, an explanation
* rises over it, and a small diagram fills in to show exactly what
* granting the permission would put on screen — before the one-shot OS
* dialog is ever spent.
*
* Self-contained: depends only on `react` and `motion`. The card sits
* above a scrim, so it uses the CSS system colors `Canvas`/`CanvasText`
* and lands light in a light app and dark in a dark one.
* Works with zero props; tune via `variant`, `title`, `body`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PermissionPrimerProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
title?: string;
body?: string;
/** Reassurance line under the diagram. */
note?: string;
allowLabel?: string;
denyLabel?: string;
/** Stage size. */
width?: number;
height?: number;
/** Diagram and primary button color. */
accent?: string;
/** Fires with the answer. `true` is where you would call the real
* permission API. */
onDecision?: (allowed: boolean) => void;
};
type VariantConfig = {
/** px the card rises from. */
rise: number;
cardSpring: { type: "spring"; stiffness: number; damping: number };
scrimSeconds: number;
/** Seconds before the diagram starts filling in. */
fillDelay: number;
/** Seconds between one diagram item and the next. */
fillStagger: number;
/** px each diagram item travels. */
fillTravel: number;
};
// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8. This card is asking for trust; a card that bounces on arrival is
// selling something. Variants change pace and travel, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a fade. For a primer shown more than once.
subtle: {
rise: 8,
cardSpring: { type: "spring", stiffness: 520, damping: 46 },
scrimSeconds: 0.22,
fillDelay: 0.24,
fillStagger: 0.06,
fillTravel: 5,
},
// A calm rise, then the diagram explains itself. All-purpose.
default: {
rise: 16,
cardSpring: { type: "spring", stiffness: 420, damping: 40 },
scrimSeconds: 0.28,
fillDelay: 0.34,
fillStagger: 0.09,
fillTravel: 8,
},
// A longer arrival for the permission the product depends on.
playful: {
rise: 24,
cardSpring: { type: "spring", stiffness: 340, damping: 34 },
scrimSeconds: 0.32,
fillDelay: 0.42,
fillStagger: 0.12,
fillTravel: 11,
},
};
const DAYS = ["M", "T", "W", "T", "F"];
/** Column index, top offset and height of each meeting in the diagram. */
const MEETINGS = [
{ day: 1, top: 6, height: 16, label: "Kickoff" },
{ day: 3, top: 24, height: 14, label: "Review" },
];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const CARD_EDGE = "1px solid color-mix(in srgb, currentColor 14%, transparent)";
export default function PermissionPrimer({
variant = "default",
title = "See your week beside your work",
body = "Meetings show up next to the tasks they belong to.",
note = "Read only — nothing is written back.",
allowLabel = "Allow calendar access",
denyLabel = "Not now",
width = 320,
height = 300,
accent = "#5B5BD6",
onDecision,
}: PermissionPrimerProps) {
const [open, setOpen] = useState(true);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: the card still arrives and the diagram still fills,
// both without travelling. What it explains is the point.
const rise = reduceMotion ? 0 : cfg.rise;
const travel = reduceMotion ? 0 : cfg.fillTravel;
const answer = (allowed: boolean) => {
setOpen(false);
onDecision?.(allowed);
};
return (
<div
style={{
position: "relative",
width,
height,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(5),
overflow: "hidden",
}}
>
<SampleSurface accent={accent} />
{!open && (
<button
type="button"
onClick={() => setOpen(true)}
style={{
position: "absolute",
left: "50%",
bottom: 14,
transform: "translateX(-50%)",
padding: "7px 12px",
fontSize: 11.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: tone(8),
border: `1px solid ${tone(14)}`,
borderRadius: 9,
cursor: "pointer",
}}
>
Ask again
</button>
)}
<AnimatePresence>
{open && (
<motion.div
key="scrim"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.22, ease: "easeOut" } }}
transition={{ duration: cfg.scrimSeconds, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
background: "rgba(8, 8, 12, 0.52)",
}}
/>
)}
</AnimatePresence>
<AnimatePresence>
{open && (
<motion.div
key="primer"
role="dialog"
aria-label={title}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduceMotion ? 0 : rise * 0.7,
transition: { duration: 0.2, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.cardSpring, delay: 0.08 }
}
style={{
position: "absolute",
left: 20,
right: 20,
bottom: 18,
boxSizing: "border-box",
padding: 16,
borderRadius: 16,
// The card sits on top of the dim, so it cannot be
// translucent. `Canvas`/`CanvasText` are the CSS system
// colors for page background and page text: they follow the
// host app's color scheme and always land as a legible pair.
background: "Canvas",
color: "CanvasText",
border: CARD_EDGE,
boxShadow: "0 18px 40px rgba(0,0,0,0.26)",
}}
>
{/* The diagram is the argument: it shows the thing the
permission would put on screen, filling in one item at a
time so the change is legible rather than decorative. */}
<div
aria-hidden
style={{
display: "flex",
gap: 5,
padding: 9,
borderRadius: 11,
border: `1px solid ${tone(10)}`,
background: tone(4),
}}
>
{DAYS.map((day, index) => (
<div key={`${day}-${index}`} style={{ flex: 1 }}>
<div
style={{
fontSize: 9,
fontWeight: 700,
textAlign: "center",
letterSpacing: 0.4,
opacity: 0.4,
}}
>
{day}
</div>
<div
style={{
position: "relative",
height: 46,
marginTop: 4,
borderRadius: 6,
background: tone(6),
}}
>
{MEETINGS.filter((meeting) => meeting.day === index).map(
(meeting, meetingIndex) => (
<motion.div
key={meeting.label}
initial={{ opacity: 0, y: travel }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0.18 : 0.3,
delay:
cfg.fillDelay +
cfg.fillStagger * (index + meetingIndex),
ease: "easeOut",
}}
style={{
position: "absolute",
left: 2,
right: 2,
top: meeting.top,
height: meeting.height,
borderRadius: 4,
background: `color-mix(in srgb, ${accent} 78%, transparent)`,
}}
/>
)
)}
</div>
</div>
))}
</div>
<div style={{ marginTop: 13, fontSize: 14, fontWeight: 680 }}>{title}</div>
<p style={{ margin: "6px 0 0", fontSize: 12, lineHeight: 1.5, opacity: 0.68 }}>
{body}
</p>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 9,
fontSize: 11,
opacity: 0.55,
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M6 1.4 10 3v3.1c0 2.2-1.6 4-4 4.5-2.4-.5-4-2.3-4-4.5V3z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
{note}
</div>
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
<button
type="button"
onClick={() => answer(false)}
style={{
padding: "9px 13px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: CARD_EDGE,
borderRadius: 10,
opacity: 0.75,
cursor: "pointer",
}}
>
{denyLabel}
</button>
<button
type="button"
onClick={() => answer(true)}
style={{
flex: 1,
padding: "9px 13px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 10,
cursor: "pointer",
}}
>
{allowLabel}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/** Stand-in product surface — the screen the primer interrupts. */
function SampleSurface({ accent }: { accent: string }) {
const rows = [
{ label: "Write the launch brief", meta: "09:30" },
{ label: "Design review", meta: "11:00" },
{ label: "Collect reference links", meta: "14:00" },
{ label: "Send the weekly update", meta: "16:30" },
];
return (
<div aria-hidden style={{ position: "absolute", inset: 0, padding: 16 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 13.5, fontWeight: 680 }}>Today</span>
<span
style={{
width: 22,
height: 22,
borderRadius: 7,
background: `color-mix(in srgb, ${accent} 60%, transparent)`,
}}
/>
</div>
{rows.map((row) => (
<div
key={row.label}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "10px 0",
borderBottom: `1px solid ${tone(8)}`,
fontSize: 12,
opacity: 0.72,
}}
>
<span
style={{
width: 13,
height: 13,
borderRadius: 999,
border: `1.4px solid ${tone(30)}`,
}}
/>
{row.label}
<span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.7 }}>
{row.meta}
</span>
</div>
))}
</div>
);
}About this pattern
The card shown before the system prompt, because the OS dialog can only be spent once. The scrim fades, the card rises on a spring that does not overshoot — a card asking for trust must not look like it is selling something — and only then does the diagram inside fill in, one item at a time, showing the thing the permission would add rather than describing it. Sitting above a scrim, the card cannot be translucent, so it is painted with the CSS system colors Canvas and CanvasText and follows the host app's color scheme. Reduced motion keeps the arrival and the fill, and drops the travel from both.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Permission prompt
An illustrated explanation shown before the operating system's access prompt.
Related patterns
- Notification Opt-inAn example alert drops in and two more fan out behind it, then the permission question reads underneath.
- First Action NudgeAfter a pause with nothing pressed, a slow halo starts breathing out of the one button worth pressing.
- Setup Checklist CompleteA finished task fills its box, draws its mark, sweeps a rule through the label, and moves the counter up by one.