All patterns

Recovery Codes Reveal

Blurred backup codes sharpen across the grid once revealed, then copy in one press.

authenticationpremiumminimalinteraction · finite · starter · ~0.6s
Interactive · click to play
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.

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

/**
 * Vibary · Recovery Codes Reveal
 *
 * Backup codes are the one screen where hiding content is the feature.
 * The codes are laid out and legible as shapes from the first frame, but
 * blurred, so the user knows what they are looking at before they choose
 * to expose it — and the reveal sharpens them in place rather than
 * swapping one panel for another.
 *
 * The codes below are deliberate placeholders (`XXXX-XXXX`). Replace them
 * with values from your server, and never log or screenshot real ones.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals mix from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `codes`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RecoveryCodesRevealProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Codes to show. Defaults to obvious placeholders. */
  codes?: string[];
  /** Accent for the reveal control and the copy confirmation. */
  accent?: string;
  /** Fires when the codes are exposed. */
  onReveal?: () => void;
};

type VariantConfig = {
  /** Blur applied to a hidden code, in px. */
  blur: number;
  /** Gap between consecutive codes sharpening. */
  stagger: number;
  /** How long one code takes to sharpen. */
  sharpen: number;
  /** Travel of a code as it sharpens, in px. */
  rise: number;
};

// Quality rule: the codes are text, so they translate and sharpen — they
// never scale and never bounce. There is no spring in this pattern at
// all: a blur that overshoots would be unreadable mid-flight.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a cut. For security pages where the reveal is a formality.
  subtle: { blur: 5, stagger: 0.02, sharpen: 0.2, rise: 0 },
  // The all-purpose setting: the grid resolves left to right, top to
  // bottom, fast enough to read as one gesture.
  default: { blur: 7, stagger: 0.04, sharpen: 0.28, rise: 3 },
  // A wider sweep, so the eye follows the reveal across the grid.
  playful: { blur: 9, stagger: 0.06, sharpen: 0.36, rise: 5 },
};

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

// Placeholders on purpose — nothing here should resemble a real secret.
const PLACEHOLDER_CODES = [
  "XXXX-XXXX",
  "XXXX-XXXX",
  "XXXX-XXXX",
  "XXXX-XXXX",
  "XXXX-XXXX",
  "XXXX-XXXX",
  "XXXX-XXXX",
  "XXXX-XXXX",
];

