All patterns

Two Factor Method Switch

Choosing another second factor slides the selection ring, crossfades the instructions and re-arms the field.

authenticationminimalcalminteraction · finite · intermediate · ~0.4s
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.

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

/**
 * Vibary · Two Factor Method Switch
 *
 * Picking another second factor slides the selection ring onto the new
 * row, crossfades the instructions, and re-arms the field underneath.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `methods`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TwoFactorMethod = {
  /** Stable key, also used as the crossfade identity. */
  id: string;
  /** Row title. */
  label: string;
  /** Row detail line. */
  detail: string;
  /** Instruction shown under the picker when this method is selected. */
  instruction: string;
  /** Label above the entry field. */
  fieldLabel: string;
  /** Placeholder inside the entry field. */
  placeholder: string;
};

export type TwoFactorSwitchProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Methods offered, in order. */
  methods?: TwoFactorMethod[];
  /** Selection ring and underline color. */
  accent?: string;
  /** Fires with the id of the newly selected method. */
  onMethodChange?: (id: string) => void;
};

type VariantConfig = {
  /** Travel of the crossfading instruction block, in px. */
  travel: number;
  /** Seconds the underline takes to sweep the re-armed field. */
  sweep: number;
  ringSpring: { type: "spring"; stiffness: number; damping: number };
  dotSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the ring and the dot are the only things that spring,
// and neither carries a glyph — the instruction block is text, so it
// translates and fades and never changes size. Both springs sit above a
// 0.8 damping ratio; a ring that overshoots its row lands on the wrong
// option for a frame, which is worse than no motion at all.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // The ring moves and little else. For security settings people revisit
  // under time pressure.
  subtle: {
    travel: 5,
    sweep: 0.28,
    ringSpring: { type: "spring", stiffness: 620, damping: 48 },
    dotSpring: { type: "spring", stiffness: 600, damping: 44 },
  },
  // The switch reads as a switch: ring, instructions, field, in that
  // order. The all-purpose setting.
  default: {
    travel: 9,
    sweep: 0.36,
    ringSpring: { type: "spring", stiffness: 480, damping: 42 },
    dotSpring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // More travel on the instructions so the panel reads as being
  // replaced, not edited.
  playful: {
    travel: 14,
    sweep: 0.46,
    ringSpring: { type: "spring", stiffness: 400, damping: 36 },
    dotSpring: { 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 or border correctly toned in
 *  either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_METHODS: TwoFactorMethod[] = [
  {
    id: "sms",
    label: "Text message",
    detail: "To the number ending 04",
    instruction: "We just texted the number on file. It expires in 5 minutes.",
    fieldLabel: "Code from the message",
    placeholder: "000000",
  },
  {
    id: "app",
    label: "Authenticator app",
    detail: "Meridian Authenticator",
    instruction: "Open your authenticator app and read the current entry.",
    fieldLabel: "Code from your app",
    placeholder: "000000",
  },
  {
    id: "key",
    label: "Security key",
    detail: "Hardware key or passkey",
    instruction: "Plug in your key and touch it when it starts blinking.",
    fieldLabel: "Confirmation from your key",
    placeholder: "Waiting for the key",
  },
];

export default function TwoFactorSwitch({
  variant = "default",
  methods = DEFAULT_METHODS,
  accent = "#5B5BD6",
  onMethodChange,
}: TwoFactorSwitchProps) {
  const [selectedId, setSelectedId] = useState(methods[0]?.id ?? "");
  // Direction is derived from the row order, so the instructions travel
  // the same way the eye just did.
  const [direction, setDirection] = useState(1);
  const [entry, setEntry] = useState("");
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const selectedIndex = Math.max(
    0,
    methods.findIndex((method) => method.id === selectedId)
  );
  const selected = methods[selectedIndex];

  const select = (id: string, index: number) => {
    if (id === selectedId) return;
    setDirection(index > selectedIndex ? 1 : -1);
    setSelectedId(id);
    // Re-arming the field is part of the switch: a code typed for one
    // method is never valid for the next one.
    setEntry("");
    onMethodChange?.(id);
  };

  return (
    <div
      style={{
        width: 300,
        display: "flex",
        flexDirection: "column",
        gap: 12,
        padding: 18,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        color: "inherit",
      }}
    >
      <div>
        <div style={{ fontSize: 15, fontWeight: 650 }}>Confirm this sign-in</div>
        <div style={{ fontSize: 12, opacity: 0.58, marginTop: 3 }}>
          Pick the second factor you have to hand.
        </div>
      </div>

      <div
        role="radiogroup"
        aria-label="Second factor method"
        style={{ display: "flex", flexDirection: "column", gap: 6 }}
      >
        {methods.map((method, index) => {
          const isSelected = method.id === selectedId;
          return (
            <button
              key={method.id}
              type="button"
              role="radio"
              aria-checked={isSelected}
              onClick={() => select(method.id, index)}
              style={{
                position: "relative",
                display: "flex",
                alignItems: "center",
                gap: 10,
                padding: "9px 11px",
                fontFamily: "inherit",
                color: "inherit",
                textAlign: "left",
                background: "transparent",
                border: `1px solid ${tone(11)}`,
                borderRadius: 11,
                cursor: "pointer",
              }}
            >
              {/* One ring for the whole group. Because every row is the
                  same height, the shared layout move is a pure translate
                  — the ring never stretches over the labels. */}
              {isSelected && (
                <motion.span
                  layoutId="vibary-tfs-ring"
                  aria-hidden
                  transition={reduceMotion ? { duration: 0 } : cfg.ringSpring}
                  style={{
                    position: "absolute",
                    inset: -1,
                    borderRadius: 11,
                    border: `1.5px solid ${accent}`,
                    pointerEvents: "none",
                  }}
                />
              )}

              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 16,
                  height: 16,
                  flex: "0 0 auto",
                  borderRadius: "50%",
                  border: `1.5px solid ${isSelected ? accent : tone(24)}`,
                }}
              >
                <AnimatePresence initial={false}>
                  {isSelected && (
                    <motion.span
                      initial={
                        reduceMotion
                          ? { opacity: 0 }
                          : { opacity: 0, scale: 0.4 }
                      }
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{ opacity: 0, transition: { duration: 0.1 } }}
                      transition={
                        reduceMotion
                          ? { duration: 0.12 }
                          : { ...cfg.dotSpring, opacity: { duration: 0.12 } }
                      }
                      style={{
                        width: 8,
                        height: 8,
                        borderRadius: "50%",
                        background: accent,
                      }}
                    />
                  )}
                </AnimatePresence>
              </span>

              <span>
                <span
                  style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
                >
                  {method.label}
                </span>
                <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                  {method.detail}
                </span>
              </span>
            </button>
          );
        })}
      </div>

      {/* The instruction block sits in a fixed-height slot: the methods
          have different sentence lengths, and a picker that grows and
          shrinks under the cursor is a picker people misclick. */}
      <div style={{ position: "relative", height: 82 }}>
        <AnimatePresence mode="wait" initial={false} custom={direction}>
          <motion.div
            key={selected?.id}
            custom={direction}
            initial={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: direction * cfg.travel }
            }
            animate={{ opacity: 1, y: 0 }}
            exit={{
              opacity: 0,
              y: reduceMotion ? 0 : direction * -cfg.travel * 0.6,
              transition: { duration: 0.12, ease: "easeIn" },
            }}
            transition={{ duration: 0.2, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              flexDirection: "column",
              gap: 7,
            }}
          >
            <div style={{ fontSize: 11.5, lineHeight: 1.45, opacity: 0.62 }}>
              {selected?.instruction}
            </div>

            <label
              htmlFor="vibary-tfs-entry"
              style={{ fontSize: 11, fontWeight: 600, opacity: 0.55 }}
            >
              {selected?.fieldLabel}
            </label>

            <div style={{ position: "relative" }}>
              <input
                id="vibary-tfs-entry"
                value={entry}
                onChange={(event) => setEntry(event.target.value)}
                inputMode="numeric"
                autoComplete="one-time-code"
                placeholder={selected?.placeholder}
                style={{
                  width: "100%",
                  boxSizing: "border-box",
                  padding: "8px 11px",
                  fontSize: 13.5,
                  fontFamily: "inherit",
                  letterSpacing: 1,
                  color: "inherit",
                  borderRadius: 9,
                  border: `1px solid ${tone(16)}`,
                  background: tone(8),
                }}
              />
              {/* The field re-arms with one sweep along its lower edge:
                  proof that the input was cleared for the new method,
                  without moving the label or resizing the box. */}
              <motion.span
                aria-hidden
                initial={{ scaleX: reduceMotion ? 1 : 0, opacity: 1 }}
                animate={{ scaleX: 1, opacity: 0.9 }}
                transition={{
                  duration: reduceMotion ? 0.12 : cfg.sweep,
                  ease: "easeOut",
                }}
                style={{
                  position: "absolute",
                  left: 9,
                  right: 9,
                  bottom: 1,
                  height: 1.5,
                  borderRadius: 1,
                  background: accent,
                  transformOrigin: "left",
                }}
              />
            </div>
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

Three things have to change together when someone gives up on one second factor and reaches for another, and doing them in the wrong order makes the screen feel broken. The selection ring is a single shared element that travels between rows — every row is the same height, so it translates and never stretches over a label — the instruction block crossfades in the direction the eye just moved, and the entry field clears itself and sweeps its underline to prove it did. The instructions sit in a fixed-height slot because the sentences differ in length and a picker that grows under the cursor is a picker people misclick.

Second factor chooser at sign-inFallback when the phone is unavailableSecurity settings method listStep-up confirmation before a sensitive action

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 list of second factors where choosing another rewrites the instructions in place.

Related patterns