All patterns

Permission Request Rise

A permission card rises with its icon settling a beat before the explanation.

feedbackpremiumfriendlyautomatic · finite · intermediate · ~0.9s
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.

256 lines · react + motion only
import { motion, useReducedMotion, type Variants } from "motion/react";

/**
 * Vibary · Permission Request Rise
 *
 * The pre-prompt that earns the system dialog: the card rises into
 * place, its icon settles a beat ahead of everything else, and the
 * explanation arrives in reading order behind it.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `title`, `body`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PermissionRequestRiseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  title?: string;
  body?: string;
  /** The affirmative button. */
  allowLabel?: string;
  /** The way out. Always offer one. */
  denyLabel?: string;
  onAllow?: () => void;
  onDeny?: () => void;
};

type VariantConfig = {
  /** px the card travels up as it lands. */
  riseY: number;
  /** Beat before the icon starts. */
  iconDelay: number;
  /** Gap between the icon settling and the text starting. */
  beat: number;
  /** Gap between each text row. */
  stagger: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  iconSpring: { type: "spring"; stiffness: number; damping: number };
};

// Damping ratios (damping / 2√stiffness) all sit at or above 0.8. A card
// that asks for something has to look composed while it asks; a bouncing
// permission prompt reads as a nag. Variants change travel and pacing.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Short rise, near-simultaneous content. For a prompt shown often.
  subtle: {
    riseY: 10,
    iconDelay: 0.04,
    beat: 0.08,
    stagger: 0.04,
    spring: { type: "spring", stiffness: 480, damping: 44 },
    iconSpring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // The icon lands, then the sentence. The all-purpose setting.
  default: {
    riseY: 16,
    iconDelay: 0.06,
    beat: 0.14,
    stagger: 0.06,
    spring: { type: "spring", stiffness: 400, damping: 38 },
    iconSpring: { type: "spring", stiffness: 380, damping: 32 },
  },
  // A longer beat, so the mark is read as a subject before the sentence
  // about it starts. For a first-run moment.
  playful: {
    riseY: 22,
    iconDelay: 0.08,
    beat: 0.18,
    stagger: 0.07,
    spring: { type: "spring", stiffness: 340, damping: 30 },
    iconSpring: { type: "spring", stiffness: 320, damping: 30 },
  },
};

const ACCENT = "#7C7CF0";

/** 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)`;

export default function PermissionRequestRise({
  variant = "default",
  title = "Turn on notifications",
  body = "We will let you know when a document you follow is updated or someone assigns you a review.",
  allowLabel = "Allow notifications",
  denyLabel = "Not now",
  onAllow,
  onDeny,
}: PermissionRequestRiseProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: the card and its contents arrive together on a
  // short fade. Nothing is lost — the order was pacing, not meaning.
  const card: Variants = reduceMotion
    ? {
        hidden: { opacity: 0 },
        shown: {
          opacity: 1,
          transition: { duration: 0.2, ease: "easeOut", staggerChildren: 0 },
        },
      }
    : {
        hidden: { opacity: 0, y: cfg.riseY },
        shown: {
          opacity: 1,
          y: 0,
          transition: {
            y: cfg.spring,
            // Opacity on its own quick curve; springing it looks muddy.
            opacity: { duration: 0.22, ease: "easeOut" },
            delayChildren: cfg.iconDelay,
            staggerChildren: cfg.beat,
          },
        },
      };

  // The icon is the one element allowed to scale: it is a mark, not type.
  const iconVariants: Variants = reduceMotion
    ? { hidden: { opacity: 0 }, shown: { opacity: 1 } }
    : {
        hidden: { opacity: 0, scale: 0.82, y: 8 },
        shown: {
          opacity: 1,
          scale: 1,
          y: 0,
          transition: {
            ...cfg.iconSpring,
            opacity: { duration: 0.2, ease: "easeOut" },
          },
        },
      };

  const group: Variants = {
    hidden: {},
    shown: { transition: { staggerChildren: reduceMotion ? 0 : cfg.stagger } },
  };

  // Text rows travel and fade; they never scale, and they never spring.
  const row: Variants = reduceMotion
    ? { hidden: { opacity: 1 }, shown: { opacity: 1 } }
    : {
        hidden: { opacity: 0, y: 8 },
        shown: {
          opacity: 1,
          y: 0,
          transition: { duration: 0.3, ease: "easeOut" },
        },
      };

  return (
    <motion.div
      variants={card}
      initial="hidden"
      animate="shown"
      style={{
        width: 296,
        padding: 20,
        borderRadius: 18,
        display: "grid",
        gap: 14,
        background: tone(6),
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 36px rgba(0,0,0,0.14)",
      }}
    >
      <motion.span
        aria-hidden
        variants={iconVariants}
        style={{
          width: 42,
          height: 42,
          borderRadius: 13,
          display: "grid",
          placeItems: "center",
          background: `color-mix(in srgb, ${ACCENT} 16%, transparent)`,
        }}
      >
        <svg width="21" height="21" viewBox="0 0 20 20" fill="none">
          <path
            d="M6 8.4a4 4 0 0 1 8 0c0 2.5.9 3.7 1.5 4.3h-11c.6-.6 1.5-1.8 1.5-4.3Z"
            stroke={ACCENT}
            strokeWidth="1.5"
            strokeLinejoin="round"
          />
          <path
            d="M8.4 14.5a1.7 1.7 0 0 0 3.2 0"
            stroke={ACCENT}
            strokeWidth="1.5"
            strokeLinecap="round"
          />
          <path
            d="M10 4.4V3.1"
            stroke={ACCENT}
            strokeWidth="1.5"
            strokeLinecap="round"
          />
        </svg>
      </motion.span>

      <motion.div variants={group} style={{ display: "grid", gap: 14 }}>
        <motion.div variants={row} style={{ display: "grid", gap: 5 }}>
          <span style={{ fontSize: 15.5, fontWeight: 650, lineHeight: 1.3 }}>
            {title}
          </span>
          <span style={{ fontSize: 12.5, opacity: 0.6, lineHeight: 1.5 }}>
            {body}
          </span>
        </motion.div>

        <motion.div variants={row} style={{ display: "flex", gap: 8 }}>
          <button
            type="button"
            onClick={onDeny}
            style={{
              flexShrink: 0,
              padding: "9px 14px",
              borderRadius: 10,
              background: "none",
              border: `1px solid ${tone(16)}`,
              color: "inherit",
              opacity: 0.7,
              fontSize: 12.5,
              fontWeight: 600,
              fontFamily: "inherit",
              cursor: "pointer",
            }}
          >
            {denyLabel}
          </button>
          <button
            type="button"
            onClick={onAllow}
            style={{
              flex: 1,
              padding: "9px 14px",
              borderRadius: 10,
              background: ACCENT,
              border: `1px solid ${ACCENT}`,
              color: "#FFFFFF",
              fontSize: 12.5,
              fontWeight: 600,
              fontFamily: "inherit",
              cursor: "pointer",
            }}
          >
            {allowLabel}
          </button>
        </motion.div>
      </motion.div>
    </motion.div>
  );
}

About this pattern

The primer that earns the system dialog. The card travels up on a flat spring, the icon settles first with a small scale — it is a mark, so it is allowed to — and only then does the sentence about it arrive, row by row, in reading order. The order is the argument: subject, reason, choice. The type itself never scales and never springs, and reduced motion collapses the whole sequence into one short fade, since the pacing was rhythm rather than meaning.

Notification opt-inLocation access primerCamera or microphone requestContacts sync consent

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

    Explains why it wants desktop notifications in its own card before the browser prompt appears.

Related patterns