All patterns

Suspicious Login Alert

A security alert holds a beat, then arrives slowly — this one is not meant to feel routine.

authenticationcalmpremiumautomatic · finite · starter · ~1.9s
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.

297 lines · react + motion only
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.

Unrecognised sign-in warningNew device security noticeAccount takeover promptSecurity activity email or screen

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

    Security messages written and paced to be read, not dismissed.

Related patterns