Incident list
A feed of incidents with a severity badge, a status pill, when each started, and how long it ran.
No hooks of its own, so it renders on the server. Every severity ships a differently shaped icon beside its word, so it survives without colour. The duration is worked out from startedAt and resolvedAt alone, and the "started" stamp is a frozen RelativeTime — an incident row is a record of when something happened, not a clock — so nothing here differs between the server render and the browser.
Install
$
npx shadcn@latest add @vibra/incident-listNeeds the @vibra registry in your components.json — set it up once.
Examples
import { IncidentList, type Incident } from "@/components/ui/incident-list"
// Fixed timestamps: the durations and the relative times are worked out from
// these, so nothing here depends on when the page was rendered.
const INCIDENTS: Incident[] = [
{
id: "inc-2411",
title: "Elevated error rate on the ingest API",
severity: "critical",
status: "investigating",
startedAt: "2026-09-04T07:20:00.000Z",
affected: ["API", "Background workers"],
href: "#inc-2411",
},
{
id: "inc-2409",
title: "Scheduled exports delivered late",
severity: "major",
status: "monitoring",
startedAt: "2026-09-03T18:05:00.000Z",
affected: ["Exports"],
href: "#inc-2409",
},
{
id: "inc-2404",
title: "Expired TLS certificate on the EU edge",
severity: "critical",
status: "resolved",
startedAt: "2026-07-11T02:14:00.000Z",
resolvedAt: "2026-07-11T02:55:00.000Z",
affected: ["API", "Web app"],
href: "#inc-2404",
},
{
id: "inc-2398",
title: "Slow cohort queries in the warehouse",
severity: "minor",
status: "resolved",
startedAt: "2026-06-18T13:02:00.000Z",
resolvedAt: "2026-06-18T16:20:00.000Z",
affected: ["Reports"],
},
]
export default function IncidentListDemo() {
return <IncidentList className="w-full max-w-lg" incidents={INCIDENTS} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| incidents | Incident[] | — | One row each, newest first; an empty array renders emptyMessage instead. |
| incidents[].id | string | — | React key and your own reference. |
| incidents[].title | React.ReactNode | — | What broke, in the reader's terms. |
| incidents[].severity | "critical" | "major" | "minor" | "info" | — | Sets the badge's tone and its icon. |
| incidents[].status | "investigating" | "identified" | "monitoring" | "resolved" | — | Where the response has got to, shown as an outline pill. |
| incidents[].startedAt | Date | string | — | When it began; read out as a relative time. |
| incidents[].resolvedAt | Date | string | — | Set once it is over; the row then reads "Resolved in 1h 12m". |
| incidents[].affected | string[] | — | The services it took with it, listed at the end of the meta row. |
| incidents[].href | string | — | Turns the row into a link to the incident's own write-up. |
| emptyMessage | React.ReactNode | No incidents in this period. | Shown in place of the rows when there is nothing to report. |
Dependencies
Source
import * as React from "react"
import { CircleAlertIcon, InfoIcon, OctagonAlertIcon, TriangleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDuration } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { RelativeTime } from "@/components/ui/relative-time"
/** How much of the product an incident took with it. */
export type IncidentSeverity = "critical" | "major" | "minor" | "info"
/** Where an incident has got to in the response. */
export type IncidentStatus = "investigating" | "identified" | "monitoring" | "resolved"
const SEVERITY_LABELS: Record<IncidentSeverity, string> = {
critical: "Critical",
major: "Major",
minor: "Minor",
info: "Info",
}
// Four different outlines, so severity still reads at a glance in greyscale.
const SEVERITY_ICONS: Record<
IncidentSeverity,
React.ComponentType<React.ComponentProps<"svg">>
> = {
critical: OctagonAlertIcon,
major: TriangleAlertIcon,
minor: CircleAlertIcon,
info: InfoIcon,
}
// COUPLED TO registry/vibra/ui/badge.tsx: outline is the one variant with no
// background of its own, which is what lets a tone supply one. Each class here
// cancels one the variant sets — border-border, and text-foreground. If a
// future upstream refresh gives outline a background, add the reset here;
// incident-list.test.tsx asserts the tone classes land, so it will fail rather
// than quietly rendering a grey badge.
const SEVERITY_TONES: Record<IncidentSeverity, string> = {
critical: "border-transparent bg-danger-muted text-danger",
major: "border-transparent bg-warning-muted text-warning",
minor: "border-transparent bg-info-muted text-info",
info: "border-transparent bg-muted text-muted-foreground",
}
const STATUS_LABELS: Record<IncidentStatus, string> = {
investigating: "Investigating",
identified: "Identified",
monitoring: "Monitoring",
resolved: "Resolved",
}
export type Incident = {
id: string
title: React.ReactNode
severity: IncidentSeverity
status: IncidentStatus
startedAt: Date | string
/** Set once the incident is over; the row then reads how long it ran. */
resolvedAt?: Date | string
/** The services it took with it, listed under the title. */
affected?: string[]
/** Turns the row into a link to the incident's own write-up. */
href?: string
}
export type IncidentListProps = React.ComponentProps<"div"> & {
incidents: Incident[]
emptyMessage?: React.ReactNode
}
function IncidentList({
className,
incidents,
emptyMessage = "No incidents in this period.",
...props
}: IncidentListProps) {
return (
<div
data-slot="incident-list"
className={cn("flex w-full flex-col divide-y rounded-lg border", className)}
{...props}
>
{incidents.length === 0 ? (
<p
data-slot="incident-list-empty"
className="px-3 py-8 text-center text-sm text-muted-foreground"
>
{emptyMessage}
</p>
) : (
incidents.map((incident) => {
const SeverityIcon = SEVERITY_ICONS[incident.severity]
// Both moments come from props, so the duration is the same figure on
// the server and in the browser — no clock is read during a render.
const duration = incident.resolvedAt
? formatDuration(
Math.max(
0,
new Date(incident.resolvedAt).getTime() - new Date(incident.startedAt).getTime()
)
)
: null
const rowClassName = cn(
"flex flex-col gap-2 px-3 py-3",
incident.href &&
"transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-muted/50 focus-ring"
)
const row = (
<>
<span className="flex items-start gap-2">
<Badge
data-slot="incident-list-severity"
variant="outline"
className={cn("shrink-0", SEVERITY_TONES[incident.severity])}
>
<SeverityIcon aria-hidden="true" />
{SEVERITY_LABELS[incident.severity]}
</Badge>
<span
data-slot="incident-list-title"
className="min-w-0 flex-1 text-sm font-medium text-pretty"
>
{incident.title}
</span>
<Badge
data-slot="incident-list-status"
variant="outline"
className="shrink-0 text-muted-foreground"
>
{STATUS_LABELS[incident.status]}
</Badge>
</span>
<span
data-slot="incident-list-meta"
className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
>
<span data-slot="incident-list-started">
{"Started "}
{/* Frozen: an incident row is a record of when something
happened, not a clock, and RelativeTime already renders the
same text on the server and on the client. */}
<RelativeTime date={incident.startedAt} updateInterval={null} />
</span>
{duration ? (
<span data-slot="incident-list-duration">{`Resolved in ${duration}`}</span>
) : null}
{incident.affected?.length ? (
<span data-slot="incident-list-affected" className="min-w-0 truncate">
<span className="sr-only">{"Affected: "}</span>
{incident.affected.join(", ")}
</span>
) : null}
</span>
</>
)
// Two tags rather than a computed one, so the href stays typed as an
// anchor's own prop.
return incident.href ? (
<a
key={incident.id}
data-slot="incident-list-item"
data-severity={incident.severity}
data-status={incident.status}
href={incident.href}
className={rowClassName}
>
{row}
</a>
) : (
<div
key={incident.id}
data-slot="incident-list-item"
data-severity={incident.severity}
data-status={incident.status}
className={rowClassName}
>
{row}
</div>
)
})
)}
</div>
)
}
export { IncidentList }