All patterns

Notification Opt-in

An example alert drops in and two more fan out behind it, then the permission question reads underneath.

onboardingfriendlycalmautomatic · finite · starter · ~0.8s
Variant

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.

350 lines · react + motion only
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.

Push permission askOnboarding flowEmail digest opt-inSettings prompt

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Allow notifications?We'll tell you when an order clears or a teammate replies. Nothing else.
    Allow
    Not now
    Permission prompt

    A mock alert is displayed above the ask so the request has visible context.

Related patterns