Security

Login Activity Map

A world map of login attempts with volume-sized city dots and destructive ping rings flagging anomalous sign-in locations.

Install

npx shadcn@latest add @paragon/login-activity-map

Also installs: world-map

login-activity-map.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ShieldAlert } from "lucide-react";
import { cn } from "@/lib/utils";
import { WorldMap, type MapMarker } from "@/registry/paragon/ui/world-map";

export interface LoginPoint {
  id: string;
  /** Longitude -180..180. */
  lng: number;
  /** Latitude -90..90. */
  lat: number;
  city: string;
  /** Login attempts from here. */
  count: number;
  /** Flags an impossible-travel / anomalous location. */
  anomaly?: boolean;
}

export interface LoginActivityMapProps extends React.ComponentProps<"div"> {
  points?: LoginPoint[];
  /** Accent color for benign login markers. Defaults to the primary token. */
  accent?: string;
  /** Disables the staggered dot enter and anomaly ping. */
  static?: boolean;
}

const DEFAULT_POINTS: LoginPoint[] = [
  { id: "sf", lng: -122.4, lat: 37.8, city: "San Francisco", count: 412 },
  { id: "nyc", lng: -74.0, lat: 40.7, city: "New York", count: 388 },
  { id: "ldn", lng: -0.1, lat: 51.5, city: "London", count: 201 },
  { id: "ber", lng: 13.4, lat: 52.5, city: "Berlin", count: 96 },
  { id: "sgp", lng: 103.8, lat: 1.35, city: "Singapore", count: 54 },
  { id: "syd", lng: 151.2, lat: -33.9, city: "Sydney", count: 33 },
  { id: "lag", lng: 3.4, lat: 6.5, city: "Lagos", count: 3, anomaly: true },
];

/**
 * A world map of login attempts with anomaly flags, built on the accurate
 * `world-map` primitive (real country geometry + geographic projection). Cities
 * plot as `markers` positioned by lng/lat; the primitive projects them onto the
 * correct landmass and renders a hover tooltip (city + attempt count).
 * Anomalous locations use the destructive tone with a pulsing ring. The map
 * reveals with a soft house enter on first view, and lists each anomaly as an
 * accessible callout. Deterministic geometry — no random or time-based values.
 */
export function LoginActivityMap({
  points = DEFAULT_POINTS,
  accent,
  static: isStatic = false,
  className,
  ...props
}: LoginActivityMapProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const animate = !isStatic && !reduced;
  const revealed = !animate || inView;

  const anomalies = React.useMemo(
    () => points.filter((p) => p.anomaly),
    [points],
  );
  const total = React.useMemo(
    () => points.reduce((s, p) => s + p.count, 0),
    [points],
  );

  const markers = React.useMemo<MapMarker[]>(
    () =>
      points.map((p) => ({
        lng: p.lng,
        lat: p.lat,
        label: p.city,
        value: `${p.count} attempt${p.count === 1 ? "" : "s"}`,
        tone: p.anomaly ? ("destructive" as const) : ("accent" as const),
        pulse: p.anomaly,
      })),
    [points],
  );

  return (
    <div
      ref={ref}
      data-slot="login-activity-map"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-login-activity-map" precedence="paragon">{`
        [data-login-map-canvas] {
          transition-property: opacity, transform, filter;
          transition-duration: var(--duration-slow, 500ms);
          transition-timing-function: var(--ease-out);
        }
        @media (prefers-reduced-motion: reduce) {
          [data-login-map-canvas] {
            transition: none !important;
            opacity: 1 !important;
            transform: none !important;
            filter: none !important;
          }
        }
      `}</style>

      <div className="mb-3 flex items-center justify-between gap-3">
        <h3 className="min-w-0 truncate text-sm font-semibold">
          Login activity
        </h3>
        <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
          {total.toLocaleString()} attempts · 24h
        </span>
      </div>

      <div
        data-login-map-canvas
        style={{
          opacity: revealed ? 1 : 0,
          transform: revealed ? "translateY(0)" : "translateY(12px)",
          filter: revealed ? "blur(0)" : "blur(4px)",
        }}
      >
        <WorldMap
          accent={accent}
          markers={markers}
          className="[--pg-map-land:color-mix(in_oklch,var(--color-foreground)_8%,transparent)]"
          aria-label={
            `Login attempts across ${points.length} cities. ` +
            (anomalies.length
              ? `${anomalies.length} anomalous location${
                  anomalies.length === 1 ? "" : "s"
                }: ${anomalies.map((a) => a.city).join(", ")}.`
              : "No anomalies.")
          }
        />
      </div>

      {anomalies.length > 0 ? (
        <ul className="mt-3 space-y-1.5 border-t pt-3">
          {anomalies.map((a) => (
            <li key={a.id} className="flex items-center gap-2 text-[13px]">
              <ShieldAlert
                aria-hidden
                className="size-3.5 shrink-0 text-destructive"
              />
              <span className="shrink-0 font-medium text-destructive">
                Anomalous sign-in
              </span>
              <span className="min-w-0 truncate text-muted-foreground">
                {a.city} · {a.count} attempt{a.count === 1 ? "" : "s"}
              </span>
            </li>
          ))}
        </ul>
      ) : (
        <p className="mt-3 border-t pt-3 text-[13px] text-muted-foreground">
          No anomalous sign-in locations detected.
        </p>
      )}
    </div>
  );
}