A 90-day status-tick uptime chart with a per-day hover tooltip; incident days pulse exactly once when they first scroll into view.
npx shadcn@latest add @paragon/uptime-barsAlso installs: tooltip
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
export type DayStatus = "operational" | "degraded" | "outage" | "maintenance";
export interface UptimeDay {
/** Label, e.g. "Mar 14". */
date: string;
status: DayStatus;
/** Uptime percentage for the day. */
uptime: number;
/** Optional incident summary shown in the tooltip. */
note?: string;
}
export interface UptimeBarsProps extends React.ComponentProps<"div"> {
/** Days oldest→newest. Overrides `count` when provided. */
days?: UptimeDay[];
/** Number of days to synthesize when `days` is not given. */
count?: number;
/** Service name for the header. */
label?: string;
}
const barTint: Record<DayStatus, string> = {
operational: "bg-success",
degraded: "bg-warning",
outage: "bg-destructive",
maintenance: "bg-primary/40",
};
const statusLabel: Record<DayStatus, string> = {
operational: "Operational",
degraded: "Degraded",
outage: "Outage",
maintenance: "Maintenance",
};
// Deterministic history — seeded incidents scaled to the requested window,
// no Math.random / Date.now during render.
function buildDays(count: number): UptimeDay[] {
const n = Math.max(1, count);
// Incidents expressed as fractions of the window so they scale with `count`.
const seeds: { at: number; status: DayStatus; uptime: number; note: string }[] =
[
{ at: 0.13, status: "degraded", uptime: 98.4, note: "Elevated API latency (17m)" },
{ at: 0.34, status: "outage", uptime: 91.2, note: "us-east-1 database failover (2h11m)" },
{ at: 0.6, status: "maintenance", uptime: 99.9, note: "Scheduled DB upgrade" },
{ at: 0.81, status: "degraded", uptime: 97.8, note: "Webhook queue backlog (42m)" },
];
const incidents = new Map<
number,
{ status: DayStatus; uptime: number; note: string }
>();
for (const s of seeds) {
const idx = Math.min(n - 1, Math.round(s.at * (n - 1)));
incidents.set(idx, { status: s.status, uptime: s.uptime, note: s.note });
}
const now = new Date(Date.UTC(2026, 6, 6));
return Array.from({ length: n }, (_, i) => {
const dayOffset = n - 1 - i;
const d = new Date(now);
d.setUTCDate(d.getUTCDate() - dayOffset);
const date = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(d);
const incident = incidents.get(i);
return incident
? { date, ...incident }
: { date, status: "operational" as const, uptime: 100 };
});
}
/**
* An uptime bar chart. Each day is a thin tick tinted by status, evenly spaced
* via a flex band scale with rounded caps and no sub-pixel gaps; hovering (or
* focusing) a tick opens a tooltip with the date, status, and uptime. Incident
* days pulse exactly once when they first scroll into view — state indication,
* not an ambient loop — via an IntersectionObserver-gated animation that never
* repeats. Reduced motion drops the pulse and keeps the color. Ticks are real
* buttons, so the whole strip is keyboard-navigable.
*/
export function UptimeBars({
days,
count = 90,
label = "API",
className,
...props
}: UptimeBarsProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const [pulse, setPulse] = React.useState(false);
const series = React.useMemo(
() => (days && days.length > 0 ? days : buildDays(count)),
[days, count],
);
React.useEffect(() => {
if (reduced) return;
const node = rootRef.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setPulse(true);
io.disconnect();
}
},
{ threshold: 0.4 },
);
io.observe(node);
return () => io.disconnect();
}, [reduced]);
const empty = series.length === 0;
const overall = empty
? 0
: series.reduce((sum, d) => sum + d.uptime, 0) / series.length;
return (
<TooltipProvider>
<div
ref={rootRef}
data-slot="uptime-bars"
className={cn(
"w-full rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<style href="paragon-uptime-bars" precedence="paragon">{`
@keyframes pg-uptime-pulse {
0% { transform: scaleY(1); }
35% { transform: scaleY(1.35); }
100% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
[data-uptime-incident] { animation: none !important; }
}
`}</style>
<div className="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-medium">{label}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{empty ? "No data" : `${overall.toFixed(2)}% uptime · ${series.length} days`}
</span>
</div>
{empty ? (
<div className="flex h-8 items-center justify-center rounded-md bg-current/[0.03] text-sm text-muted-foreground">
No data
</div>
) : (
<div className="flex items-end gap-[2px]">
{series.map((day, i) => {
const isIncident = day.status !== "operational";
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${day.date}: ${statusLabel[day.status]}, ${day.uptime}% uptime`}
className={cn(
"group h-8 min-w-0 flex-1 rounded-full outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span
data-uptime-incident={isIncident || undefined}
className={cn(
"block h-full w-full origin-bottom rounded-full transition-[filter] duration-(--duration-fast)",
"group-hover:brightness-110",
barTint[day.status],
)}
style={
isIncident && pulse && !reduced
? {
animation: `pg-uptime-pulse 600ms var(--ease-out) ${i * 6}ms both`,
}
: undefined
}
/>
</button>
</TooltipTrigger>
<TooltipContent className="text-center">
<p className="font-medium">{day.date}</p>
<p className="text-[11px] opacity-80 tabular-nums">
{statusLabel[day.status]} · {day.uptime}%
</p>
{day.note && (
<p className="mt-0.5 max-w-40 text-[11px] opacity-80 text-pretty">
{day.note}
</p>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
<div className="mt-2 flex items-center justify-between text-[11px] text-muted-foreground">
<span>{empty ? "—" : `${series.length} days ago`}</span>
<span>Today</span>
</div>
</div>
</TooltipProvider>
);
}