Blocks

Status Page

A public status page whose overall banner is derived from the worst service state and open incident, with per-service 90-day uptime strips, timestamped incident updates and upcoming maintenance windows.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Bell,
  CalendarClock,
  ChevronDown,
  CircleCheck,
  CircleDashed,
  OctagonAlert,
  RotateCw,
  TriangleAlert,
  Wrench,
  type LucideIcon,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/status-page.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "StatusPage" block with zod and
lucide-react; cn() (clsx + tailwind-merge) for classes, no other dependency.
This is the page a company points status.<domain> at: one sentence about
whether the platform is healthy, one row per service with its recent uptime,
the incidents being worked on right now, and the maintenance that is coming.
Its only job is to never let a green headline sit above an unresolved outage.

Contract
- A zod schema (`statusPageSchema` in a sibling contract file) is the single
  source of truth and props are z.infer of it — never a parallel interface:
  { status; title; asOf; services[]; incidents[]; maintenances[]; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" — the page's own render
  state, unrelated to whether the platform is healthy. A service's health lives
  in service.state.
- ServiceState = "operational" | "maintenance" | "degraded" | "partial_outage"
  | "major_outage", REPORTED by the host and never inferred from a percentage;
  "maintenance" exists so planned work does not read as a failure.
- StatusService = { id; name; description?; state; days: UptimeDay[] }.
  UptimeDay = { date: plain "YYYY-MM-DD" (a day has no instant, so it carries
  no offset); uptimePercent: number | null }. null means NEVER MEASURED, which
  is not zero. days are chronological, OLDEST FIRST, and services may carry
  different lengths.
- StatusIncident = { id; title; severity: "minor"|"major"|"critical"; stage:
  "investigating"|"identified"|"monitoring"|"resolved"; startedAt; resolvedAt:
  string|null; serviceIds: string[]; updates: IncidentUpdate[] }. `stage`
  decides open vs closed; resolvedAt is only the timestamp of it.
  IncidentUpdate = { id; at (ISO instant with an offset, never a pre-formatted
  label); stage; body }. Updates arrive in any order.
- MaintenanceWindow = { id; title; description?; scheduledStart; scheduledEnd;
  serviceIds }. scheduledEnd is exclusive: a window ending at asOf is over.
- asOf is an ISO instant and is the ONLY clock. Never call Date.now(): every
  "ago", "ends in", duration and live/finished decision derives from asOf, so
  the same payload always renders the same page and SSR matches hydration.
- Component props = the schema type plus: locale? ("en-US"), timeZone? ("UTC"),
  skeletonRows? (4, clamped 1-12), defaultExpandedServiceIds? (uncontrolled
  initial value only), showUptimeBars? (true), onRetry?, onSubscribe?,
  subscribeLabel? ("Subscribe to updates"). forwardRef<HTMLElement>, extends
  Omit<React.HTMLAttributes<HTMLElement>, "title">, rest spread on the root
  <section>. onRetry / onSubscribe are the consumer's: omit either and that
  button is not painted at all — a dead bell is worse than no bell.
- Export the banner arithmetic as summarizeStatusPage(data) ->
  { state; headline; operational; total; activeIncidents; liveMaintenances } so
  an alert, a badge or a digest email reuses it instead of growing a second
  opinion about "are we up". Export the day-bucketing, mean and format helpers
  too (uptimeDayState, meanUptime, formatUptime, formatDuration).

Behavior — four branches, not one plus three afterthoughts
- loading: the title and the "Updated …" line still render (they are known
  before the feed answers); below them a pulsing banner block and skeletonRows
  service rows, aria-hidden, with aria-busy on the root.
- empty: one centered panel explaining that nothing is monitored yet.
- error: a destructive-bordered panel printing errorMessage, falling back to a
  sentence that admits current health is UNKNOWN — never "everything is fine".
  The retry button only exists when onRetry is passed.
- ready: banner, services card, incidents card, maintenance card.
- status "ready" with zero services renders the EMPTY branch: a banner claiming
  all zero systems are operational is worse than saying nothing.

Behavior — the maths
- Rank ladder: operational 0 < maintenance 1 < degraded 2 < partial_outage 3 <
  major_outage 4. The banner state is the MAXIMUM over every service.state and
  over every OPEN incident (stage !== "resolved") mapped through
  minor->degraded, major->partial_outage, critical->major_outage. That single
  max is why an open incident can never hide behind a wall of green rows.
- Each state maps to one headline sentence ("All systems operational", "Partial
  system outage", …) and one row word ("Operational", "Degraded performance",
  …). Colour never carries a state on its own.
- clampPercent: clamp to 0-100; null or non-finite means "never measured" and
  stays null through every downstream calculation.
- uptimeDayState buckets a day: >= 99.99 operational, >= 99 degraded, >= 95
  partial_outage, else major_outage, null -> a neutral gap. One threshold
  function feeds the bar fill, the "days with incidents" count and the panel.
  "maintenance" is deliberately not a bucket: planned work is reported, never
  inferred from a number.
- meanUptime = sum over MEASURED days / count of MEASURED days; all-null
  history returns null and prints an em dash, never "0%".
- formatUptime grows precision from 2 to 4 decimals until the printed figure
  stops claiming a perfect period it did not have: 99.9994 prints as "99.9994%",
  never as a rounded "100.00%". If even 4 decimals still round up, truncate
  (floor at 1e-4) rather than lie. Exactly 100 prints as "100.00%".
- The service's uptime figure is computed over the WHOLE history, never over
  the visible window: narrowing the card must not change what a service claims.
- Day window: measure one shared box with a ResizeObserver and derive
  capacity = max(1, floor((width + BAR_GAP) / (MIN_BAR + BAR_GAP))) with
  MIN_BAR 4px and BAR_GAP 2px; windowSize = min(capacity, longest history).
  Before the first measurement windowSize is the longest history, so a narrow
  first paint renders thin bars sharing the width instead of overflowing.
- Every row paints exactly windowSize cells: a shorter history is padded with
  nulls on the OLD end, so every strip ends on the same "today" edge and the
  rows read as one aligned date axis.
- formatDuration prints every non-zero unit largest first ("2d 3h", "10h 50m")
  and "<1m" below a minute; formatAgo prints the largest unit only.
- Incidents sort open-before-resolved, then by severity rank, then newest
  first. Updates sort newest first; the first 3 show and the rest hide behind a
  "Show N earlier updates" disclosure.
- Maintenance is live when parse(scheduledEnd) > asOf; finished windows belong
  to a history page and are dropped. A window that straddles asOf is chipped
  "In progress" and reports "ends in …"; a future one reports "starts in …".

Behavior — degenerate data (all of these are branches, none of them crash)
- A percentage above 100 or below 0 is clamped, so a broken exporter cannot
  inflate a mean past 100.
- days: [] and all-null histories both render as a full row of neutral gaps
  with an em dash figure.
- Any timestamp that will not parse is printed VERBATIM and every figure that
  needed it is simply omitted — no NaN, no "Invalid Date". Updates with an
  unparseable `at` sink to the bottom of the list in their original order
  instead of reordering the ones that parsed.
- A serviceId that matches no service prints the raw id rather than being
  silently dropped: a missing association is information.
- stage "resolved" with resolvedAt null falls back to startedAt for the label
  and measures the duration to asOf.
- An unknown IANA zone or a malformed locale makes Intl.DateTimeFormat throw a
  RangeError at construction; catch it and retry without timeZone, then with
  "en-US", so one bad config string cannot blank the page.
- Format day labels in UTC whatever timeZone says: "2026-08-03" parses to UTC
  midnight, and printing that in a western zone shifts every label back a day.

Behavior — interaction, keyboard and ARIA
- Root is <section aria-labelledby> pointing at the h2 title, aria-busy while
  loading.
- Each service row is a <button aria-expanded aria-controls> whose aria-label
  is the whole row as a sentence: "REST API. Operational. Uptime 99.99% over 90
  measured days, 1 day with incidents." — because the strip itself is
  aria-hidden.
- The day strip is decorative and aria-hidden: 90 bars across a dozen rows
  would be a thousand focus stops. All of its information is in that sentence
  and in the expanded panel.
- Tab / Shift+Tab move between the buttons (expand-all, each row, each
  disclosure, retry, subscribe) — this is a list of buttons, not a composite
  widget, so no roving tabindex. Enter / Space activate natively. Escape on a
  focused expanded row collapses it and calls stopPropagation, so a surrounding
  dialog keeps its own Escape for the second press.
- Toggle the panel with the `hidden` attribute, not a collapsed 0fr grid track:
  a 0fr track still holds its tab stops, which turns the panel into an
  invisible keyboard trap.
- "Expand all" / "Collapse all" appears only when there is more than one
  service, and flips on whether every service is currently expanded.
- One persistent live region: <span role="status" aria-atomic className="sr-only">
  that speaks only when the page's PHASE changes (branch, or the derived banner
  state within ready). The first paint is the baseline and is never announced,
  so a screen reader is not read the whole page on load.
