All patterns

Permission Needed

The shape of the content stays visible out of focus while a lock closes over it and offers a way to ask.

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

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

Restricted folderPaid tier contentTeam-only workspaceAdmin settings without rights

Where it shows up

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

  • Ridgeline
    Files
    Recent
    Shared
    Starred
    Trash
    FilesNew
    This folder is emptyWhen there is something to show, it appears here.
    File browser

    A folder you are not shared on shows a locked panel with a request-access action.

Related patterns