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…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type UptimeSeverity = "minor" | "major" | "critical"

export interface UptimeIncident {
  title: string
  /** Pre-formatted duration ("42 min", "1h 05m") — the component never does time math. */
  durationLabel: string
  severity: UptimeSeverity
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/uptime-bars.json

Prompt

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. primary carries "fine" and destructive carries "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 no Date.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-only table lists only the days that actually had incidents.

On This Page