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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/status-page.jsonPrompt
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 clock —
asOfis a prop, notDate.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 zero —
uptimePercent: nullis 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.
Case Study Grid
A four-state customer-story grid where the quantified result is the first-class field: every metric carries value, unit, direction and a required baseline (or says it has none), industry and company-size facets really filter with truthful counts, and logos of any aspect ratio are optically area-matched.
NPS Survey
A whole Net Promoter Score round in one block: a keyboard-reachable 0–10 scale, a follow-up question reworded per detractor / passive / promoter bucket, an optional contact-me box, and a one-submission-per-session thank-you.