Suspicious Login Alert
A security alert holds a beat, then arrives slowly — this one is not meant to feel routine.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Suspicious Login Alert
*
* A security alert that deliberately takes longer to arrive than a
* routine notice. The extra beat before it moves, and the slow settle
* once it does, are the whole point: speed is what makes a message feel
* dismissible, and this one is asking the user to make a decision.
*
* Serious, not alarming — amber rather than red, no shaking, no pulsing.
* The tone is a bank statement, not a fire alarm.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals mix from the inherited text color, so the card reads correctly
* on a light page and on a dark one.
* Works with zero props; tune via `variant`, `device`, `location`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SuspiciousLoginAlertProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Device the sign-in came from. */
device?: string;
/** Where it came from. */
location?: string;
/** When it happened. */
when?: string;
/** Color of the caution mark. Amber by default — serious, not alarming. */
caution?: string;
/** Fires once the whole alert has settled. */
onComplete?: () => void;
};
type VariantConfig = {
/** Silence before anything moves. This is what stops it reading as routine. */
lead: number;
/** Travel of the card, in px. */
rise: number;
/** Gap between the detail rows. */
stagger: number;
/** Deliberately low stiffness: a long, unhurried settle. */
card: { type: "spring"; stiffness: number; damping: number };
row: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: every spring here is at or above a 0.8 damping ratio, and
// the card spring is the softest in the library by design — low stiffness
// with heavy damping gives a long approach and no rebound at the end. A
// security alert that bounces is a security alert nobody believes.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Restrained, but still slower than a routine toast.
subtle: {
lead: 0.3,
rise: 10,
stagger: 0.08,
card: { type: "spring", stiffness: 170, damping: 26 },
row: { type: "spring", stiffness: 260, damping: 30 },
},
// The all-purpose setting: a held beat, then an unhurried arrival.
default: {
lead: 0.5,
rise: 16,
stagger: 0.11,
card: { type: "spring", stiffness: 120, damping: 22 },
row: { type: "spring", stiffness: 200, damping: 27 },
},
// The longest hold, for a full-screen interruption the user is meant
// to stop and read.
playful: {
lead: 0.72,
rise: 22,
stagger: 0.14,
card: { type: "spring", stiffness: 90, damping: 20 },
row: { type: "spring", stiffness: 170, damping: 25 },
},
};
/** 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 SuspiciousLoginAlert({
variant = "default",
device = "Windows · Chrome",
location = "Lisbon, Portugal",
when = "Today at 14:02",
caution = "#E0A23C",
onComplete,
}: SuspiciousLoginAlertProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const rows = [
{ label: "Device", value: device },
{ label: "Location", value: location },
{ label: "When", value: when },
];
// Reduced motion keeps the seriousness — the held beat survives as a
// delayed fade — and drops the travel and the long settle.
const card = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.28, delay: cfg.lead * 0.6, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, y: cfg.rise },
animate: { opacity: 1, y: 0 },
transition: {
...cfg.card,
delay: cfg.lead,
opacity: { duration: 0.42, delay: cfg.lead, ease: "easeOut" as const },
},
};
const contentDelay = cfg.lead + (reduceMotion ? 0.05 : 0.22);
const item = (index: number) =>
reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: {
duration: 0.2,
delay: contentDelay + index * 0.04,
ease: "easeOut" as const,
},
}
: {
initial: { opacity: 0, y: 7 },
animate: { opacity: 1, y: 0 },
transition: {
...cfg.row,
delay: contentDelay + index * cfg.stagger,
opacity: {
duration: 0.3,
delay: contentDelay + index * cfg.stagger,
ease: "easeOut" as const,
},
},
};
return (
<motion.div
role="alert"
{...card}
style={{
width: 322,
padding: 18,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
// A hairline of caution along the top edge rather than a coloured
// card: the alert is serious without shouting.
borderTop: `2px solid ${caution}`,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 11 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 32,
height: 32,
flexShrink: 0,
borderRadius: 10,
background: tone(8),
color: caution,
}}
>
{/* The shield strokes in slowly rather than appearing. At this
pace it reads as attention being drawn, not as an alarm. */}
<svg width="17" height="17" viewBox="0 0 20 20" fill="none" aria-hidden>
<motion.path
d="M10 2.9l5.6 2.1v4.5c0 3.2-2.3 5.9-5.6 7-3.3-1.1-5.6-3.8-5.6-7V5l5.6-2.1z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.72, delay: contentDelay, ease: "easeInOut" }
}
/>
<motion.path
d="M10 7.4v3.1"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
initial={{ opacity: reduceMotion ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.24, delay: contentDelay + 0.5 }}
/>
<motion.circle
cx="10"
cy="12.9"
r="0.9"
fill="currentColor"
initial={{ opacity: reduceMotion ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.24, delay: contentDelay + 0.6 }}
/>
</svg>
</span>
<div style={{ minWidth: 0 }}>
<motion.div {...item(0)} style={{ fontSize: 14, fontWeight: 650, lineHeight: 1.35 }}>
A sign-in we do not recognise
</motion.div>
<motion.div
{...item(1)}
style={{ fontSize: 12.5, opacity: 0.6, marginTop: 4, lineHeight: 1.5 }}
>
Someone signed in to your Meridian account from a new device.
</motion.div>
</div>
</div>
<div
style={{
marginTop: 14,
borderRadius: 11,
border: `1px solid ${tone(10)}`,
overflow: "hidden",
}}
>
{rows.map((row, index) => (
<motion.div
key={row.label}
{...item(2 + index)}
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
padding: "9px 12px",
fontSize: 12,
borderTop: index === 0 ? "none" : `1px solid ${tone(8)}`,
}}
>
<span style={{ opacity: 0.5 }}>{row.label}</span>
<span style={{ fontWeight: 600 }}>{row.value}</span>
</motion.div>
))}
</div>
<motion.div
{...item(5)}
onAnimationComplete={() => onComplete?.()}
style={{ display: "flex", gap: 8, marginTop: 14 }}
>
<button
type="button"
style={{
flex: 1,
padding: "10px 12px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 10,
border: "none",
background: "#5B5BD6",
color: "#FFFFFF",
cursor: "pointer",
}}
>
Secure my account
</button>
<button
type="button"
style={{
flex: 1,
padding: "10px 12px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: `1px solid ${tone(16)}`,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
This was me
</button>
</motion.div>
</motion.div>
);
}About this pattern
Everything else in an interface competes to be fast. This deliberately is not: there is a held beat before anything moves, and the card then travels on the softest spring in the library — low stiffness, heavy damping, a long approach and no rebound at the end. Speed is what makes a message feel dismissible, and this one is asking for a decision. The tone is serious rather than alarming: amber along one edge instead of a red card, a shield that strokes in slowly instead of a pulsing icon, and detail rows that arrive one at a time so the device, place and time are read rather than skimmed. Nothing shakes, nothing flashes, nothing repeats.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
Security messages written and paced to be read, not dismissed.
Related patterns
- Session Lock BlurAn idle workspace softens behind a scrim while a lock card rises over it, keeping shape but not content.
- Sign-in Success HandoffThe panel recedes as the product assembles behind it and the avatar travels into the header.
- Trust This DeviceFlipping the switch drops the device into the trusted list below, already marked.