An EDR threat-detection card with severity, host, MITRE technique, an expandable detection timeline, and isolate/dismiss triage actions.
npx shadcn@latest add @paragon/threat-alert"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>
);
}