Uptime Status Bars in React and Tailwind (90-Day Status Page Strip)

4 min read

TL;DR Render one bar per day from an array of { date, uptime } where uptime can be null for days with no data. Color by threshold, give every bar an aria-label and a keyboard-reachable tooltip, show 30 days on phones and 90 on desktop, stagger the bars in with one CSS variable, and compute the headline percentage only over days that have data.

On this page
  1. The data
  2. Colors and the headline number
  3. The strip
  4. The tooltip
  5. The reveal
  6. Marking today
  7. Where the data comes from
  8. A full ops screen
  9. FAQ
  10. How do I build a status page uptime bar in React?
  11. How do I calculate uptime percentage for a status page?
  12. How many days should an uptime bar show on mobile?
  13. How do I animate uptime bars with Tailwind?

The row of thin green bars on a status page is one of the most copied patterns in dashboards. Each bar is a day, a red one is an incident, and the number at the end says how reliable the service has been. Ask Cursor or v0 for it and you usually get 90 divs with random colors, no way to see what a bar means, and an uptime number that counts missing days as perfect. I make animated React components at qodesign, including an ops dashboard with a service map, and this is how I build the strip.

Orbit Dashboard from qodesign: count-up KPIs, a live service map and a latency chart

That is the Orbit Dashboard, a free and MIT-licensed ops dashboard on qodesign. The uptime strip below fits into a screen like it.

The data

One entry per day, oldest first. uptime is a fraction from 0 to 1, or null when you have no data for that day (the monitor didn't exist yet, or the check itself was down):

ts
export type Day = {
  date: string; // "2026-10-21"
  uptime: number | null;
  incidents?: string[];
};

Keeping null separate from 1 is the most important decision in this component. A day with no data isn't a perfect day.

Colors and the headline number

ts
export function barColor(uptime: number | null) {
  if (uptime === null) return "bg-zinc-700";
  if (uptime >= 0.999) return "bg-emerald-500";
  if (uptime >= 0.99) return "bg-amber-400";
  return "bg-rose-500";
}

export function overallUptime(days: Day[]) {
  const known = days.filter((d) => d.uptime !== null) as (Day & { uptime: number })[];
  if (known.length === 0) return null;
  return known.reduce((sum, d) => sum + d.uptime, 0) / known.length;
}

const pct = new Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 3 });

Pick thresholds that match your SLA. With these, a day with about 1.4 minutes of downtime is still green, and a day under 99% (more than about 14 minutes down) is red. Averaging daily fractions is fine when each day is checked equally often; if check intervals vary, sum the minutes up and the minutes checked instead.

The strip

tsx
"use client";

import { useState } from "react";

export function UptimeBars({ name, days }: { name: string; days: Day[] }) {
  const [active, setActive] = useState<number | null>(null);
  const overall = overallUptime(days);
  const firstDesktopOnly = days.length - 30; // older days hide on phones

  return (
    <section className="rounded-xl border border-zinc-800 bg-zinc-950 p-4">
      <header className="mb-3 flex items-baseline justify-between text-sm">
        <h3 className="font-medium text-zinc-100">{name}</h3>
        <span className="tabular-nums text-zinc-400">
          {overall === null ? "No data" : `${pct.format(overall)} uptime`}
        </span>
      </header>

      <div className="relative">
        <ol className="flex h-9 items-stretch gap-[2px]" aria-label={`${name}, daily uptime`}>
          {days.map((d, i) => (
            <li
              key={d.date}
              className={`flex-1 ${i < firstDesktopOnly ? "hidden md:flex" : "flex"}`}
              style={{ "--i": i } as React.CSSProperties}
            >
              <button
                type="button"
                className={`uptime-bar w-full rounded-[2px] ${barColor(d.uptime)} transition-transform hover:scale-y-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white`}
                aria-label={label(d)}
                onMouseEnter={() => setActive(i)}
                onMouseLeave={() => setActive(null)}
                onFocus={() => setActive(i)}
                onBlur={() => setActive(null)}
              />
            </li>
          ))}
        </ol>
        {active !== null && <BarTooltip day={days[active]} index={active} count={days.length} />}
      </div>

      <div className="mt-2 flex justify-between text-xs text-zinc-500">
        <span className="md:hidden">30 days ago</span>
        <span className="hidden md:inline">{days.length} days ago</span>
        <span>Today</span>
      </div>
    </section>
  );
}

