All patterns

Passkey Create

A key glyph strokes itself into existence, bow first, as the credential registers.

authenticationfuturisticpremiumautomatic · finite · intermediate · ~1.4s
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.

266 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Passkey Create
 *
 * Registering a passkey is a cryptographic non-event: nothing is typed,
 * nothing is sent, and the moment is over before it registers. Drawing
 * the key from its parts gives the user something to watch that is
 * honest about what happened — a credential was assembled on the device.
 *
 * 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`, `siteName`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PasskeyCreateProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Domain the passkey belongs to. */
  siteName?: string;
  /** Where the key is stored. */
  storedIn?: string;
  /** Key and accent color. */
  accent?: string;
  /** Fires once the key and its caption have settled. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** How long each stroke of the key takes to draw. */
  stroke: number;
  /** Gap between the strokes. */
  step: number;
  /** Travel of the caption rows, in px. */
  rise: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the key is stroke work, not a bouncing object — it draws
// and stops. The caption rows carry text and land on a spring above a 0.8
// damping ratio, so nothing wobbles. Variants change pace and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly instantaneous. For a security settings page where a passkey
  // is one row among twenty.
  subtle: {
    stroke: 0.24,
    step: 0.1,
    rise: 6,
    spring: { type: "spring", stiffness: 560, damping: 44 },
  },
  // The all-purpose setting: the key visibly assembles, then names
  // itself.
  default: {
    stroke: 0.34,
    step: 0.16,
    rise: 10,
    spring: { type: "spring", stiffness: 440, damping: 38 },
  },
  // Slower strokes and a wider gap, for a first-run screen where the
  // passkey is the whole point of the step.
  playful: {
    stroke: 0.46,
    step: 0.22,
    rise: 14,
    spring: { type: "spring", stiffness: 360, damping: 33 },
  },
};

/** 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)`;

export default function PasskeyCreate({
  variant = "default",
  siteName = "meridian.app",
  storedIn = "Saved to this device",
  accent = "#5B5BD6",
  onComplete,
}: PasskeyCreateProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Each stroke waits for the one before it: the bow, then the shaft,
  // then the teeth. Drawn in the order a hand would draw them.
  const strokeAt = (index: number) => cfg.step * index;
  const assembledAt = strokeAt(3) + cfg.stroke;

  const draw = (index: number) => ({
    initial: {
      pathLength: reduceMotion ? 1 : 0,
      opacity: reduceMotion ? 1 : 0,
    },
    animate: { pathLength: 1, opacity: 1 },
    transition: reduceMotion
      ? { duration: 0 }
      : {
          pathLength: {
            duration: cfg.stroke,
            delay: strokeAt(index),
            ease: "easeOut" as const,
          },
          opacity: { duration: 0.1, delay: strokeAt(index) },
        },
  });

  const rowEnter = (index: number) =>
    reduceMotion
      ? {
          initial: { opacity: 0 },
          animate: { opacity: 1 },
          transition: { duration: 0.18, delay: 0.05 * index, ease: "easeOut" as const },
        }
      : {
          initial: { opacity: 0, y: cfg.rise },
          animate: { opacity: 1, y: 0 },
          transition: {
            ...cfg.spring,
            delay: assembledAt + 0.06 * index,
            opacity: { duration: 0.2, delay: assembledAt + 0.06 * index },
          },
        };

  return (
    <div
      style={{
        width: 300,
        padding: "26px 20px 20px",
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        textAlign: "center",
      }}
    >
      <div
        style={{
          position: "relative",
          width: 76,
          height: 76,
          margin: "0 auto",
          display: "grid",
          placeItems: "center",
        }}
      >
        {/* One ring, expanding once as the last tooth lands. It holds no
            text, so it is the only element here free to scale. */}
        {!reduceMotion && (
          <motion.span
            aria-hidden
            initial={{ opacity: 0, scale: 0.72 }}
            animate={{ opacity: [0, 0.45, 0], scale: 1.08 }}
            transition={{ duration: 0.62, delay: assembledAt - 0.08, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              borderRadius: 999,
              border: `1.5px solid ${accent}`,
            }}
          />
        )}

        <span
          aria-hidden
          style={{
            display: "grid",
            placeItems: "center",
            width: 66,
            height: 66,
            borderRadius: 999,
            background: tone(8),
            color: accent,
          }}
        >
          <svg width="34" height="34" viewBox="0 0 40 40" fill="none" aria-hidden>
            {/* Bow — the ring your finger goes through. */}
            <motion.circle
              cx="13.5"
              cy="20"
              r="7.2"
              stroke="currentColor"
              strokeWidth="2.6"
              strokeLinecap="round"
              {...draw(0)}
            />
            {/* Shaft. */}
            <motion.path
              d="M20.7 20H34"
              stroke="currentColor"
              strokeWidth="2.6"
              strokeLinecap="round"
              {...draw(1)}
            />
            {/* Teeth, one after the other, so the key finishes at its tip. */}
            <motion.path
              d="M27.6 20v5.2"
              stroke="currentColor"
              strokeWidth="2.6"
              strokeLinecap="round"
              {...draw(2)}
            />
            <motion.path
              d="M32.4 20v3.4"
              stroke="currentColor"
              strokeWidth="2.6"
              strokeLinecap="round"
              {...draw(3)}
            />
          </svg>
        </span>
      </div>

      <motion.div
        {...rowEnter(0)}
        style={{ fontSize: 16, fontWeight: 650, marginTop: 16 }}
      >
        Passkey created
      </motion.div>

      <motion.div
        {...rowEnter(1)}
        style={{ fontSize: 12.5, opacity: 0.6, marginTop: 5, lineHeight: 1.5 }}
      >
        You can sign in to {siteName} with your face, fingerprint or device
        passcode.
      </motion.div>

      <motion.div
        {...rowEnter(2)}
        onAnimationComplete={() => onComplete?.()}
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: 7,
          marginTop: 14,
          padding: "8px 12px",
          borderRadius: 999,
          background: tone(8),
          border: `1px solid ${tone(10)}`,
          fontSize: 11.5,
          fontWeight: 600,
        }}
      >
        <svg
          width="13"
          height="13"
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.7"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden
        >
          <rect x="4" y="8.6" width="12" height="7.6" rx="2" />
          <path d="M7.2 8.6V6.9a2.8 2.8 0 0 1 5.6 0v1.7" />
        </svg>
        {storedIn}
      </motion.div>
    </div>
  );
}

About this pattern

Creating a passkey is a cryptographic non-event: nothing is typed, nothing is sent, and it is over before the user notices it started. Drawing the key from its parts gives them something honest to watch — a credential being assembled on the device. The strokes arrive in the order a hand would draw them, bow then shaft then teeth, so the glyph finishes at its tip rather than fading in whole. A single ring expands as the last tooth lands; it holds no text, which is why it is the one element here allowed to change size. The caption rows then rise on a spring that settles without a rebound.

Passkey registration confirmationBiometric credential setupSecurity key enrollmentPasswordless onboarding step

Where it shows up

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

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

    A confirmation screen that names the site and device the credential belongs to.

Related patterns