Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, CalendarClock, TriangleAlert, Wrench, X } from "lucide-react"
import { cn } from "@/lib/utils"

const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR

/** A layout effect warns when it is rendered on the server; the fallback never runs there anyway. */
const useIsomorphicLayoutEffect = typeof document === "undefined" ? React.useEffect : React.useLayoutEffect

export type MaintenanceSeverity = "info" | "warning" | "critical"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/maintenance-banner.json

Prompt

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 instantnow arrives 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 returnFocusRef or 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.

On This Page