Notification Opt-in
An example alert drops in and two more fan out behind it, then the permission question reads underneath.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Notification Opt-in
*
* A sample notification fans out above the ask, so the person sees what
* they would actually receive before deciding — the value arrives
* first, the question second.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `title`, `body`, `sample`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type NotificationOptInProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline of the ask. */
title?: string;
/** Supporting line under the headline. */
body?: string;
/** Primary button label. */
allowLabel?: string;
/** Secondary button label. */
dismissLabel?: string;
/** The example alert shown above the ask. */
sample?: { source: string; message: string; time: string };
/** Primary button and mark color. */
accent?: string;
/** Fires when the ask is accepted. */
onAllow?: () => void;
/** Fires when the ask is declined. */
onDismiss?: () => void;
};
type VariantConfig = {
/** How far the front alert drops in from, in px. */
drop: number;
/** How far the two cards behind it slide out of the stack, in px. */
fan: number;
arrive: { type: "spring"; stiffness: number; damping: number };
/** Gap between the alert landing and the ask reading, in seconds. */
beat: number;
};
// Quality rule: this is a permission prompt, so nothing may read as
// pressure. Every spring sits at or above a 0.8 damping ratio and the
// stack lands once — an alert that bounces into view is exactly the
// behavior the person is being asked to consent to.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a drop, tight beats. For a prompt shown mid-session.
subtle: {
drop: 10,
fan: 4,
arrive: { type: "spring", stiffness: 520, damping: 46 },
beat: 0.06,
},
// The stack fans, then the ask reads. The all-purpose setting.
default: {
drop: 16,
fan: 6,
arrive: { type: "spring", stiffness: 420, damping: 38 },
beat: 0.09,
},
// A longer fall and a wider fan, for a full-screen onboarding moment.
playful: {
drop: 22,
fan: 9,
arrive: { type: "spring", stiffness: 340, damping: 32 },
beat: 0.12,
},
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE = {
source: "Northwind",
message: "Riley approved your budget request",
time: "now",
};
export default function NotificationOptIn({
variant = "default",
title = "Turn on notifications",
body = "Only the things that need you: approvals, mentions and direct messages. Nothing else, ever.",
allowLabel = "Allow",
dismissLabel = "Not now",
sample = SAMPLE,
accent = "#5B5BD6",
onAllow,
onDismiss,
}: NotificationOptInProps) {
const [choice, setChoice] = useState<"allow" | "dismiss" | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: the same staging, expressed as fades. The order
// still teaches — alert, then question — it just does not travel.
const rise = (delay: number) =>
reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.2, delay: delay * 0.5, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.32, delay, ease: "easeOut" as const },
};
const choose = (next: "allow" | "dismiss") => {
setChoice(next);
if (next === "allow") onAllow?.();
else onDismiss?.();
};
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
{/* The stack: two blank cards behind stand for the alerts that
would follow, so one example implies a stream without printing
three of them. */}
<div style={{ position: "relative", height: 92, marginBottom: 16 }}>
{[2, 1].map((depth) => (
<motion.div
key={depth}
aria-hidden
// Starts flush with the front alert — hidden behind it — and
// slides up into the stack, so the fan reads as "and more
// like this" rather than as three separate arrivals.
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: depth * cfg.fan }
}
animate={{ opacity: depth === 2 ? 0.3 : 0.55, y: 0 }}
transition={
reduceMotion
? { duration: 0.24, ease: "easeOut" }
: { ...cfg.arrive, delay: cfg.beat * (3 - depth) }
}
style={{
position: "absolute",
left: depth * cfg.fan,
right: depth * cfg.fan,
top: 30 - depth * cfg.fan,
height: 56,
borderRadius: 14,
border: `1px solid ${tone(14)}`,
background: tone(8),
}}
/>
))}
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.drop }}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion ? { duration: 0.24, ease: "easeOut" } : cfg.arrive
}
style={{
position: "absolute",
left: 0,
right: 0,
top: 30,
display: "flex",
gap: 10,
alignItems: "flex-start",
padding: 12,
borderRadius: 14,
boxSizing: "border-box",
// The alert floats over the card, so it cannot be
// translucent. `Canvas`/`CanvasText` are the CSS system
// colors for page background and page text: the alert lands
// light in a light app, dark in a dark one, always legible.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 12px 28px rgba(0,0,0,0.18)",
}}
>
<span
aria-hidden
style={{
flex: "none",
display: "grid",
placeItems: "center",
width: 26,
height: 26,
borderRadius: 8,
background: accent,
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M8 2.4a3.5 3.5 0 0 0-3.5 3.5v2.3L3.4 10.3h9.2L11.5 8.2V5.9A3.5 3.5 0 0 0 8 2.4Z"
fill="#FFFFFF"
/>
<path
d="M6.5 11.7a1.6 1.6 0 0 0 3 0"
stroke="#FFFFFF"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "flex",
justifyContent: "space-between",
gap: 8,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.55,
}}
>
<span>{sample.source}</span>
<span>{sample.time}</span>
</span>
<span
style={{
display: "block",
marginTop: 3,
fontSize: 13,
lineHeight: 1.4,
}}
>
{sample.message}
</span>
</span>
</motion.div>
</div>
<motion.div {...rise(cfg.beat * 4)}>
<div style={{ fontSize: 16, fontWeight: 650, lineHeight: 1.3 }}>
{title}
</div>
<p
style={{
margin: "6px 0 0",
fontSize: 13,
lineHeight: 1.55,
opacity: 0.6,
}}
>
{body}
</p>
</motion.div>
{choice === null ? (
<motion.div
{...rise(cfg.beat * 6)}
style={{ display: "flex", gap: 8, marginTop: 14 }}
>
<button
type="button"
onClick={() => choose("dismiss")}
style={{
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: `1px solid ${tone(20)}`,
borderRadius: 9,
cursor: "pointer",
}}
>
{dismissLabel}
</button>
<button
type="button"
onClick={() => choose("allow")}
style={{
flex: 1,
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
cursor: "pointer",
}}
>
{allowLabel}
</button>
</motion.div>
) : (
<motion.div
role="status"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.26, ease: "easeOut" }}
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 14,
padding: "10px 12px",
fontSize: 12.5,
borderRadius: 10,
border: `1px solid ${tone(12)}`,
background: tone(6),
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
{choice === "allow" ? (
<path
d="M3.5 8.4 6.6 11.5 12.5 5"
stroke={accent}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path
d="M8 4.2v4.4M8 11.2v.6"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
opacity="0.6"
/>
)}
</svg>
<span style={{ opacity: 0.72 }}>
{choice === "allow"
? "Alerts are on. Change them any time in settings."
: "No alerts for now. Settings has the switch when you want it."}
</span>
</motion.div>
)}
</div>
);
}About this pattern
The permission ask that shows its own value first. An example alert lands at the top of the card, two blank cards fan out from behind it to imply the stream it belongs to, and only then does the headline and the pair of buttons settle in. The order is the argument: by the time the question is readable the person already knows what they are agreeing to receive. Nothing bounces and nothing pulses — a prompt that behaves like an interruption is a poor advertisement for permission to interrupt. The example alert is opaque and uses the CSS system colors, so it reads as a real notification on a light or dark page.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Permission prompt
A mock alert is displayed above the ask so the request has visible context.
Related patterns
- Permission PrimerThe page dims and an explanation rises, with a small diagram filling in to show what the permission would actually put on screen.
- 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.