Feedback

Alert Triage Row

A security alert row with a severity band and assignee; acknowledging settles the row into a muted state as the severity tint drains to neutral.

Install

npx shadcn@latest add @paragon/alert-triage-row

Also installs: avatar

alert-triage-row.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ShieldAlert, ShieldCheck } from "lucide-react";
import { Avatar, AvatarFallback } from "@/registry/paragon/ui/avatar";
import { cn } from "@/lib/utils";

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

export interface AlertTriageRowProps
  extends Omit<
    React.ComponentProps<"div">,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
  > {
  severity?: AlertSeverity;
  /** Alert title. */
  title?: string;
  /** Source / rule that fired. */
  source?: string;
  /** Relative timestamp. */
  time?: string;
  /** Assignee name; renders an initials avatar. */
  assignee?: string;
  /** Start acknowledged (muted). */
  defaultAcknowledged?: boolean;
  onAcknowledge?: () => void;
}

const sevMeta: Record<
  AlertSeverity,
  { label: string; band: string; text: string; chip: string }
> = {
  critical: {
    label: "Critical",
    band: "bg-destructive",
    text: "text-destructive",
    chip: "bg-destructive/12 text-destructive",
  },
  high: {
    label: "High",
    band: "bg-warning",
    text: "text-warning",
    chip: "bg-warning/15 text-warning",
  },
  medium: {
    label: "Medium",
    band: "bg-primary",
    text: "text-primary",
    chip: "bg-primary/12 text-primary",
  },
  low: {
    label: "Low",
    band: "bg-muted-foreground/50",
    text: "text-muted-foreground",
    chip: "bg-muted text-muted-foreground",
  },
};

/**
 * A security alert row. A severity band on the left edge tints the row; the
 * Acknowledge button settles the alert into a muted state — the row nudges,
 * desaturates, and the severity band drains to neutral, so triaged alerts
 * recede without leaving the list. The action label crossfades to a checked
 * "Acknowledged". Reduced motion keeps the color settle, drops the nudge.
 */
export function AlertTriageRow({
  severity = "critical",
  title = "Impossible-travel sign-in detected",
  source = "Identity · anomaly rule",
  time = "3m ago",
  assignee = "Priya Raghavan",
  defaultAcknowledged = false,
  onAcknowledge,
  className,
  ...props
}: AlertTriageRowProps) {
  const reduced = useReducedMotion();
  const [acked, setAcked] = React.useState(defaultAcknowledged);
  const meta = sevMeta[severity];

  const acknowledge = () => {
    if (acked) return;
    setAcked(true);
    onAcknowledge?.();
  };

  return (
    <motion.div
      data-slot="alert-triage-row"
      data-acknowledged={acked || undefined}
      // A single settle nudge on acknowledge; no loop, so it never fights.
      animate={reduced ? undefined : { x: acked ? [0, 6, 0] : 0 }}
      transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
      className={cn(
        "relative flex items-center gap-3 overflow-hidden rounded-xl bg-card py-3 pr-3 pl-5 text-card-foreground shadow-border",
        "transition-[opacity,filter] duration-(--duration-base)",
        acked && "opacity-65 saturate-[0.6]",
        className,
      )}
      {...props}
    >
      {/* Severity band — drains to neutral once acknowledged. */}
      <span
        aria-hidden
        className={cn(
          "absolute inset-y-2 left-2 w-1 rounded-full transition-colors duration-(--duration-base)",
          acked ? "bg-border" : meta.band,
        )}
      />

      <span
        className={cn(
          "flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors duration-(--duration-base)",
          acked ? "bg-muted text-muted-foreground" : meta.chip,
        )}
      >
        {acked ? (
          <ShieldCheck className="size-4" />
        ) : (
          <ShieldAlert className="size-4" />
        )}
      </span>

      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          <span
            className={cn(
              "rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-colors duration-(--duration-base)",
              acked ? "bg-muted text-muted-foreground" : meta.chip,
            )}
          >
            {meta.label}
          </span>
          <span
            className={cn(
              "truncate text-[13px] font-medium",
              acked && "font-normal text-muted-foreground",
            )}
          >
            {title}
          </span>
        </div>
        <p className="mt-0.5 truncate text-xs text-muted-foreground">
          {source} · {time}
        </p>
      </div>

      {assignee && (
        <Avatar size="sm" className="shrink-0" title={assignee}>
          <AvatarFallback name={assignee} />
        </Avatar>
      )}

      <button
        type="button"
        onClick={acknowledge}
        disabled={acked}
        className={cn(
          "pressable inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium whitespace-nowrap",
          "transition-colors duration-(--duration-fast)",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          acked
            ? "text-muted-foreground"
            : "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        )}
      >
        {reduced ? (
          <span className="inline-flex items-center gap-1.5">
            {acked && <Check className="size-3.5" />}
            {acked ? "Acknowledged" : "Acknowledge"}
          </span>
        ) : (
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={acked ? "done" : "todo"}
              className="inline-flex items-center gap-1.5"
              initial={{ opacity: 0, y: 6, filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, y: -6, filter: "blur(2px)" }}
              transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
            >
              {acked && <Check className="size-3.5" />}
              {acked ? "Acknowledged" : "Acknowledge"}
            </motion.span>
          </AnimatePresence>
        )}
      </button>
    </motion.div>
  );
}