Trust This Device
Flipping the switch drops the device into the trusted list below, already marked.
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 · Trust This Device
*
* Flipping the switch does not just change a setting — the device drops
* into the trusted list below, already marked, so the consequence of the
* toggle is visible in the same glance as the toggle itself.
*
* 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`, `deviceName`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DeviceTrustToggleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Label of the row that appears when the switch goes on. */
deviceName?: string;
/** Second line of that row. */
deviceDetail?: string;
/** Switch and check color. */
accent?: string;
/** Fires on every flip with the new state. */
onTrustChange?: (trusted: boolean) => void;
};
type VariantConfig = {
/** How far the new row travels before it lands, in px. */
rise: number;
/** How long the list takes to open the gap for it, in seconds. */
open: number;
/** Beat between the row landing and its check drawing. */
markDelay: number;
knob: { type: "spring"; stiffness: number; damping: number };
row: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: both springs sit above a 0.8 damping ratio. A knob that
// overshoots its track reads as a broken switch rather than a lively
// one, and the row carries text. Variants change pace and travel only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For security settings that should feel like record-keeping.
subtle: {
rise: 5,
open: 0.16,
markDelay: 0.05,
knob: { type: "spring", stiffness: 620, damping: 42 },
row: { type: "spring", stiffness: 540, damping: 44 },
},
// Enough travel to draw the eye down to the list. The all-purpose one.
default: {
rise: 9,
open: 0.22,
markDelay: 0.1,
knob: { type: "spring", stiffness: 520, damping: 38 },
row: { type: "spring", stiffness: 420, damping: 38 },
},
// The row visibly arrives from above the list.
playful: {
rise: 14,
open: 0.28,
markDelay: 0.14,
knob: { type: "spring", stiffness: 440, damping: 34 },
row: { type: "spring", stiffness: 360, damping: 32 },
},
};
/** 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)`;
const ROW_HEIGHT = 46;
const KNOWN_DEVICES = [
{ name: "iPhone 15 · Safari", detail: "Last used 2 days ago" },
{ name: "Windows · Edge", detail: "Last used 28 July" },
];
export default function DeviceTrustToggle({
variant = "default",
deviceName = "MacBook Pro · Chrome",
deviceDetail = "This device · added just now",
accent = "#5B5BD6",
onTrustChange,
}: DeviceTrustToggleProps) {
const [trusted, setTrusted] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const flip = () => {
const next = !trusted;
setTrusted(next);
onTrustChange?.(next);
};
// Reduced motion keeps the whole consequence — the row still appears,
// still marked — and drops only the travel and the gap opening.
const rowMotion = reduceMotion
? {
initial: { opacity: 0, height: ROW_HEIGHT },
animate: { opacity: 1, height: ROW_HEIGHT },
exit: { opacity: 0, height: 0 },
transition: { duration: 0.15, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, height: 0, y: -cfg.rise },
animate: { opacity: 1, height: ROW_HEIGHT, y: 0 },
exit: { opacity: 0, height: 0, y: -cfg.rise },
transition: {
y: cfg.row,
// Height is a genuine size change — the list opening a gap —
// so it tweens. Springing a height leaves the rows below
// rocking, which is the one thing a settings list must not do.
height: { duration: cfg.open, ease: "easeOut" as const },
opacity: { duration: 0.18, ease: "easeOut" as const },
},
};
return (
<div
style={{
width: 320,
padding: 16,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
}}
>
<div style={{ fontSize: 11, fontWeight: 650, letterSpacing: 0.5, opacity: 0.5 }}>
SECURITY
</div>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 14,
marginTop: 10,
}}
>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600 }}>Trust this device</div>
<div style={{ fontSize: 12, opacity: 0.55, marginTop: 3, lineHeight: 1.45 }}>
Skip the sign-in code here for 30 days
</div>
</div>
<button
type="button"
role="switch"
aria-checked={trusted}
aria-label="Trust this device"
onClick={flip}
style={{
flexShrink: 0,
width: 44,
height: 26,
padding: 3,
marginTop: 1,
borderRadius: 999,
border: `1px solid ${trusted ? "transparent" : tone(16)}`,
background: trusted ? accent : tone(10),
color: "inherit",
cursor: "pointer",
transition: "background-color 180ms ease, border-color 180ms ease",
display: "flex",
justifyContent: "flex-start",
}}
>
{/* The knob is the only thing that travels: an 18px slide on a
tight spring, so the switch answers the finger immediately. */}
<motion.span
aria-hidden
animate={{ x: trusted ? 18 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.knob}
style={{
display: "block",
width: 18,
height: 18,
borderRadius: 999,
background: "#FFFFFF",
boxShadow: "0 1px 3px rgba(0,0,0,0.28)",
}}
/>
</button>
</div>
<div
style={{
marginTop: 14,
paddingTop: 12,
borderTop: `1px solid ${tone(10)}`,
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.5,
opacity: 0.5,
}}
>
TRUSTED DEVICES
</div>
<div style={{ marginTop: 2 }}>
{/* initial={false} so the two standing devices do not replay the
entrance on mount — only the row the switch just added moves. */}
<AnimatePresence initial={false}>
{trusted && (
<motion.div key="this-device" {...rowMotion} style={{ overflow: "hidden" }}>
<DeviceRow
name={deviceName}
detail={deviceDetail}
accent={accent}
markDelay={reduceMotion ? 0 : cfg.markDelay}
reduceMotion={Boolean(reduceMotion)}
/>
</motion.div>
)}
</AnimatePresence>
{KNOWN_DEVICES.map((device) => (
<DeviceRow key={device.name} name={device.name} detail={device.detail} />
))}
</div>
</div>
);
}
function DeviceRow({
name,
detail,
accent,
markDelay = 0,
reduceMotion = false,
}: {
name: string;
detail: string;
accent?: string;
markDelay?: number;
reduceMotion?: boolean;
}) {
return (
<div
style={{
height: ROW_HEIGHT,
display: "flex",
alignItems: "center",
gap: 10,
borderBottom: `1px solid ${tone(8)}`,
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 26,
height: 26,
flexShrink: 0,
borderRadius: 8,
background: tone(8),
opacity: 0.75,
}}
>
<svg
width="14"
height="14"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2.5" y="4" width="15" height="9.5" rx="1.6" />
<path d="M7 16.5h6" />
</svg>
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
</div>
<div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>{detail}</div>
</div>
{accent && (
<span
aria-label="Trusted"
style={{
display: "grid",
placeItems: "center",
width: 20,
height: 20,
flexShrink: 0,
borderRadius: 999,
background: accent,
color: "#FFFFFF",
}}
>
{/* The check draws rather than appears: a stroke that arrives
after the row has landed reads as the system confirming,
not as decoration that came with the row. */}
<svg width="12" height="12" viewBox="0 0 20 20" fill="none">
<motion.path
d="M5.5 10.4l3 3 6-6.4"
stroke="currentColor"
strokeWidth="2.1"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.24, delay: markDelay, ease: "easeOut" }
}
/>
</svg>
</span>
)}
</div>
);
}About this pattern
A switch that only changes its own colour leaves the user guessing what it did. Here the knob slides on a tight spring and the list underneath opens a gap for the device in the same beat, so the setting and its consequence are visible in one glance. The row travels a short distance into the gap while the list height tweens — a genuine size change, so it eases rather than springs, otherwise every row below would rock. The trusted check draws a fraction later, which reads as the system confirming rather than as decoration that arrived with the row. Switching back closes the gap the same way.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Settings
Session and device lists that update in place as a preference changes.
Related patterns
- Two Factor Method SwitchChoosing another second factor slides the selection ring, crossfades the instructions and re-arms the field.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.
- Suspicious Login AlertA security alert holds a beat, then arrives slowly — this one is not meant to feel routine.