Skip to contentVibraUI
Feedback & status

Uptime bar

A day-by-day uptime strip with a tooltip on every bar and the percentage at the end of the label row.

No hooks of its own, so it renders on the server, and it never reads the clock: every date comes from the days you pass, and they are read in UTC unless you say otherwise, which is what keeps the server and the client rendering the same strip wherever the reader is sitting. computeUptime is exported and pure — an up day counts one, a degraded day a half, a down day nothing, and a day with no data is left out of the reckoning entirely, so a gap in the record cannot drag the number down. The tooltip only reaches a pointer, so each bar also carries its date and status as an aria-label and the strip carries a summary of the whole run. showLegend renders the first and last day of the run under the strip — the range, not a colour key: what each colour means is already on every bar, in its label and its tooltip.

Install

npx shadcn@latest add @vibra/uptime-bar

Needs the @vibra registry in your components.json — set it up once.

Examples

Compact, one row per service

The small size with the legend off, stacked into a service list.

Props

PropTypeDefaultDescription
days{ date: string | Date; status: "up" | "degraded" | "down" | "none"; label?: string }[]—One bar each, oldest first; none is a gap in the record, not an outage.
days[].datestring | Date—The day the bar stands for; also the first line of its tooltip.
days[].status"up" | "degraded" | "down" | "none"—Colours the bar and sets what it weighs: one, a half, nothing, or nothing counted.
labelTextstring—Summarises the run when label is markup rather than a string; the group's accessible name is built from it.
timeZonestring"UTC"IANA zone the dates are read in. A bar stands for a whole day, and those days come out of a database in UTC; pass the reader's zone only when the days are local ones.
days[].labelstring—Detail read after the status — "2 incidents", "14m of downtime".
uptimenumbercomputeUptime(days)A percentage measured somewhere more authoritative than a daily rollup.
labelReact.ReactNode—What the strip is measuring; pass a string and it names the strip for a screen reader too.
showLegendbooleantruePuts the first and last day of the run under the strip.
size"sm" | "default""default"sm drops the bars to h-6 and the label row to text-xs.
computeUptime(days: UptimeDay[]) => number—The percentage of measured days that stayed up; 100 when nothing was measured.

Dependencies

Source

components/ui/uptime-bar.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { formatDate, percentOf } from "@/lib/format"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

const uptimeBarDayVariants = cva("min-w-0 flex-1 rounded-[2px]", {
  variants: {
    status: {
      up: "bg-success",
      degraded: "bg-warning",
      down: "bg-danger",
      // Not a good day and not a bad one — the track colour, so a gap in the
      // record reads as a gap rather than as an outage.
      none: "bg-muted",
    },
    size: {
      default: "h-8",
      sm: "h-6",
    },
  },
  defaultVariants: { status: "up", size: "default" },
})

/** How one day went: up, degraded, down, or never measured. */
export type UptimeDayStatus = NonNullable<VariantProps<typeof uptimeBarDayVariants>["status"]>

/** How tall the strip stands. */
export type UptimeBarSize = NonNullable<VariantProps<typeof uptimeBarDayVariants>["size"]>

// A day with no data has no weight at all, which is what keeps it out of the
// arithmetic below rather than being counted as either a good day or a bad one.
const DAY_WEIGHTS: Partial<Record<UptimeDayStatus, number>> = { up: 1, degraded: 0.5, down: 0 }

const DAY_LABELS: Record<UptimeDayStatus, string> = {
  up: "Operational",
  degraded: "Degraded",
  down: "Down",
  none: "No data",
}

export type UptimeDay = {
  date: string | Date
  status: UptimeDayStatus
  /** Detail for the day, read after its status — "2 incidents", "14m of downtime". */
  label?: string
}

/** The share of measured days that stayed up, as a percentage: a degraded day counts half, a day with no data not at all, and nothing measured reads 100. */
export function computeUptime(days: UptimeDay[]): number {
  let counted = 0
  let earned = 0
  for (const day of days) {
    const weight = DAY_WEIGHTS[day.status]
    if (weight === undefined) continue
    counted += 1
    earned += weight
  }
  if (counted === 0) return 100
  return percentOf(earned, counted)
}