function label(d: Day) {
  const when = new Date(d.date + "T00:00:00Z").toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
  if (d.uptime === null) return `${when}: no data`;
  const extra = d.incidents?.length ? `, ${d.incidents.length} incident${d.incidents.length > 1 ? "s" : ""}` : "";
  return `${when}: ${pct.format(d.uptime)} uptime${extra}`;
}

Notes on the choices:

  • Each bar is a button, so keyboard users can Tab through the days and hear each label. An ordered list tells screen readers the days are in sequence.
  • flex-1 with a 2px gap lets the strip fill any width. On phones the older 60 days are hidden with hidden md:flex, so 30 bars stay thick enough to tap.
  • Dates are parsed and printed in UTC. Your monitor almost certainly buckets days in UTC, and local time would shift the labels by a day for half the world.

The tooltip

Position the tooltip by the bar's index as a percentage, so it needs no measuring:

tsx
function BarTooltip({ day, index, count }: { day: Day; index: number; count: number }) {
  const left = ((index + 0.5) / count) * 100;
  return (
    <div
      role="tooltip"
      className="pointer-events-none absolute bottom-full mb-2 w-48 -translate-x-1/2 rounded-lg border border-zinc-800 bg-zinc-900 p-2 text-xs text-zinc-200 shadow-lg"
      style={{ left: `clamp(6rem, ${left}%, calc(100% - 6rem))` }}
    >
      <p className="font-medium">{label(day)}</p>
      {day.incidents?.map((t) => (
        <p key={t} className="mt-1 text-zinc-400">{t}</p>
      ))}
    </div>
  );
}

The clamp() keeps the tooltip inside the card at both ends of the strip. On phones the hidden bars shift the math slightly; if that bothers you, compute the index within the visible range. The accessible tooltip post covers Escape to close and touch behavior in more depth.

The reveal

Bars growing up from the baseline, left to right, read as "loading history" and take under a second:

css
.uptime-bar {
  transform-origin: bottom;
  animation: bar-in 400ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
  animation-delay: calc(var(--i) * 6ms);
}
@keyframes bar-in {
  from { transform: scaleY(0.2); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .uptime-bar { animation: none; }
}

Six milliseconds per bar puts the last of 90 bars at about half a second, plus the bar's own 400ms. The --i variable is set on the li and inherited by the button. In Tailwind v4, hover:scale-y-110 uses the separate scale property, so it doesn't fight the entrance animation over transform.

Marking today

The last bar is today and still changing. A soft pulse says so:

tsx
className={`... ${i === days.length - 1 ? "motion-safe:animate-pulse" : ""}`}

Use it only on today. Ninety pulsing bars would be noise.

Where the data comes from

Most monitors (your own cron job, or a hosted uptime checker) can give you minutes down per day. Do the bucketing on the server, return exactly 90 entries with null for the gaps, and render the strip in a client component. Keep that endpoint cached; nobody needs per-second freshness on a 90-day history.

A full ops screen

An uptime strip usually sits next to a service map, a latency chart and an incidents table. The Orbit Dashboard has all of those, animated and free under MIT, in the free kit with a PROMPT.md for Cursor, v0 and Lovable. To animate the rest of a generated dashboard, see how to animate a v0 dashboard.

FAQ

How do I build a status page uptime bar in React?

Render one element per day from an array with the date and the uptime fraction, color each by threshold with Tailwind classes, and lay them out in a flex row with a small gap. Give each bar an aria-label with the date and percentage, and show details in a tooltip on hover and focus.

How do I calculate uptime percentage for a status page?

Average the daily uptime only over days that have data, and treat days without data as unknown instead of 100%. If check intervals differ between days, sum the minutes up and divide by the minutes checked.

How many days should an uptime bar show on mobile?

Around 30. Ninety bars on a phone become hairlines that are hard to tap, so hide the older days below the md breakpoint and keep the label saying how far back the strip goes.

How do I animate uptime bars with Tailwind?

Give each bar its index as a CSS variable and use one keyframe that scales it up from the bottom with a delay of a few milliseconds per index. Turn the animation off under prefers-reduced-motion.