export default function RecoveryCodesReveal({
  variant = "default",
  codes = PLACEHOLDER_CODES,
  accent = "#5B5BD6",
  onReveal,
}: RecoveryCodesRevealProps) {
  const [revealed, setRevealed] = useState(false);
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  useEffect(() => () => clearTimeout(timer.current), []);

  const reveal = () => {
    setRevealed(true);
    onReveal?.();
  };

  const copyAll = () => {
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1800);
  };

  return (
    <div
      style={{
        width: 320,
        padding: 18,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
        <span
          aria-hidden
          style={{
            display: "grid",
            placeItems: "center",
            width: 28,
            height: 28,
            borderRadius: 9,
            background: tone(9),
            color: accent,
          }}
        >
          <svg
            width="15"
            height="15"
            viewBox="0 0 20 20"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.6"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M10 3.2l5.4 2v4.4c0 3.1-2.2 5.7-5.4 6.8-3.2-1.1-5.4-3.7-5.4-6.8V5.2l5.4-2z" />
          </svg>
        </span>
        <div>
          <div style={{ fontSize: 13.5, fontWeight: 650 }}>Recovery codes</div>
          <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}>
            Each one signs you in once
          </div>
        </div>
      </div>

      <div style={{ position: "relative", marginTop: 14 }}>
        <div
          role="list"
          aria-label="Recovery codes"
          style={{
            display: "grid",
            gridTemplateColumns: "1fr 1fr",
            gap: 8,
          }}
        >
          {codes.map((code, index) => (
            <motion.div
              key={index}
              role="listitem"
              // Blur is the whole point here, so it is the property that
              // animates — kept to a short ease, because a code that is
              // half-legible for long is worse than one that is hidden.
              initial={false}
              animate={{
                filter:
                  revealed || reduceMotion
                    ? "blur(0px)"
                    : `blur(${cfg.blur}px)`,
                opacity: revealed ? 1 : 0.4,
                y: revealed || reduceMotion ? 0 : cfg.rise,
              }}
              transition={
                reduceMotion
                  ? { duration: 0.12 }
                  : {
                      duration: cfg.sharpen,
                      delay: revealed ? index * cfg.stagger : 0,
                      ease: "easeOut",
                    }
              }
              style={{
                padding: "9px 10px",
                borderRadius: 9,
                background: tone(8),
                border: `1px solid ${tone(10)}`,
                fontSize: 12.5,
                fontWeight: 600,
                letterSpacing: 0.6,
                textAlign: "center",
                fontFamily:
                  "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
                userSelect: revealed ? "text" : "none",
              }}
            >
              {code}
            </motion.div>
          ))}
        </div>

        {/* The control sits over the grid rather than beside it: the
            thing you are unlocking is directly underneath your finger. */}
        <AnimatePresence>
          {!revealed && (
            <motion.div
              key="veil"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: reduceMotion ? 0.1 : 0.22, ease: "easeOut" }}
              style={{
                position: "absolute",
                inset: 0,
                display: "grid",
                placeItems: "center",
              }}
            >
              <button
                type="button"
                onClick={reveal}
                style={{
                  display: "inline-flex",
                  alignItems: "center",
                  gap: 7,
                  padding: "9px 15px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  borderRadius: 999,
                  border: "none",
                  background: accent,
                  color: "#FFFFFF",
                  cursor: "pointer",
                  boxShadow: "0 6px 18px rgba(0,0,0,0.2)",
                }}
              >
                <svg
                  width="14"
                  height="14"
                  viewBox="0 0 20 20"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.7"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden
                >
                  <path d="M2.6 10S5.4 5.4 10 5.4 17.4 10 17.4 10 14.6 14.6 10 14.6 2.6 10 2.6 10z" />
                  <circle cx="10" cy="10" r="2.2" />
                </svg>
                Reveal codes
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 10,
          marginTop: 14,
        }}
      >
        <span style={{ fontSize: 11.5, opacity: 0.5, lineHeight: 1.45 }}>
          Store them somewhere only you can reach
        </span>

        <button
          type="button"
          onClick={copyAll}
          disabled={!revealed}
          style={{
            flexShrink: 0,
            display: "inline-flex",
            alignItems: "center",
            gap: 6,
            width: 96,
            justifyContent: "center",
            padding: "8px 10px",
            fontSize: 12,
            fontWeight: 600,
            fontFamily: "inherit",
            borderRadius: 9,
            border: `1px solid ${tone(14)}`,
            background: tone(8),
            color: "inherit",
            opacity: revealed ? 1 : 0.4,
            cursor: revealed ? "pointer" : "default",
          }}
        >
          {/* The button holds its width, so confirming does not shove the
              line of guidance beside it. */}
          <AnimatePresence mode="wait" initial={false}>
            <motion.span
              key={copied ? "copied" : "idle"}
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 5 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -5 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
            >
              {copied ? (
                <>
                  <svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden>
                    <motion.path
                      d="M5.5 10.4l3 3 6-6.4"
                      stroke={accent}
                      strokeWidth="2.1"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      initial={{ pathLength: reduceMotion ? 1 : 0 }}
                      animate={{ pathLength: 1 }}
                      transition={{ duration: reduceMotion ? 0 : 0.24, ease: "easeOut" }}
                    />
                  </svg>
                  Copied
                </>
              ) : (
                "Copy all"
              )}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>
    </div>
  );
}

About this pattern

Backup codes are the rare screen where hiding the content is the feature. The grid is laid out and readable as shapes from the first frame, so the user understands what they are about to expose, and the reveal sharpens the codes in place instead of swapping one panel for another. Blur is the property that animates because blur is the point, kept to a short ease — a code that sits half-legible is worse than one that is hidden. Codes are text, so they translate and sharpen but never scale, and there is no spring anywhere in the pattern: an overshooting blur would be unreadable mid-flight. The copy control holds its width so confirming never shoves the guidance beside it.

Two-factor backup codes screenOne-time recovery keys handoverSensitive value revealAccount recovery setup

Where it shows up

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

  • 10:15
    Enter your codeWe sent six digits to your phone
    Verification code
    4 8 2 1 0 6
    Verify
    Two-factor prompt

    A one-time handover screen with a grid of codes and a single copy action.

Related patterns