Permission Needed
The shape of the content stays visible out of focus while a lock closes over it and offers a way to ask.
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 Needed
*
* Content you are not cleared for should look like content, not like an
* error. The shape of what is behind stays visible and out of focus, the
* shackle of the lock draws closed over it in one stroke, and the way to
* ask for access follows. Requesting swaps the label in place — the card
* never resizes, because nothing about the situation has changed yet.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PermissionNeededProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline of the locked state. */
title?: string;
/** One line saying who can grant access. */
message?: string;
/** Button labels, before and after the request is sent. */
requestLabel?: string;
sentLabel?: string;
/** Line that fades in once the request is away. */
confirmation?: string;
/** Fires when access is requested. */
onRequest?: () => void;
/** Accent used for the action. A literal brand color. */
accent?: string;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Seconds the shackle takes to draw. */
drawSeconds: number;
/** px the copy travels on its way in. */
rise: number;
fadeSeconds: number;
/** Blur applied to the shape of the content behind, in px. */
blur: number;
bodySpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the lock body is the only sprung element and sits above
// 0.88 damping ratio (damping / 2√stiffness) in every variant, so the
// lock closes once and stays closed. The shackle draws on a tween — a
// stroke that overshoots its own end point is not a stroke.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Understated, for a locked panel inside a page of unlocked ones.
subtle: {
drawSeconds: 0.34,
rise: 5,
fadeSeconds: 0.24,
blur: 4,
bodySpring: { type: "spring", stiffness: 460, damping: 44 },
},
// The all-purpose setting: the stroke is followed easily by eye.
default: {
drawSeconds: 0.46,
rise: 8,
fadeSeconds: 0.3,
blur: 5,
bodySpring: { type: "spring", stiffness: 380, damping: 38 },
},
// Slower and deeper, for a full-page access wall.
playful: {
drawSeconds: 0.6,
rise: 12,
fadeSeconds: 0.36,
blur: 7,
bodySpring: { type: "spring", stiffness: 300, damping: 31 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps the obscured content, the scrim and the lock
* correct on light and dark pages. The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function PermissionNeeded({
variant = "default",
title = "You don't have access yet",
message = "The workspace owner can share this folder with you.",
requestLabel = "Request access",
sentLabel = "Request sent",
confirmation = "You'll get a notification when it's approved.",
onRequest,
accent = "#5B5BD6",
width = 320,
}: PermissionNeededProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [sent, setSent] = useState(false);
const rise = reduceMotion ? 0 : cfg.rise;
const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };
const request = () => {
if (sent) return;
setSent(true);
onRequest?.();
};
return (
<div
style={{
position: "relative",
width,
boxSizing: "border-box",
overflow: "hidden",
}}
>
{/* The shape of what is behind. Blurred and static: it is scenery,
and scenery that moves would invite a second look at something
the reader is not allowed to read. */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
padding: "18px 20px",
filter: `blur(${cfg.blur}px)`,
opacity: 0.45,
userSelect: "none",
overflow: "hidden",
}}
>
{["72%", "94%", "56%", "88%", "40%"].map((barWidth, index) => (
<div
key={barWidth}
style={{
width: barWidth,
height: index === 0 ? 11 : 8,
borderRadius: 5,
background: tone(index === 0 ? 16 : 11),
marginTop: index === 0 ? 0 : 11,
}}
/>
))}
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: cfg.fadeSeconds * 1.4, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
background: tone(6),
}}
/>
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
padding: "22px 22px 20px",
}}
>
<motion.div
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.bodySpring, opacity: fade }
}
style={{ lineHeight: 0, marginBottom: 12 }}
>
<LockMark
drawSeconds={reduceMotion ? 0 : cfg.drawSeconds}
instant={Boolean(reduceMotion)}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...fade, delay: 0.12 }}
style={{ fontSize: 14.5, fontWeight: 640 }}
>
{title}
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 0.58, y: 0 }}
transition={{ ...fade, delay: 0.18 }}
style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5, maxWidth: 236 }}
>
{message}
</motion.div>
<motion.button
type="button"
onClick={request}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...fade, delay: 0.25 }}
style={{
font: "inherit",
fontSize: 12.5,
fontWeight: 600,
color: "#fff",
background: accent,
border: "none",
borderRadius: 10,
padding: "9px 15px",
marginTop: 15,
cursor: sent ? "default" : "pointer",
}}
>
{/* Both labels share one cell, so sending the request cannot
change the width of the button under the cursor. */}
<span style={{ display: "grid", placeItems: "center" }}>
<motion.span
animate={{ opacity: sent ? 0 : 1 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
>
{requestLabel}
</motion.span>
<motion.span
animate={{ opacity: sent ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
>
{sentLabel}
</motion.span>
</span>
</motion.button>
{/* Reserved from the first frame, so the confirmation appearing
cannot push the card taller. */}
<motion.div
initial={false}
animate={{ opacity: sent ? 0.5 : 0 }}
transition={{ ...fade, delay: sent ? 0.08 : 0 }}
style={{ fontSize: 11, marginTop: 11, minHeight: 14 }}
>
{confirmation}
</motion.div>
</div>
</div>
);
}
/** Line art authored inline: the shackle draws closed, then the body is
* already there under it. Stroked in `currentColor` so it inherits the
* page theme on light and dark alike. */
function LockMark({
drawSeconds,
instant,
}: {
drawSeconds: number;
instant: boolean;
}) {
return (
<svg width="44" height="48" viewBox="0 0 44 48" fill="none" aria-hidden>
<motion.path
d="M14 21v-5.5a8 8 0 0 1 16 0V21"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
opacity="0.34"
initial={{ pathLength: instant ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: drawSeconds, ease: "easeInOut", delay: 0.08 }}
/>
<rect
x="9"
y="21"
width="26"
height="19"
rx="4"
stroke="currentColor"
strokeWidth="1.6"
opacity="0.34"
/>
<path
d="M22 28.5v5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
opacity="0.22"
/>
</svg>
);
}About this pattern
Restricted content should read as content held back, not as a page that failed. The rows behind stay in place, blurred and still, so the reader can see there is something real here; a scrim settles over them, the shackle of the lock draws closed in one stroke, and the request action follows. Sending the request swaps the button label inside a slot sized for both readings, so the card holds its exact size — nothing has actually changed yet, and motion that implied otherwise would be a lie. Reduced motion presents the closed lock without the stroke.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- File browser
A folder you are not shared on shows a locked panel with a request-access action.
Related patterns
- Coming SoonA locked preview of an unreleased area, crossed once by a gloss and dated by a badge that lands last.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.