Permission Scope List
A consent screen reveals each requested scope in turn and keeps the approve button inert until they have all landed.
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 · 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Permission prompt
Requests paced so the sentence is read before the button is reachable.
Related patterns
- Social Login ButtonsThe separator rules draw outward from their label, then the provider buttons rise in one after the next.
- Guest Mode LimitA guest hits the limit and the gate glides up over a soft fade, offering a way in.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.