Incident Timeline
A status-page incident: current state, affected services, timestamped updates, and a composer to post the next one.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/incident-timeline.json'use client'
import { useState } from 'react'
import './incident-timeline.css'
export interface IncidentUpdate { status: string; text: string; at: string }
export interface IncidentTimelineProps {
title?: string
updates: IncidentUpdate[]
services: { name: string; state: string }[]
now?: string
fill?: 'dark' | 'light'
className?: string
}
const STAGES = ['Investigating', 'Identified', 'Monitoring', 'Resolved']
const mins = (t: string) => { const [h, m] = t.split(':').map(Number); return h * 60 + m }
/** Status-page incident with update composer. */
export function IncidentTimeline({ title = 'Elevated API error rates', updates: u0, services, now = '14:52', fill = 'dark', className }: IncidentTimelineProps) {
const [ups, setUps] = useState(u0)
const [st, setSt] = useState(STAGES[Math.min(3, STAGES.indexOf(u0[u0.length - 1].status) + 1)])
const [text, setText] = useState('')
const [clock, setClock] = useState(mins(now))
const cur = ups[ups.length - 1].status
const done = cur === 'Resolved'
const dur = mins(ups[ups.length - 1].at) - mins(ups[0].at)
const fmt = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
return (
<div className={['sd-inc', className].filter(Boolean).join(' ')} data-fill={fill} data-done={done || undefined}>
<div className="sd-inc__h"><b>{title}</b><span className="sd-inc__st" aria-live="polite"><i />{done ? `Resolved · lasted ${Math.floor(dur / 60) ? `${Math.floor(dur / 60)}h ` : ''}${dur % 60}m` : cur}</span></div>
<div className="sd-inc__sv" aria-label="Affected services">{services.map((s) => <span key={s.name}>{s.name}<em>{done ? 'Operational' : s.state}</em></span>)}</div>
<ol reversed aria-label="Updates">{[...ups].reverse().map((u, i) => <li key={ups.length - i}><b>{u.status}<time>{u.at} UTC</time></b><p>{u.text}</p></li>)}</ol>
{!done && <form onSubmit={(e) => { e.preventDefault(); if (!text.trim()) return; const t = clock + 7; setClock(t); setUps([...ups, { status: st, text: text.trim(), at: fmt(t) }]); setText(''); setSt(STAGES[Math.min(3, STAGES.indexOf(st) + 1)]) }}>
<textarea aria-label="Update message" placeholder="What changed?" value={text} onChange={(e) => setText(e.target.value)} />
<div><select aria-label="New status" value={st} onChange={(e) => setSt(e.target.value)}>{STAGES.map((s) => <option key={s}>{s}</option>)}</select><button type="submit" disabled={!text.trim()}>Post update</button></div>
</form>}
</div>
)
}.sd-inc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-inc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-inc :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-inc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-inc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-inc { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-inc__h { display: grid; gap: 4px; }
.sd-inc__h b { font-size: 15px; }
.sd-inc__st { justify-self: start; display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 99px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-inc__st i { width: 7px; height: 7px; border-radius: 50%; background: var(--fg); animation: sd-inc-p 1.6s ease-in-out infinite; }
@keyframes sd-inc-p { 50% { opacity: 0.25; } }
.sd-inc[data-done] .sd-inc__st { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-inc[data-done] .sd-inc__st i { animation: none; background: var(--on); }
.sd-inc__sv { display: flex; flex-wrap: wrap; gap: 4px; }
.sd-inc__sv span { display: inline-flex; gap: 5px; align-items: center; height: 24px; padding: 0 8px; border-radius: 7px; background: var(--soft); font-size: 11.5px; }
.sd-inc__sv em { font-style: normal; color: var(--mute); }
.sd-inc ol { list-style: none; margin: 0; padding: 0 0 0 16px; display: grid; gap: 10px; position: relative; max-height: 210px; overflow-y: auto; }
.sd-inc ol::before { content: ''; position: absolute; left: 4px; top: 6px; bottom: 6px; width: 1.5px; background: var(--line); }
.sd-inc li { position: relative; display: grid; gap: 2px; font-size: 12.5px; animation: sd-inc-in 260ms ease-out; }
@keyframes sd-inc-in { from { opacity: 0; transform: translateY(-6px); } }
.sd-inc li::before { content: ''; position: absolute; left: -16px; top: 4px; width: 9px; height: 9px; border-radius: 50%; background: var(--bg); box-shadow: inset 0 0 0 2px var(--fg); }
.sd-inc li:first-child::before { background: var(--fg); }
.sd-inc li b { font-size: 12px; }
.sd-inc li time { font-size: 11px; color: var(--mute); margin-left: 6px; font-weight: 400; }
.sd-inc li p { margin: 0; color: var(--mute); }
.sd-inc form { display: grid; gap: 6px; padding-top: 8px; border-top: 1px solid var(--line); }
.sd-inc form textarea { height: 52px; resize: none; padding: 8px 10px; border-radius: 10px; border: 0; background: var(--soft); color: var(--fg); font: 14px ui-sans-serif, system-ui; outline: 0; }
.sd-inc form textarea:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-inc form > div { display: flex; gap: 6px; }
.sd-inc form select { flex: 1; height: 34px; padding: 0 8px; border-radius: 9px; border: 0; background: var(--soft); color: var(--fg); font: 13px ui-sans-serif, system-ui; outline: 0; min-width: 0; }
.sd-inc form button { padding: 0 14px; height: 34px; border-radius: 9px; font-size: 12.5px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-inc form button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-inc *, .sd-inc *::before, .sd-inc *::after { animation: none !important; transition: none !important; } }Overview
The incident moves through Investigating, Identified, Monitoring and Resolved. Each update is stamped and the newest sits on top of a vertical timeline.
Affected services show their own state; resolving clears them and states how long the incident lasted.
When to use
- Status pages, internal incident tools, customer comms.
When not to use
- Multi-incident dashboards.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/incident-timeline.json$
npx shadcn@latest add https://sundaeui.dev/r/incident-timeline.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/incident-timeline.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/incident-timeline.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy incident-timeline.tsx and incident-timeline.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { IncidentTimeline } from '@/components/sundae/incident-timeline'
<IncidentTimeline />Props
| Prop | Type | Default |
|---|---|---|
updates*Updates, oldest first. | { status: string; text: string; at: string }[] | — |
services*Affected services. | { name: string; state: string }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The timeline is an ordered list with times in <time> elements.
- The status change is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-inc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Incident Timeline free?
- Yes. Incident Timeline is free for personal and commercial projects, with full source on this page.
- How do I install Incident Timeline?
- Run npx shadcn@latest add https://sundaeui.dev/r/incident-timeline.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Incident Timeline support keyboard and screen readers?
- The timeline is an ordered list with times in <time> elements. The status change is announced.
- Does Incident Timeline respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Order Tracker
Components · RefinedVideo Trimmer
Components · RefinedDeploy Status
Components · Refined- Pro
Reasoning Trace
Components · Refined Toast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined