Display
Uptime Bars
A status-page availability strip — one bar per day bucketed into operational / degraded / partial / major, with hover-or-focus incident detail and a width-aware day window.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/uptime-bars.jsonPrompt
Build a React + TypeScript + Tailwind "Uptime Bars" component (no extra
libraries; cn() from clsx + tailwind-merge).
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
rest props spread on the root.
- days: DayStatus[] — chronological, OLDEST FIRST; the last entry is today.
DayStatus = { key: string (stable identity + React key);
label: string (already formatted, e.g. "Jul 12");
uptimePercent: number (0-100);
incidents?: UptimeIncident[] }
UptimeIncident = { title: string; durationLabel: string (already formatted,
e.g. "1h 24m"); severity: "minor" | "major" | "critical" }
- serviceName?: string — shown next to the status dot; falls back to the
newest day's tier word ("Operational", "Major outage", …).
- periodLabel?: string — caption centered under the strip ("Last 90 days").
- overallLabel?: string — pre-formatted headline figure; when omitted the
component prints the mean of days at 2 decimals.
- onDayClick?: (day: DayStatus) => void — optional; the component never
navigates and never fetches.
- showLegend?: boolean = false — opt-in four-step legend row.
- compact?: boolean = false — denser strip (shorter/narrower bars, tighter
gaps, smaller header).
- The component does ZERO time math: dates and durations arrive
pre-formatted, so locale and timezone stay the consumer's decision and the
render stays pure (no Date.now(), no new Date(), no Intl inside).
Behavior
- One threshold function buckets a percent into four tiers and everything
(bar color, header dot, legend, aria-label) reads it: >= 100 operational,
>= 99 degraded, >= 95 partial outage, else major outage. Percents are
clamped to 0-100 and non-finite values read as 0, so bad input tints a bar
instead of breaking layout.
- Width-aware day window (this is the core trick): a ResizeObserver on the
strip computes capacity = max(1, floor((width + gap) / (minBar + gap))) and
renders only the most recent `capacity` days — the window slides off the
OLD end, never the new one. When it truncates, the footer says
"N of M days shown". No horizontal scrollbar, and bars never squeeze below
their minimum width. Capacity is null until the first measurement, so the
first paint renders every day rather than one.
- Hover or focus a bar to open one shared detail panel: date, percent, tier,
then either "No incidents" or the incident list (severity word, title,
duration). Panel placement is one pass: clamp left inside the strip, then
flip above/below by comparing the needed height against the space in the
viewport intersected with every clipping/scrolling ancestor — the panel is
positioned inside the component, so a card with overflow:hidden really can
clip it and measuring against the viewport alone would place an invisible
panel. Measurement lives in a ResizeObserver on the panel itself (its first
callback doubles as the initial measure) plus capture-phase scroll
(passive, rAF-throttled) and resize.
- Panel visibility: opacity-0 until the first measurement, never
visibility:hidden (that would break focus()). While remeasuring for the
next day it KEEPS the previous coordinates — adjacent bars are a few px
apart, so the carry-over frame reads as a slide; resetting to left:0 for a
frame is a visible jump on every bar you cross. Closing clears the stored
placement so a reopen fades in at its own position.
- Keyboard: roving tab stop — exactly one bar is in the tab order, Arrow
Left/Right + Home/End move focus between bars, focus opens the panel and
blur closes it. Blur is guarded (only clears when it still owns the panel)
and pointer-leave restores whatever the keyboard owns, so the mouse can
never steal a panel the keyboard opened.
- days=[] is a first-class branch: dashed "No uptime data for this period"
panel, headline figure falls back to "—", header keeps the service name.
Days with no data are simply absent from days[] — the strip starts later
instead of inventing bars.
- Cleanup: both ResizeObservers disconnected, the pending rAF cancelled, the
scroll/resize listeners removed on unmount and before every re-measure.
Rendering & styling
- Semantic tokens only, no hex/oklch anywhere. Four steps ride TWO tokens
because the default palette is monochrome and a hue ramp would collapse:
bg-primary (operational), bg-primary/40 (degraded), bg-destructive/50
(partial), bg-destructive (major). Chrome uses bg-card / bg-popover /
text-popover-foreground / border / ring-ring / text-muted-foreground.
Never put a --chart-* token on text — color only ranks the tiers, the
legend and the aria-labels carry the meaning.
- cn() merges the consumer className into the root; bar/gap/height sizes come
from one METRICS table keyed by regular vs compact.
- Transitions are decorative and carry motion-reduce:transition-none — with
reduced motion the panel appears instantly and nothing else is lost.
- Accessibility: the strip is role="group" with ONE summary aria-label
("<service> uptime 99.94% over the last 90 days, 3 days with incidents"),
deliberately not role="img" — an image's descendants are presentational,
which would strip the semantics off bars that are real focusable buttons.
Each bar's aria-label carries date + percent + tier + incident summary; the
detail panel is aria-hidden because it only repeats that label; the status
dot and legend swatches are aria-hidden; an sr-only table lists just the
days that had incidents, so nobody has to arrow through 90 bars to find
them.
Customization levers
- Density: the METRICS table is the single knob — gap, minimum bar width, bar
height and header type size per mode. Widening minBar shows fewer days at
the same width (the window math follows automatically).
- Thresholds: the four numbers in the tier function are the SLA policy; move
them (e.g. 99.9 / 99.5 / 99) without touching any rendering code.
- Palette: the tier→class map is one object. Remap it to var(--chart-1..5)
fills for a colorful host theme, or add a fifth step — the legend, the
header dot and the bars all follow because they read the same map.
- Sub-blocks: serviceName, periodLabel, overallLabel and showLegend are each
droppable; without them you get just the strip and its end labels.
- Missing days: uptimePercent is a plain number today. To draw a "no data"
gap the way a real status page does, widen it to number | null, add a fifth
tier backed by bg-muted, and skip nulls in the mean.
- Panel strategy: it is intentionally in-flow (no portal) so it inherits the
host's stacking context. If the host clips very aggressively, portal it to
document.body with position:fixed and reuse the same clamp-then-flip pass.
- Interactivity: onDayClick turns the bars into a real day picker (wire it to
an incident drawer); leave it out and the bars stay hover/focus-only.Concepts
- Width-aware windowing, not scrolling — the strip asks "how many bars fit at my minimum width?" and drops the oldest days that don't, then admits it in the footer (
N of M days shown). A 90-day history in a 260px card degrades to the most recent ~50 days instead of squeezing into unreadable hairlines or forcing a horizontal scrollbar. - Threshold tiering — a day is not a value on an axis, it is a bucket: one function maps a percent to
operational / degraded / partial / major, and the bar color, the header dot, the legend and the accessible label all read that single decision. Retuning the SLA means editing four numbers. - Four steps on two tokens — the default palette is monochrome, so a hue-based scale would collapse into one gray.
primarycarries "fine" anddestructivecarries "broken"; an alpha step separates the two grades inside each half, and text never borrows a chart token. - Pre-formatted labels keep the render pure — the component receives
"Jul 12"and"1h 24m"rather than timestamps, so timezone and locale stay with the consumer and there is noDate.now()in render to make output non-deterministic. - Placement carry-over — while the shared panel is being re-measured for the next day it keeps the previous coordinates instead of resetting; a one-frame carry-over between neighboring bars reads as a slide, whereas resetting to the strip's left edge reads as a jump on every bar you cross.
- Summary-first accessibility — 90 focusable bars are navigable but not browsable, so the group announces one sentence of headline, each bar owns its own label for arrow-key exploration, and an
sr-onlytable lists only the days that actually had incidents.
Diff Viewer
A GitHub-style text diff — unified or side-by-side, own LCS line matching with word-level highlights, foldable context and a pinned line-number gutter.
Steps Checklist
A dismissible onboarding checklist — jump-around completion, a required-only progress bar, per-step expandable instructions with a CTA, and a dependency-free celebration state.