All patterns

Biometric Prompt

A fingerprint glyph strokes itself in while the device reads, then a ring settles once it accepts.

authenticationpremiumfuturisticautomatic · finite · intermediate · ~1.5s
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.

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

/**
 * Vibary · Biometric Prompt
 *
 * The fingerprint glyph strokes itself in while the device reads you,
 * then an accent ring settles around it once the identity is accepted.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `title`, `accent`, `scanMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BiometricPromptProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Prompt heading. */
  title?: string;
  /** Line under the heading while the read is running. */
  subtitle?: string;
  /** Line shown once the read is accepted. */
  confirmedLabel?: string;
  /** Escape hatch offered under the glyph. */
  fallbackLabel?: string;
  /** How long the read runs before it resolves, in ms. */
  scanMs?: number;
  /** Ring and stroke color once accepted. */
  accent?: string;
  /** Fires when the prompt reaches its accepted state. */
  onVerified?: () => void;
};

type VariantConfig = {
  /** Seconds one ridge takes to stroke itself in. */
  draw: number;
  /** Gap between ridges — this is what makes the glyph assemble. */
  stagger: number;
  /** How far under full size the acceptance ring starts. */
  ringFrom: number;
  ringSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the acceptance ring is the only thing here that springs,
// and it carries no text, so it can afford a settle. Every spring sits
// above a 0.8 damping ratio — a security confirmation that wobbles reads
// as uncertain. Variants change how long the read looks like it took and
// how far the ring travels, never the number of settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly instant. For unlocks that happen forty times a day and should
  // feel like nothing at all.
  subtle: {
    draw: 0.32,
    stagger: 0.05,
    ringFrom: 0.94,
    ringSpring: { type: "spring", stiffness: 560, damping: 46 },
  },
  // The read is visibly a read, and the ring lands cleanly on it. The
  // all-purpose setting.
  default: {
    draw: 0.44,
    stagger: 0.07,
    ringFrom: 0.9,
    ringSpring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // A longer assemble and a wider ring travel: the device is doing
  // something, and the prompt says so.
  playful: {
    draw: 0.56,
    stagger: 0.09,
    ringFrom: 0.84,
    ringSpring: { type: "spring", stiffness: 420, damping: 36 },
  },
};

/** 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, border or fill that is correctly
 *  toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Concentric ridges, outermost first. Ordering the array this way is
 *  what makes the glyph read as growing inward rather than as five
 *  unrelated arcs switching on. */
const RIDGES = [
  "M5 30a19 19 0 0 1 38 0",
  "M10.5 32a13.5 13.5 0 0 1 27 0",
  "M15.5 33.5a8.5 8.5 0 0 1 17 0",
  "M20 35a4 4 0 0 1 8 0",
  "M5 30v6M43 30v6M10.5 32v7M37.5 32v7",
];

export default function BiometricPrompt({
  variant = "default",
  title = "Confirm it's you",
  subtitle = "Hold still while your device reads",
  confirmedLabel = "Identity confirmed",
  fallbackLabel = "Use your passcode instead",
  scanMs = 1100,
  accent = "#5B5BD6",
  onVerified,
}: BiometricPromptProps) {
  const [verified, setVerified] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // The read resolves on its own: this is the prompt's own timeline, not
  // an input the user drives. Swap the timer for your platform callback.
  useEffect(() => {
    const timer = setTimeout(() => setVerified(true), scanMs);
    return () => clearTimeout(timer);
  }, [scanMs]);

  useEffect(() => {
    if (verified) onVerified?.();
  }, [verified, onVerified]);

  return (
    <div
      style={{
        width: 264,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 14,
        padding: "22px 20px 18px",
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        color: "inherit",
        textAlign: "center",
      }}
    >
      <div style={{ fontSize: 15.5, fontWeight: 650 }}>{title}</div>

      <div
        style={{
          position: "relative",
          display: "grid",
          placeItems: "center",
          width: 96,
          height: 96,
        }}
      >
        {/* The acceptance ring. It is absent, not merely transparent,
            until the read resolves — an empty ring sitting under the
            glyph the whole time would pre-announce the answer. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={
            verified
              ? { opacity: 1, scale: 1 }
              : { opacity: 0, scale: reduceMotion ? 1 : cfg.ringFrom }
          }
          transition={
            reduceMotion
              ? { duration: 0.18, ease: "easeOut" }
              : { ...cfg.ringSpring, opacity: { duration: 0.18 } }
          }
          style={{
            position: "absolute",
            width: 90,
            height: 90,
            borderRadius: "50%",
            border: `2px solid ${accent}`,
          }}
        />

        <svg
          width="62"
          height="62"
          viewBox="0 0 48 48"
          fill="none"
          role="img"
          aria-label={verified ? confirmedLabel : subtitle}
        >
          {RIDGES.map((d, index) => (
            <motion.path
              key={d}
              d={d}
              // Stroke color is a state change, not motion: a CSS
              // transition keeps it off the animation loop, which stays
              // pathLength/opacity only.
              stroke={verified ? accent : "currentColor"}
              strokeWidth="2.2"
              strokeLinecap="round"
              initial={
                reduceMotion
                  ? { opacity: 0, pathLength: 1 }
                  : { opacity: 0, pathLength: 0 }
              }
              animate={{ opacity: verified ? 1 : 0.68, pathLength: 1 }}
              transition={{
                pathLength: {
                  duration: reduceMotion ? 0 : cfg.draw,
                  delay: reduceMotion ? 0 : index * cfg.stagger,
                  ease: "easeInOut",
                },
                opacity: {
                  duration: 0.2,
                  delay: reduceMotion ? 0 : index * cfg.stagger,
                },
              }}
              style={{ transition: "stroke 200ms ease-out" }}
            />
          ))}
        </svg>
      </div>

      {/* Two fixed lines in one slot: the result is written out, so
          reduced-motion users and screen readers get the same answer the
          ring gives everyone else. The slot has a set height, so the
          card never resizes under the swap. */}
      <div
        role="status"
        aria-live="polite"
        style={{
          position: "relative",
          width: "100%",
          height: 34,
          fontSize: 12.5,
          lineHeight: 1.4,
        }}
      >
        {[
          { key: "scanning", text: subtitle, shown: !verified, rest: 0.65 },
          { key: "verified", text: confirmedLabel, shown: verified, rest: 1 },
        ].map((line) => (
          <motion.div
            key={line.key}
            initial={false}
            animate={{ opacity: line.shown ? line.rest : 0 }}
            transition={{ duration: 0.2, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              display: "grid",
              placeItems: "center",
              color: line.key === "verified" ? accent : "inherit",
              fontWeight: line.key === "verified" ? 600 : 400,
            }}
          >
            {line.text}
          </motion.div>
        ))}
      </div>

      <button
        type="button"
        style={{
          padding: "8px 12px",
          fontSize: 12,
          fontWeight: 600,
          fontFamily: "inherit",
          color: "inherit",
          background: "transparent",
          border: `1px solid ${tone(14)}`,
          borderRadius: 9,
          opacity: verified ? 0.35 : 0.75,
          cursor: "pointer",
        }}
      >
        {fallbackLabel}
      </button>
    </div>
  );
}

About this pattern

The moment a device decides whether you are you, given a shape. The ridges stroke themselves in from the outside inward, which makes the glyph read as being assembled by the reader rather than switched on, and the pacing is the only thing that says work is happening — there is no separate indicator to contradict the result later. Acceptance arrives as a single ring settling around the glyph and the ridges taking the accent color; one settle, no rebound, because a security confirmation that wobbles reads as uncertain. The outcome is written out as well as drawn, so a screen reader and a reduced-motion reader get the same answer.

Device unlock promptStep-up confirmation before a paymentRe-authentication after idlePasswordless mobile sign-in

Where it shows up

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

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

    A glyph that reads as the sensor itself, resolving into a single accepted state.

Related patterns