Maintenance Banner
A scheduled-window notice that counts down to the start, switches to an in-progress state with an elapsed bar, and clears itself once the window ends — every verdict derived from an injected instant.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/maintenance-banner.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MaintenanceBanner" component using
lucide-react and the local cn() helper (clsx + tailwind-merge). It announces one
scheduled maintenance window: it counts down to the start, switches to an
in-progress state while the window runs, and disappears once the window is over.
Every one of those verdicts comes from an instant handed in as a prop — the
component never reads the clock — so the server render, the first paint and a
test all agree on what it says.
Contract
- Export a forwardRef<HTMLDivElement> component whose props extend
Omit<React.HTMLAttributes<HTMLDivElement>, "title">; the remaining props are
spread on the banner element and className is merged through cn().
- Instants: type MaintenanceInstant = string | number | Date, normalised by one
toMs() helper (Date -> getTime(), otherwise new Date(x).getTime()).
- Required props: start (inclusive), end (exclusive), now (the injected instant).
- Optional: severity = "info" | "warning" | "critical" (default "info");
title?: ReactNode (per-phase default); children = the message body;
action?: { label: string; href: string }; icon?: ReactNode (per-phase default,
null removes it); timeZone?: string (IANA; omitted = the reader's own zone);
locale = "en-US"; dismissible = true;
defaultDismissedPhase?: MaintenancePhase | null (read once, on mount);
onDismiss?: (phase: MaintenancePhase) => void;
returnFocusRef?: React.RefObject<HTMLElement | null>.
- Also export the phase union "scheduled" | "in-progress" | "ended" | "invalid"
and the pure resolver resolveMaintenancePhase(start, end, now), so the host app
can gate its own UI (freeze Save, pause a poller, flip to read-only) on exactly
the same verdict instead of re-deriving it slightly differently.
Behavior
- Phase maths, in this order: any non-finite ms, or end <= start -> "invalid";
now < start -> "scheduled"; now < end -> "in-progress"; otherwise "ended".
Start inclusive, end exclusive, so each boundary instant belongs to exactly
one phase and no tick can land nowhere.
- "scheduled" prints "Starts in <d>", "in-progress" prints "Ends in <d>", where
<d> is the coarsest two units, floored: "2 days 4 hours", "3 hours 12 minutes",
"14 minutes", "under a minute". Floor instead of round on purpose — a notice
may understate the time left, never overstate it.
- "in-progress" also draws an elapsed bar at
clamp((now - start) / (end - start), 0, 1). Clamped because a laggy poller or
a rounded server timestamp can hand in a now that sits just outside the window.
- "ended": the banner renders no visible output at all — it clears itself, no
parent state required. "invalid": it keeps the headline, the message and the
tone but drops the window line, the countdown and the bar, and never
self-clears; a schedule it cannot read is not a schedule it can wait out.
- Dismissal is remembered against the phase it happened in (one state field,
MaintenancePhase | null; hidden while it equals the current phase). Dismissing
the heads-up therefore does not silence the notice that the window has actually
opened: that one is no longer an announcement, it is the current state of the
product, so it returns on the phase change. onDismiss reports the phase so the
app can persist a key such as "db-migration:scheduled";
defaultDismissedPhase seeds it back on the next visit and is deliberately read
only at mount, so a stale stored value can never fight a live click.
- Window text: one Intl.DateTimeFormat for the date, one for the time, one read
only for its timeZoneName part. "Same day" is decided by comparing the two
formatted date strings, so there is no hand-rolled timezone arithmetic: a
same-day window prints "Sat, 8 Aug, 02:00 - 04:30 GMT+2" and a window crossing
midnight repeats the date on the right-hand side. If the zone abbreviation
differs between the two ends (a window straddling a DST change), each side
carries its own label instead of sharing one that would be wrong for one end.
- Reader zone: when timeZone is omitted, read
Intl.DateTimeFormat().resolvedOptions().timeZone through useSyncExternalStore
with a getServerSnapshot that returns null, falling back to "UTC" while it is
null. The server render and the first paint print a window explicitly labelled
UTC — never a lie — and React swaps in the reader's own zone right after
hydration, with no mismatch and no mounted flag. Cache that lookup in a
module-level variable: getSnapshot must return the identical string on every
call. Wrap the formatter construction in try/catch and fall back to
("en-US", "UTC") — a typo in a locale tag or a zone id throws RangeError, and a
page that crashes on its way to announcing downtime is the worst outcome
available.
- Keyboard: add no key handlers. The only two controls are a real <a href> and a
real <button type="button">, so Tab / Shift+Tab move between them and Enter
(plus Space on the button) activates them — native behaviour, untouched.
Nothing is ever natively disabled: dismissible={false} simply does not render
the button, so focus can never be trapped on a dead control.
- ARIA: the visible bar is role="region" with a per-phase aria-label. The
announcement lives in a separate sr-only node carrying role="status" (or
role="alert" when severity is "critical") whose text is derived from the phase
alone and never contains the countdown — a live region fed by a ticking value
would re-announce on every tick. Keep the politeness attribute constant and
gate the *text* instead: hold the mount-time message in state and render an
empty string while the current message still equals it. An empty region is
silent, so opening a page that already carries a notice does not read it out
of nowhere, and every later change lands inside a region assistive tech is
already watching (switching aria-live on in the same commit as the text tends
to swallow that first announcement).
- Focus: the banner can vanish under the reader two ways — the window ends, or
the dismiss button removes itself on click. Track focus-within with onFocus /
onBlur writing a ref synchronously (in onBlur compare event.relatedTarget
against the banner, so moving between the link and the button does not count as
leaving), then in a layout effect running after the commit that removed it,
move focus to returnFocusRef?.current, else to the sr-only status node with
focus({ preventScroll: true }) since it is visually hidden. Render that node as
a sibling of the conditional banner inside one fragment: its slot never
changes, so the DOM node survives the transition and is still there to receive
focus. Focus must never fall back to <body>.
- Cleanup: the component starts no timer, no rAF, no listener and no observer;
the single subscription is useSyncExternalStore's, whose subscribe returns a
no-op unsubscribe. The clock belongs to the caller:
const [now, setNow] = useState(() => Date.now())
useEffect(() => { const id = setInterval(() => setNow(Date.now()), 30_000)
return () => clearInterval(id) }, [])
One interval per page, cleared on unmount; minute-grained wording does not
need a faster cadence, and an SSR-rendered page can pass the server timestamp
until that effect starts.
Rendering & styling
- Semantic tokens only, no hardcoded colors. Per severity: info =
border-border bg-muted/60 with text-muted-foreground accents; warning =
border-primary/30 bg-primary/10 with text-primary; critical =
border-destructive/30 bg-destructive/10 with text-destructive. The elapsed
track is bg-foreground/10 and its fill takes the severity token; the pill dot
uses bg-current so it inherits whichever tone is active.
- Layout is one flex row: icon (aria-hidden, shrink-0) -> a min-w-0 flex-1
column holding headline + phase pill, message, window line and elapsed bar ->
a shrink-0 self-start cluster with the action link and the dismiss button.
min-w-0 is what lets long copy wrap instead of shoving the controls off-screen.
- The icon reports the stage (CalendarClock scheduled, Wrench in progress,
TriangleAlert invalid) while severity reports only the volume, so glyph and
colour carry different information rather than repeating each other.
- Motion is decorative: a pulsing dot in the in-progress pill
(motion-reduce:animate-none) and transition-[width] on the elapsed fill
(motion-reduce:transition-none). With motion off every number, label and state
change still lands.
- The countdown span is tabular-nums so the row does not jitter as digits change,
and both ends of the window are marked up as <time dateTime={iso}> so machines
read the instant while humans read the localised text.
Customization levers
- Cadence: the component moves at whatever rate the parent updates `now`. 30s is
plenty for the built-in wording; go to 1s only if you also teach formatDuration
to print seconds.
- Wording and i18n: formatDuration plus the four phase lookup tables (title,
pill, region label, announcement) sit at the top of the file — swap the strings
or route them through your t() function without touching the state machine.
- Severity palette: add a level by adding one entry to the TONES record
(root / accent / pill / fill); nothing else in the component knows how many
levels exist. Point a level at var(--chart-1..5) if your ops palette is
separate from primary/destructive.
- Density and placement: px-4 py-3 text-sm is the only sizing, so pass
py-2 text-xs through className for a thin strip under a top nav, or wrap the
banner in a sticky top-0 z-50 container to pin it. The component itself stays
position-agnostic.
- Sub-blocks: icon={null} drops the glyph, dismissible={false} drops the button,
omitting action drops the link, and the elapsed bar is one conditional block
away from being deleted. title and children are ReactNode, so a region name in
<strong> or an inline changelog link can live in the copy.
- Persistence: pair onDismiss with storage and defaultDismissedPhase to restore
it, e.g. write `${windowId}:${phase}` on dismiss and read it back on mount
(guard with typeof window in SSR frameworks). Key it per window so next
month's notice is not silenced by last month's dismissal.
- One source of truth for the whole app: call resolveMaintenancePhase with the
same three instants in a provider and flip the app to read-only on
"in-progress", so the banner and the behaviour can never drift apart.Concepts
- Injected instant —
nowarrives as a prop, so the phase, the countdown and the bar are pure functions of three numbers: the server and the client cannot disagree, and a test drives the whole state machine by handing in a different number instead of faking a clock. - Phase-scoped dismissal — a dismissal is remembered against the phase it happened in, so closing the heads-up never silences the notice that the window has actually opened; that one is the current state of the product, not an announcement, and it returns on the phase change.
- Refusal over a fake countdown — an end that is not after the start (or an unparsable date) drops the window line, the countdown and the self-clearing instead of rendering a plausible-looking number, because a wrong maintenance time is worse than none.
- Phase-only live region — the announcement is a separate visually hidden node whose text derives from the phase alone; the ticking countdown never enters a live region, so a screen reader hears “maintenance is under way” once instead of on every update.
- Deliberate focus successor — when the banner disappears under the reader — window over, or the dismiss button removing itself — focus is handed to
returnFocusRefor to the status node that outlives the banner in the same fragment slot, never to<body>. - Reader-timezone window — the window is printed in the visitor's own resolved zone with the abbreviation attached, falling back to a labelled UTC during SSR and the first paint, and splitting the label per end when the window straddles a DST change.
Session Expiry
A sign-out warning that arms at a threshold before an injected expiry instant — live countdown, one-shot refresh, a closed state that says why, and cross-tab dismissal over BroadcastChannel.
Unsaved Changes Guard
A three-way exit for unsaved work — beforeunload armed only while dirty, in-app navigation held through an injected navigate handler, and a save / discard / stay dialog instead of the browser's bare prompt.