All patterns

Permission Scope List

A consent screen reveals each requested scope in turn and keeps the approve button inert until they have all landed.

authenticationcalmminimalautomatic · finite · starter · ~1.2s
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.

334 lines · react + motion only
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Permission Scope List
 *
 * A consent screen that reveals each requested scope in turn and keeps
 * the approve button inert until the last one has landed.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `appName`, `scopes`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PermissionScope = {
  /** What the app is asking for. */
  title: string;
  /** The limit on it — the line that decides whether people say yes. */
  detail: string;
  /** Which of the bundled glyphs to draw. */
  glyph?: "doc" | "write" | "people" | "bell";
};

export type PermissionScopeListProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Name of the app requesting access. */
  appName?: string;
  /** Account the access is granted against. */
  accountName?: string;
  /** Scopes, in reading order. */
  scopes?: PermissionScope[];
  /** Primary button color. */
  accent?: string;
  /** Fires when the approve button is pressed. */
  onApprove?: () => void;
};

type VariantConfig = {
  /** Travel before a scope row lands, in px. */
  rise: number;
  /** Gap between consecutive rows — the reading pace of the list. */
  stagger: number;
  /** Beat before the first row moves. */
  lead: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the pace is the point. These rows are being read, not
// admired, so they translate and fade at a rhythm slow enough to follow
// and nothing scales or rebounds — every spring sits above a 0.8 damping
// ratio. Variants change the reading pace, never the number of settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk. For a consent screen returning users have seen before.
  subtle: {
    rise: 6,
    stagger: 0.07,
    lead: 0.05,
    spring: { type: "spring", stiffness: 540, damping: 46 },
  },
  // Paced for reading: each line gets its own beat. The all-purpose
  // setting.
  default: {
    rise: 10,
    stagger: 0.12,
    lead: 0.1,
    spring: { type: "spring", stiffness: 440, damping: 40 },
  },
  // Deliberately unhurried, for scopes that grant real reach into an
  // account.
  playful: {
    rise: 15,
    stagger: 0.17,
    lead: 0.14,
    spring: { type: "spring", stiffness: 380, damping: 34 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields a surface, border or fill correctly toned
 *  in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_SCOPES: PermissionScope[] = [
  {
    title: "Read your documents",
    detail: "Titles, contents and comments",
    glyph: "doc",
  },
  {
    title: "Create and update reports",
    detail: "Only inside the Reports folder",
    glyph: "write",
  },
  {
    title: "See your team roster",
    detail: "Names and roles, never contact details",
    glyph: "people",
  },
  {
    title: "Send you notifications",
    detail: "About jobs you started yourself",
    glyph: "bell",
  },
];

function ScopeGlyph({ glyph }: { glyph: PermissionScope["glyph"] }) {
  return (
    <svg
      aria-hidden
      width="14"
      height="14"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.4"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {glyph === "write" ? (
        <>
          <path d="M3 8v4.6h4.6L13 7.2 8.4 2.6 3 8z" />
          <path d="M7.6 3.4 12.2 8" />
        </>
      ) : glyph === "people" ? (
        <>
          <circle cx="6.2" cy="6" r="2.4" />
          <path d="M2.2 13c0-2.2 1.8-3.6 4-3.6s4 1.4 4 3.6" />
          <path d="M11 4.2a2.2 2.2 0 0 1 0 4.2M12.2 12.6c0-1.5-.6-2.6-1.6-3.2" />
        </>
      ) : glyph === "bell" ? (
        <>
          <path d="M4.4 11.2V7.4a3.6 3.6 0 0 1 7.2 0v3.8" />
          <path d="M3.2 11.2h9.6M6.9 13.4h2.2" />
        </>
      ) : (
        <>
          <path d="M4 2.4h5l3 3v8.2H4z" />
          <path d="M9 2.4v3h3M6 9h4M6 11.2h2.6" />
        </>
      )}
    </svg>
  );
}

export default function PermissionScopeList({
  variant = "default",
  appName = "Atlas Reports",
  accountName = "Meridian",
  scopes = DEFAULT_SCOPES,
  accent = "#5B5BD6",
  onApprove,
}: PermissionScopeListProps) {
  // The approve button is armed by the motion itself: it becomes usable
  // only once the whole list has finished arriving, so nobody can grant
  // a scope that was still animating in when they clicked.
  const [read, setRead] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const container = {
    hidden: {},
    visible: {
      transition: {
        delayChildren: reduceMotion ? 0.02 : cfg.lead,
        staggerChildren: reduceMotion ? 0.05 : cfg.stagger,
      },
    },
  };

  const row = reduceMotion
    ? {
        hidden: { opacity: 0 },
        visible: {
          opacity: 1,
          transition: { duration: 0.16, ease: "easeOut" as const },
        },
      }
    : {
        hidden: { opacity: 0, y: cfg.rise },
        visible: {
          opacity: 1,
          y: 0,
          transition: {
            ...cfg.spring,
            // Opacity runs on its own short curve; springing a fade
            // leaves a long, muddy tail behind the movement.
            opacity: { duration: 0.2, ease: "easeOut" as const },
          },
        },
      };

  return (
    <motion.div
      initial="hidden"
      animate="visible"
      variants={container}
      style={{
        width: 300,
        display: "flex",
        flexDirection: "column",
        gap: 10,
        padding: 18,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        color: "inherit",
      }}
    >
      <motion.div variants={row}>
        <div style={{ fontSize: 14.5, fontWeight: 650, lineHeight: 1.35 }}>
          {appName} wants access to {accountName}
        </div>
        <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 3 }}>
          Review what it will be able to do.
        </div>
      </motion.div>

      <div
        style={{
          display: "flex",
          flexDirection: "column",
          gap: 2,
          padding: "4px 0",
          borderTop: `1px solid ${tone(10)}`,
          borderBottom: `1px solid ${tone(10)}`,
        }}
      >
        {scopes.map((scope) => (
          <motion.div
            key={scope.title}
            variants={row}
            style={{
              display: "flex",
              alignItems: "flex-start",
              gap: 10,
              padding: "8px 2px",
            }}
          >
            <span
              aria-hidden
              style={{
                display: "grid",
                placeItems: "center",
                width: 24,
                height: 24,
                flex: "0 0 auto",
                borderRadius: 8,
                background: tone(9),
                color: accent,
              }}
            >
              <ScopeGlyph glyph={scope.glyph} />
            </span>
            <span>
              <span
                style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
              >
                {scope.title}
              </span>
              <span
                style={{
                  display: "block",
                  fontSize: 11,
                  opacity: 0.52,
                  marginTop: 1,
                }}
              >
                {scope.detail}
              </span>
            </span>
          </motion.div>
        ))}
      </div>

      <motion.div variants={row} style={{ display: "flex", gap: 8 }}>
        <button
          type="button"
          style={{
            flex: "0 0 auto",
            padding: "9px 14px",
            fontSize: 12.5,
            fontWeight: 600,
            fontFamily: "inherit",
            color: "inherit",
            background: "transparent",
            border: `1px solid ${tone(16)}`,
            borderRadius: 10,
            cursor: "pointer",
          }}
        >
          Cancel
        </button>
        <button
          type="button"
          disabled={!read}
          onClick={() => onApprove?.()}
          style={{
            flex: 1,
            padding: "9px 14px",
            fontSize: 12.5,
            fontWeight: 600,
            fontFamily: "inherit",
            color: read ? "#ffffff" : "inherit",
            background: read ? accent : tone(8),
            border: read ? "none" : `1px solid ${tone(14)}`,
            borderRadius: 10,
            opacity: read ? 1 : 0.5,
            cursor: read ? "pointer" : "default",
            // Arming is a state change, not a move: the label is text
            // and stays exactly where it was.
            transition: "background-color 240ms ease-out, opacity 240ms ease-out",
          }}
        >
          Allow access
        </button>
      </motion.div>

      {/* The final row owns the gate: with a stagger the container
          finishes before its children do, so the sequence is only really
          over down here — which is also the first moment the whole list
          has been on screen to read. */}
      <motion.div
        variants={row}
        onAnimationComplete={() => setRead(true)}
        style={{ fontSize: 10.5, opacity: 0.45 }}
      >
        You can withdraw this at any time from Settings.
      </motion.div>
    </motion.div>
  );
}

About this pattern

Consent screens are the one place in a product where the interface has an interest in being read slowly. The scopes arrive at a deliberate reading pace rather than a decorative one — far enough apart that the eye finishes a line before the next begins — and each one only translates and fades, because a permission that scales into place is asking to be looked at instead of understood. The pace also does something functional: the approve button is armed by the animation itself and stays inert until the whole list has arrived, so nobody grants a scope that was still moving when they clicked. The limits sit on their own second line, which is the line that actually decides the answer.

OAuth consent screenConnect a third-party appInstall an integrationGrant an API token its scopes

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

    Requests paced so the sentence is read before the button is reachable.

Related patterns