Recovery Codes Reveal
Blurred backup codes sharpen across the grid once revealed, then copy in one press.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Two-factor prompt
A one-time handover screen with a grid of codes and a single copy action.
Related patterns
- Role Badge AssignGranting a role slides the selection pill and settles a new badge onto the member row.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.