Two Factor Method Switch
Choosing another second factor slides the selection ring, crossfades the instructions and re-arms the field.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Two-factor prompt
A list of second factors where choosing another rewrites the instructions in place.
Related patterns
- Trust This DeviceFlipping the switch drops the device into the trusted list below, already marked.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.
- OTP Code EntrySix boxes: the waiting box lifts, digits settle in, and a wrong code answers with one short nudge.