All patterns

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.

onboardingcalmfriendlyautomatic · finite · intermediate · ~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.

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

Permission pre-promptCalendar or contacts accessLocation requestTrust explanation

Where it shows up

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

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

    An illustrated explanation shown before the operating system's access prompt.

Related patterns