A status-page row with one cell per day. It leads with the uptime and the outage count, the cells are one tab stop moved with the arrow keys, and the day you are on is written underneath instead of in a tooltip.
API
93.33% uptime1 outage day, 3 degraded
Website
98.33% uptime1 degraded
Background jobs
98.28% uptime1 outage day
"use client"
import { UptimeTracker, type UptimeDay } from "@/components/ballmac/uptime-tracker"
function make(seed: number, overrides: Record<number, Partial<UptimeDay>> = {}): UptimeDay[] {
return Array.from({ length: 60 }, (_, i) => {
const d = new Date(2026, 7, 27 + i)
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
return { date, status: "up" as const, ...overrides[i] }
})
}
const api = make(1, { 12: { status: "degraded", note: "Slow responses for 25 minutes" }, 33: { status: "down", note: "42 minutes of errors after a bad deploy" }, 34: { status: "degraded", note: "Recovering, queues drained" }, 51: { status: "degraded", note: "Elevated latency in one region" } })
const web = make(2, { 20: { status: "degraded", note: "CDN cache purge slowed pages" } })
const jobs = make(3, { 5: { status: "none" }, 6: { status: "none" }, 44: { status: "down", note: "Queue worker crashed for 3 hours" } })
export default function UptimeTrackerDemo() {
return (
<div className="grid w-full max-w-2xl gap-6 rounded-xl border bg-card p-5">
<UptimeTracker label="API" days={api} startLabel="60 days ago" />
<UptimeTracker label="Website" days={web} startLabel="60 days ago" />
<UptimeTracker label="Background jobs" days={jobs} startLabel="60 days ago" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/uptime-trackerAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/uptime-tracker.tsx// Ballmac UI: Uptime Tracker. https://ui.ballmac.com/components/uptime-tracker "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type UptimeStatus = "up" | "degraded" | "down" | "none" type UptimeDay = { /** The day, as text, for example "Oct 3". */ date: string status: UptimeStatus /** What happened, for example "42 minutes of errors on checkout". */ note?: string } type UptimeTrackerProps = Omit<React.ComponentProps<"div">, "children"> & { /** Oldest first. */ days: UptimeDay[] /** The service name. */ label: string /** Overrides the uptime shown. Defaults to the share of days that were up. */ uptime?: number /** Text under the left end, for example "90 days ago". */ startLabel?: string /** Text under the right end. Defaults to "Today". */ endLabel?: string } const WORDS: Record<UptimeStatus, string> = { up: "Operational", degraded: "Degraded", down: "Outage", none: "No data" } const TONE: Record<UptimeStatus, string> = { up: "bg-chart-2", degraded: "bg-chart-3", down: "bg-destructive", none: "bg-muted", } /** * A row of one cell per day, the way status pages show a service's history. It reads as a sentence first (the uptime and how many days went wrong), and the * cells are a single tab stop that you move along with the arrow keys; the day you are on is written out underneath, not hidden in a tooltip. */ function UptimeTracker({ days, label, uptime, startLabel, endLabel, className, ...props }: UptimeTrackerProps) { const msg = useMessages() const id = React.useId() const [active, setActive] = React.useState<number | null>(null) const [focus, setFocus] = React.useState(Math.max(0, days.length - 1)) const refs = React.useRef<(HTMLButtonElement | null)[]>([]) const counted = days.filter((d) => d.status !== "none") const upDays = counted.filter((d) => d.status === "up").length const value = uptime ?? (counted.length ? (upDays / counted.length) * 100 : 0) const bad = days.filter((d) => d.status === "down").length const soft = days.filter((d) => d.status === "degraded").length const day = active !== null ? days[active] : undefined const word = (s: UptimeStatus) => msg(`uptime-tracker.${s}`, WORDS[s]) function move(e: React.KeyboardEvent, i: number) { const last = days.length - 1 const next = e.key === "ArrowRight" ? Math.min(last, i + 1) : e.key === "ArrowLeft" ? Math.max(0, i - 1) : e.key === "Home" ? 0 : e.key === "End" ? last : e.key === "Escape" ? -1 : null if (next === null) return e.preventDefault() if (next < 0) return setActive(null) setFocus(next) setActive(next) refs.current[next]?.focus() } return ( <div data-slot="uptime-tracker" className={cn("grid min-w-0 gap-2.5", className)} {...props}> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> <p id={`${id}-l`} className="text-sm font-medium">{label}</p> <p className="text-sm tabular-nums"> <span className="font-semibold">{msg("uptime-tracker.uptime", "{percent}% uptime", { percent: (Math.round(value * 100) / 100).toString() })}</span> {(bad > 0 || soft > 0) && ( <span className="ms-2 text-xs text-muted-foreground"> {[bad > 0 && (bad === 1 ? msg("uptime-tracker.outage1", "1 outage day") : msg("uptime-tracker.outages", "{count} outage days", { count: bad })), soft > 0 && msg("uptime-tracker.slow", "{count} degraded", { count: soft })].filter(Boolean).join(", ")} </span> )} </p> </div> <div role="group" aria-labelledby={`${id}-l`} className="flex h-9 gap-px sm:gap-0.5" onMouseLeave={() => setActive(null)}> {days.map((d, i) => ( <button key={i} ref={(el) => { refs.current[i] = el }} type="button" tabIndex={i === focus ? 0 : -1} aria-label={msg("uptime-tracker.cell", "{date}: {status}{note}", { date: d.date, status: word(d.status), note: d.note ? `. ${d.note}` : "" })} onMouseEnter={() => setActive(i)} onFocus={() => { setFocus(i) setActive(i) }} onBlur={() => setActive(null)} onKeyDown={(e) => move(e, i)} className="group/c relative h-full min-w-0 flex-1 rounded-[3px] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/60" > <span aria-hidden="true" className={cn("absolute inset-0 rounded-[3px] transition-[opacity,transform] motion-reduce:transition-none", TONE[d.status], active !== null && active !== i ? "opacity-50" : "", active === i && "scale-y-110")} /> </button> ))} </div> <div className="flex min-h-5 items-center justify-between gap-3 text-xs text-muted-foreground" aria-live="polite"> {day ? ( <p className="min-w-0 truncate text-foreground"> <span className="font-medium">{day.date}</span> · {word(day.status)} {day.note ? ` · ${day.note}` : ""} </p> ) : ( <> <span>{startLabel ?? msg("uptime-tracker.start", "{count} days ago", { count: days.length })}</span> <span>{endLabel ?? msg("uptime-tracker.today", "Today")}</span> </> )} </div> </div> ) } export { UptimeTracker, type UptimeDay, type UptimeStatus, type UptimeTrackerProps }Update the import paths to match your project setup.
Usage
import { UptimeTracker } from "@/components/ballmac/uptime-tracker"The full example is in the Code tab above.
Examples
A clean month
Dashboard
100% uptime
"use client"
import { UptimeTracker } from "@/components/ballmac/uptime-tracker"
export default function UptimeTrackerPerfect() {
return (
<div className="w-full max-w-xl">
<UptimeTracker label="Dashboard" days={Array.from({ length: 30 }, (_, i) => ({ date: `Day ${i + 1}`, status: "up" as const }))} uptime={100} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
days*Oldest first. | UptimeDay[] | — |
label*The service name. | string | — |
uptimeOverrides the uptime shown. Defaults to the share of days that were up. | number | — |
startLabelText under the left end, for example "90 days ago". | string | — |
endLabelText under the right end. Defaults to "Today". | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Cells | Buttons named for the day, the status in words and the note; one tab stop; Left, Right, Home, End move; Escape clears |
| Detail | The day you are on is text in a live region |
| Status | Never by color alone: every cell's name says it |
Use with AI
days is [{ date, status: 'up'|'degraded'|'down'|'none', note? }] oldest first; label names the service. uptime overrides the computed share of days that were up; startLabel and endLabel caption the ends. Cells are buttons with a name that includes the date, the status and the note; hover or focus writes the day underneath. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Uptime Tracker (@ballmac/uptime-tracker) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A public or internal status page
- A reliability section in a dashboard
Not for
- Minute-level charts; use chart-line
- Listing incidents; use incident-1
Registry JSON: https://ui.ballmac.com/r/uptime-tracker.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Gauge
A half-circle gauge with a needle, colored zones and a target mark. The value, the zone it is in and the target are written as text, and the needle sweeps in once and holds still with reduced motion.
Status Dot
A compact status indicator that always pairs its color with a visible label.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Timeline
A composable sequence of milestones with current and complete states, dates, and connectors.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.