- Retry is one-shot per burst: the guard is a ref read AND written
  synchronously inside the handler, because a state flag is only visible after
  a re-render and a double click lands before that. Paint the pressed state
  from separate state. Re-arm on a 1200ms timer — a considered second attempt
  is a real retry, and a locked-forever button is worse than a duplicated
  request. Use aria-disabled plus a handler guard while locked, never the
  native disabled attribute: the browser blurs a focused disabled control to
  <body> and the keyboard user loses their place.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
  rounded-xl cards, bg-muted/40 expanded panels, text-muted-foreground for
  secondary text, bg-primary for healthy bars, bg-destructive (and /50) for
  outage bars, bg-muted-foreground/40 for maintenance, bg-muted for unmeasured
  gaps, ring-ring focus-visible rings with ring-offset-background. Swap the
  fills for var(--chart-1..5) if the page should read as a data viz.
- The banner carries a tinted border + background from the same state map
  (border-primary/30 bg-primary/5 up to border-destructive/50 bg-destructive/10)
  and always pairs an icon with a word.
- Bars are flex-1 min-w-0 with a fixed 2px gap, so the strip fills the row at
  any width; the strip's caption row is what the ResizeObserver measures.
- Merge the consumer className with cn() on the root; long names, bodies and ids
  get wrap-anywhere so an unbroken 56-character hostname wraps instead of
  overflowing the card.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and the
  retry spinner, motion-reduce:transition-none on the chevron rotation. Nothing
  functional depends on any of it.
