Charts

Uptime Bars

A 90-day status-tick uptime chart with a per-day hover tooltip; incident days pulse exactly once when they first scroll into view.

Install

npx shadcn@latest add @paragon/uptime-bars

Also installs: tooltip

uptime-bars.tsx

"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>
  );
}