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

> 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.

- URL: https://qodesign.dev/blog/uptime-status-bars-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-22
- Updated: 2026-10-22
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/previews/orbit-dashboard.webp)

That is the [Orbit Dashboard](/components/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](/blog/accessible-tooltip-react-tailwind-no-library) 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](/components/orbit-dashboard) has all of those, animated and free under MIT, in the [free kit](/free) with a PROMPT.md for Cursor, v0 and Lovable. To animate the rest of a generated dashboard, see [how to animate a v0 dashboard](/blog/animate-v0-dashboard-react-tailwind).

## 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.