- Cleanup: disconnect the ResizeObserver on unmount and whenever the observed
  node changes, and skip it entirely when ResizeObserver is undefined (SSR);
  clear the retry timer on unmount and before re-arming it.

Customization levers
- Sub-blocks: showUptimeBars={false} keeps the state rows and drops the
  strips; pass maintenances: [] or incidents: [] and those cards disappear
  entirely rather than rendering an empty shell. Drop the whole services card
  and you have a bare incident feed.
- Day density: MIN_BAR_PX (4) and BAR_GAP_PX (2) decide how many days survive a
  narrow card — raise MIN_BAR_PX for a chunkier 30-day look, lower it to keep
  all 90 in a sidebar. The capacity maths and the rendered gap read the same
  constant, so changing one number stays consistent.
- Thresholds: the 99.99 / 99 / 95 ladder in uptimeDayState is the whole colour
  policy; loosen it for an internal tool, tighten it for a five-nines contract.
- Disclosure depth: UPDATE_PREVIEW (3) is how many updates show before the
  "show earlier" button; set it to Infinity for a full public post-mortem feed.
- Precision: UPTIME_MAX_DECIMALS (4) caps how far the figure grows before it
  truncates.
- Palette: the STATE_FILL / STATE_TEXT / BANNER_TONE maps are the only place
  colour is decided. The default is monochrome (primary + destructive + muted);
  a five-colour brand palette is five edits in one object.
- Locale / zone: locale and timeZone drive every instant; day labels stay UTC on
  purpose. skeletonRows matches the loading skeleton to the number of services
  you expect.
- Actions: wire onSubscribe to your notification flow and flip subscribeLabel to
  confirm it; wire onRetry to your fetcher. Rows are buttons, so linking a
  service to its own detail page means swapping the row button for a link and
  moving the disclosure into a trailing button.

Concepts

  • Derived banner, never reported — the headline is the maximum of every service state and every open incident's severity on one rank ladder, so nobody can publish "All systems operational" while an unresolved outage is sitting three rows down. The same arithmetic is exported as summarizeStatusPage, so a badge elsewhere in the app cannot disagree with the page.
  • Injected clockasOf is a prop, not Date.now(). Every "ago", every "ends in", and every live-or-finished decision derives from it, which is what makes the page renderable on the server, screenshot-stable, and identical between SSR and hydration.
  • Ready-with-nothing is empty — a page whose fetch succeeded but returned no services falls into the empty branch instead of announcing that all zero systems are operational; "no data" and "no problems" are different claims.
  • Unmeasured is not zerouptimePercent: null is a neutral gap and drops out of the denominator, so a service that was never measured reads as an em dash, and a growing precision keeps 99.9994% from rounding itself up into a perfect period it never had.
  • Shared today edge — one ResizeObserver measures one box, and every row paints the same number of day cells, padding short histories on the old end. A service added last week ends its strip under today alongside the ninety-day ones instead of being stretched across the axis.
  • Phase-only live region — the status region speaks when the page changes phase (loading to ready, operational to outage) and stays silent on the first paint and on every re-render that changed nothing, which is the difference between a useful announcement and a screen reader reciting the page on load.

On This Page