All patterns

Session Lock Blur

An idle workspace softens behind a scrim while a lock card rises over it, keeping shape but not content.

authenticationpremiumcalmautomatic · finite · intermediate · ~0.9s
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.

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

/**
 * Vibary · Session Lock Blur
 *
 * An idle workspace softens behind a scrim and a lock card rises over
 * it: the content is unreadable, but the shape of where you were stays.
 *
 * Self-contained: depends only on `react` and `motion`. The lock card
 * uses the CSS system colors, so it lands light in a light app and dark
 * in a dark one.
 * Works with zero props; tune via `variant`, `idleMs`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SessionLockBlurProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Idle time before the screen locks itself, in ms. */
  idleMs?: number;
  /** Card heading. */
  title?: string;
  /** Line under the heading. */
  subtitle?: string;
  /** Primary button color. */
  accent?: string;
  /** Fires whenever the lock engages or clears. */
  onLockChange?: (locked: boolean) => void;
};

type VariantConfig = {
  /** Blur applied to the page behind, in px. */
  blur: number;
  /** How far the card travels before it lands, in px. */
  rise: number;
  /** Seconds the blur and scrim take to arrive. */
  veil: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the card carries text and never changes size — it
// translates and fades only, on springs above a 0.8 damping ratio. A
// lock screen that bounces into place undercuts the one thing it is
// there to say. Variants change how much of the page survives the blur
// and how far the card travels, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A light haze. For internal tools where the lock is a courtesy, not
  // a compliance control.
  subtle: {
    blur: 5,
    rise: 8,
    veil: 0.28,
    spring: { type: "spring", stiffness: 540, damping: 46 },
  },
  // Unreadable, still recognisable. The all-purpose setting.
  default: {
    blur: 9,
    rise: 14,
    veil: 0.36,
    spring: { type: "spring", stiffness: 440, damping: 40 },
  },
  // A heavier veil that arrives more slowly, so the lock reads as the
  // room going quiet rather than a shutter dropping.
  playful: {
    blur: 14,
    rise: 20,
    veil: 0.46,
    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 or border correctly toned in
 *  either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const ROWS = [
  ["Q3 revenue summary", "Edited 4 minutes ago"],
  ["Regional breakdown", "Shared with 9 people"],
  ["Forecast worksheet", "Draft"],
];

export default function SessionLockBlur({
  variant = "default",
  idleMs = 1200,
  title = "Session locked",
  subtitle = "You stepped away. Confirm it's you to pick up where you left off.",
  accent = "#5B5BD6",
  onLockChange,
}: SessionLockBlurProps) {
  const [locked, setLocked] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // The idle timer is the trigger, and it arms once. Replace it with
  // your own inactivity source — the motion below does not care what
  // decided to lock.
  useEffect(() => {
    const timer = setTimeout(() => {
      setLocked(true);
      onLockChange?.(true);
    }, idleMs);
    return () => clearTimeout(timer);
    // Deliberately mount-only: re-arming on every unlock would turn a
    // one-shot lock into a loop.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [idleMs]);

  return (
    <div
      style={{
        position: "relative",
        width: 336,
        height: 292,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(4),
        color: "inherit",
        overflow: "hidden",
      }}
    >
      {/* The page behind. Blur is the one non-transform property animated
          here, because the blur *is* the pattern: it is what makes the
          content unreadable while leaving the layout recognisable, which
          a plain dim cannot do. It runs on the whole layer at once, so
          it stays a single compositor-friendly filter. */}
      <motion.div
        aria-hidden={locked}
        animate={{
          filter: locked ? `blur(${cfg.blur}px)` : "blur(0px)",
          opacity: locked ? 0.55 : 1,
        }}
        transition={{
          duration: reduceMotion ? 0.001 : cfg.veil,
          ease: "easeOut",
        }}
        style={{ padding: 16 }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 9,
            paddingBottom: 12,
            borderBottom: `1px solid ${tone(10)}`,
          }}
        >
          <span
            aria-hidden
            style={{
              width: 22,
              height: 22,
              borderRadius: 7,
              background: accent,
            }}
          />
          <span style={{ fontSize: 13, fontWeight: 650 }}>Meridian</span>
          <span style={{ marginLeft: "auto", fontSize: 11.5, opacity: 0.5 }}>
            Documents
          </span>
        </div>

        {ROWS.map(([name, detail]) => (
          <div
            key={name}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 10,
              padding: "11px 0",
              borderBottom: `1px solid ${tone(8)}`,
            }}
          >
            <span
              aria-hidden
              style={{
                width: 26,
                height: 26,
                borderRadius: 8,
                background: tone(10),
              }}
            />
            <span>
              <span
                style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
              >
                {name}
              </span>
              <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                {detail}
              </span>
            </span>
          </div>
        ))}
      </motion.div>

      <AnimatePresence>
        {locked && (
          <motion.div
            key="scrim"
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: 0.16 } }}
            transition={{ duration: cfg.veil, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              // A scrim darkens in both themes — light or dark, a locked
              // page recedes — so this one stays literal.
              background: "rgba(0,0,0,0.34)",
            }}
          />
        )}
      </AnimatePresence>

      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "grid",
          placeItems: "center",
          padding: 22,
          pointerEvents: "none",
        }}
      >
        <AnimatePresence>
          {locked && (
            <motion.div
              key="card"
              role="dialog"
              aria-modal="true"
              aria-label={title}
              initial={
                reduceMotion
                  ? { opacity: 0 }
                  : { opacity: 0, y: cfg.rise }
              }
              animate={{ opacity: 1, y: 0 }}
              exit={{
                opacity: 0,
                y: reduceMotion ? 0 : cfg.rise * 0.5,
                transition: { duration: 0.16, ease: "easeIn" },
              }}
              transition={
                reduceMotion
                  ? { duration: 0.18, ease: "easeOut" }
                  : {
                      ...cfg.spring,
                      delay: cfg.veil * 0.4,
                      opacity: { duration: 0.2, delay: cfg.veil * 0.4 },
                    }
              }
              style={{
                width: "100%",
                pointerEvents: "auto",
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                gap: 10,
                padding: "18px 18px 16px",
                borderRadius: 14,
                textAlign: "center",
                // The one surface here that cannot be translucent: it
                // sits over a blurred page, and a see-through lock card
                // would be as unreadable as what it is covering.
                // `Canvas`/`CanvasText` are the CSS system colors for
                // page background and page text, so the card lands light
                // in a light app and dark in a dark one.
                background: "Canvas",
                color: "CanvasText",
                border: `1px solid ${tone(14)}`,
                boxShadow: "0 18px 44px rgba(0,0,0,0.30)",
              }}
            >
              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 36,
                  height: 36,
                  borderRadius: 11,
                  background: tone(10),
                  color: accent,
                }}
              >
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
                  <rect
                    x="4.5"
                    y="10.5"
                    width="15"
                    height="10"
                    rx="2.6"
                    stroke="currentColor"
                    strokeWidth="1.9"
                  />
                  {/* The shackle strokes closed as the card lands, so the
                      glyph performs the verb instead of illustrating it. */}
                  <motion.path
                    d="M8 10.5V7.8a4 4 0 0 1 8 0v2.7"
                    stroke="currentColor"
                    strokeWidth="1.9"
                    strokeLinecap="round"
                    initial={{ pathLength: reduceMotion ? 1 : 0 }}
                    animate={{ pathLength: 1 }}
                    transition={{
                      duration: reduceMotion ? 0 : 0.34,
                      delay: reduceMotion ? 0 : 0.14,
                      ease: "easeOut",
                    }}
                  />
                </svg>
              </span>

              <div style={{ fontSize: 14.5, fontWeight: 650 }}>{title}</div>
              <p
                style={{
                  margin: 0,
                  maxWidth: 232,
                  fontSize: 12,
                  lineHeight: 1.5,
                  opacity: 0.62,
                }}
              >
                {subtitle}
              </p>

              <button
                type="button"
                onClick={() => {
                  setLocked(false);
                  onLockChange?.(false);
                }}
                style={{
                  width: "100%",
                  marginTop: 2,
                  padding: "9px 14px",
                  fontSize: 13,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "#ffffff",
                  background: accent,
                  border: "none",
                  borderRadius: 9,
                  cursor: "pointer",
                }}
              >
                Unlock
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

What an inactivity timeout should feel like: protective, not punitive. The page behind is blurred rather than replaced, so the layout you were working in stays recognisable and coming back is orientation rather than rediscovery — a plain dim cannot do that, which is why blur is the one non-transform property this pattern animates. The scrim and the blur arrive together as a single veil, the card follows a beat later so the eye has somewhere to land, and the shackle strokes closed as it arrives so the glyph performs the verb instead of illustrating it. The card is opaque on purpose: a translucent panel over a blurred page is as unreadable as the page.

Idle session lockPrivacy screen on a shared machineRe-authentication before sensitive dataKiosk or shared-device timeout

Where it shows up

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

  • Sign inUse the address your team invited
    Email
    nils@ridgeline.co
    Password
    ••••••••••
    Continue
    Sign-in screen

    The desktop behind stays visible as shape while becoming unreadable as content.

Related patterns