Security

Threat Alert

An EDR threat-detection card with severity, host, MITRE technique, an expandable detection timeline, and isolate/dismiss triage actions.

Install

npx shadcn@latest add @paragon/threat-alert

threat-alert.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  Check,
  ChevronDown,
  Cpu,
  ShieldAlert,
  ShieldX,
  X,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type ThreatSeverity = "critical" | "high" | "medium" | "low";

export interface ThreatTimelineEvent {
  time: string;
  label: string;
}

export interface ThreatAlertProps extends React.ComponentProps<"div"> {
  severity?: ThreatSeverity;
  /** Short threat title. */
  title?: string;
  /** Affected host. */
  host?: string;
  /** MITRE ATT&CK technique id + name. */
  technique?: string;
  /** Detection process / file. */
  process?: string;
  /** Ordered detection timeline. */
  timeline?: ThreatTimelineEvent[];
  /** Disables enter animation. */
  static?: boolean;
}

const SEVERITY_META: Record<
  ThreatSeverity,
  { label: string; ring: string; dot: string; text: string; bg: string }
> = {
  critical: {
    label: "Critical",
    ring: "text-destructive",
    dot: "bg-destructive",
    text: "text-destructive",
    bg: "bg-destructive/10",
  },
  high: {
    label: "High",
    ring: "text-destructive",
    dot: "bg-destructive",
    text: "text-destructive",
    bg: "bg-destructive/10",
  },
  medium: {
    label: "Medium",
    ring: "text-warning",
    dot: "bg-warning",
    text: "text-warning",
    bg: "bg-warning/10",
  },
  low: {
    label: "Low",
    ring: "text-muted-foreground",
    dot: "bg-muted-foreground",
    text: "text-muted-foreground",
    bg: "bg-secondary",
  },
};

const DEFAULT_TIMELINE: ThreatTimelineEvent[] = [
  { time: "14:02:11", label: "Suspicious PowerShell spawned by winword.exe" },
  { time: "14:02:13", label: "Encoded command executed, reached out to 185.220.x.x" },
  { time: "14:02:19", label: "Credential access attempt on LSASS" },
];

type Resolution = "none" | "contained" | "dismissed";

/**
 * An EDR threat-detection card: severity, host, MITRE technique, and an
 * expandable detection timeline, with triage actions. Expanding uses the
 * accordion grid-rows trick (0fr→1fr) so no height is animated. Triaging
 * blur-crossfades the action row to a resolved state. Reduced motion keeps the
 * expand instantaneous but preserves the opacity change.
 */
export function ThreatAlert({
  severity = "critical",
  title = "Credential dumping detected",
  host = "WKS-FINANCE-04",
  technique = "T1003 · OS Credential Dumping",
  process = "powershell.exe",
  timeline = DEFAULT_TIMELINE,
  static: isStatic = false,
  className,
  ...props
}: ThreatAlertProps) {
  const reduced = useReducedMotion();
  const [open, setOpen] = React.useState(false);
  const [resolution, setResolution] = React.useState<Resolution>("none");
  const meta = SEVERITY_META[severity];

  return (
    <div
      data-slot="threat-alert"
      className={cn(
        "w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      style={
        !isStatic && !reduced
          ? ({
              animation: "threat-enter var(--duration-base) var(--ease-out)",
            } as React.CSSProperties)
          : undefined
      }
      {...props}
    >
      <style href="paragon-threat-alert" precedence="paragon">{`
        @keyframes threat-enter {
          from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-slot="threat-alert"] { animation: none !important; }
        }
      `}</style>

      <div className={cn("flex items-start gap-3 p-4", meta.bg)}>
        <span className={cn("mt-0.5 shrink-0", meta.ring)}>
          <ShieldAlert className="size-5" />
        </span>
        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-2">
            <span
              className={cn(
                "flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
                meta.text,
              )}
            >
              <span className={cn("size-1.5 rounded-full", meta.dot)} />
              {meta.label}
            </span>
            <span className="text-[11px] text-muted-foreground tabular-nums">
              Detected 14:02
            </span>
          </div>
          <h3 className="mt-1 text-sm font-semibold text-foreground">{title}</h3>
        </div>
      </div>

      <div className="grid grid-cols-2 gap-px bg-border">
        <Cell label="Host" value={host} />
        <Cell label="Process" value={process} mono />
        <Cell label="Technique" value={technique} span />
      </div>

      {/* Expandable timeline */}
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        className="flex w-full items-center justify-between px-4 py-2.5 text-left text-xs font-medium text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        Detection timeline
        <ChevronDown
          className={cn(
            "size-4 transition-transform duration-(--duration-base) ease-(--ease-out)",
            open && "rotate-180",
          )}
        />
      </button>
      <div
        className={cn(
          "grid px-4 transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
          open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div className="overflow-hidden">
          <ol className="space-y-2.5 pb-3">
            {timeline.map((event, i) => (
              <li key={i} className="flex gap-3 text-xs">
                <span className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
                  {event.time}
                </span>
                <span className="flex-1 text-foreground">{event.label}</span>
              </li>
            ))}
          </ol>
        </div>
      </div>

      {/* Triage */}
      <div className="border-t p-3">
        <AnimatePresence mode="wait" initial={false}>
          {resolution === "none" ? (
            <motion.div
              key="actions"
              className="flex gap-2"
              initial={false}
              exit={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)" }}
              transition={{ duration: 0.15 }}
            >
              <button
                type="button"
                onClick={() => setResolution("contained")}
                className="pressable inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-destructive px-3 text-[13px] font-medium text-destructive-foreground transition-[opacity] duration-(--duration-fast) hover:opacity-90 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                <ShieldX className="size-4" />
                Isolate host
              </button>
              <button
                type="button"
                onClick={() => setResolution("dismissed")}
                className="pressable inline-flex h-8 items-center justify-center gap-1.5 rounded-lg bg-card px-3 text-[13px] font-medium shadow-border transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                <X className="size-4" />
                Dismiss
              </button>
            </motion.div>
          ) : (
            <motion.div
              key="resolved"
              className={cn(
                "flex items-center gap-2 rounded-lg px-3 py-1.5 text-[13px] font-medium",
                resolution === "contained"
                  ? "bg-success/10 text-success"
                  : "bg-secondary text-muted-foreground",
              )}
              initial={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
            >
              {resolution === "contained" ? (
                <>
                  <Check className="size-4" />
                  Host isolated — investigation opened
                </>
              ) : (
                <>
                  <Cpu className="size-4" />
                  Marked benign — added to allowlist
                </>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

function Cell({
  label,
  value,
  mono,
  span,
}: {
  label: string;
  value: string;
  mono?: boolean;
  span?: boolean;
}) {
  return (
    <div className={cn("bg-card px-4 py-2.5", span && "col-span-2")}>
      <p className="text-[11px] font-medium text-muted-foreground">{label}</p>
      <p className={cn("mt-0.5 truncate text-[13px] text-foreground", mono && "font-mono")}>
        {value}
      </p>
    </div>
  );
}