// The way a status page writes it: whole when the number is whole, two decimals
// when it is not, because the difference between 99.99 and 100 is the story.
function uptimeText(percent: number): string {
  return Number.isInteger(percent) ? `${percent}%` : `${percent.toFixed(2)}%`
}

export type UptimeBarProps = React.ComponentProps<"div"> & {
  days: UptimeDay[]
  /** Overrides the computed percentage, for a figure measured somewhere more authoritative. */
  uptime?: number
  label?: React.ReactNode
  /** Summarises the run when `label` is markup rather than a string. */
  labelText?: string
  /**
   * IANA zone the dates are read in. A bar stands for a whole day and those
   * days come out of a database in UTC, so UTC is what decides which day a
   * bar is — read in the reader's own zone, a strip drifts a day west of
   * Greenwich. Pass the reader's zone only when the days are local ones.
   */
  timeZone?: string
  /** Puts the first and last day of the run under the strip. */
  showLegend?: boolean
  size?: UptimeBarSize
}

function UptimeBar({
  className,
  days,
  uptime,
  label,
  labelText,
  showLegend = true,
  size = "default",
  timeZone = "UTC",
  ...props
}: UptimeBarProps) {
  const percent = uptime ?? computeUptime(days)
  const reading = `${uptimeText(percent)} uptime`
  const first = days[0]
  const last = days[days.length - 1]
  // Inside the strip the record is carried by colour alone, so the whole run is
  // summarised on the group and every day names itself on its own bar. The
  // tooltip only ever reaches a pointer.
  const summary = [
    labelText ?? (typeof label === "string" ? label : "Daily status"),
    `${days.length} ${days.length === 1 ? "day" : "days"}`,
    reading,
  ].join(", ")

  return (
    <div
      data-slot="uptime-bar"
      data-size={size}
      className={cn("flex w-full flex-col gap-2", className)}
      {...props}
    >
      <div data-slot="uptime-bar-header" className="flex items-baseline justify-between gap-3">
        {label ? (
          <span
            data-slot="uptime-bar-label"
            className={cn(
              "min-w-0 truncate text-muted-foreground",
              size === "sm" ? "text-xs" : "text-sm"
            )}
          >
            {label}
          </span>
        ) : (
          <span />
        )}
        <span
          data-slot="uptime-bar-uptime"
          className={cn(
            "shrink-0 font-medium tabular-nums",
            size === "sm" ? "text-xs" : "text-sm"
          )}
        >
          {reading}
        </span>
      </div>

      <div
        data-slot="uptime-bar-track"
        role="group"
        aria-label={summary}
        className="flex w-full items-stretch gap-px"
      >
        {days.map((day, index) => {
          const date = formatDate(day.date, "medium", { timeZone })
          const detail = day.label
            ? `${DAY_LABELS[day.status]}. ${day.label}`
            : DAY_LABELS[day.status]

          return (
            <Tooltip key={index}>
              <TooltipTrigger
                render={
                  <span
                    data-slot="uptime-bar-day"
                    data-status={day.status}
                    role="img"
                    aria-label={`${date}: ${detail}`}
                    className={cn(uptimeBarDayVariants({ status: day.status, size }))}
                  />
                }
              />
              {/* COUPLED TO registry/vibra/ui/tooltip.tsx: the popup is a
                  centred row by default, and these three cancel that so the
                  date can sit above its status. If a future upstream refresh
                  changes how TooltipContent lays its children out, revisit
                  these rather than adding more on top. */}
              <TooltipContent className="flex-col items-start gap-0.5">
                <span className="font-medium">{date}</span>
                <span>{detail}</span>
              </TooltipContent>
            </Tooltip>
          )
        })}
      </div>

      {showLegend && first && last ? (
        <div
          data-slot="uptime-bar-legend"
          className="flex items-baseline justify-between gap-3 text-xs text-muted-foreground"
        >
          <span>{formatDate(first.date, "medium", { timeZone })}</span>
          <span>{formatDate(last.date, "medium", { timeZone })}</span>
        </div>
      ) : null}
    </div>
  )
}

export { UptimeBar, uptimeBarDayVariants }