Connection Status
An online / offline / reconnecting bar with an exponential-backoff retry countdown, a manual retry button and SSR-safe browser subscription.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/connection-status.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ConnectionStatus" component using
lucide-react icons (Wifi, WifiOff, RefreshCw).
Contract
- export type ConnectionState = "online" | "offline" | "reconnecting".
- export interface ConnectionStatusLabels { online: string; offline: string;
retryingIn: (seconds: number) => string; reconnecting: string;
retryNow: string } with defaults "Back online" / "You're offline" /
`You're offline — retrying in ${s}s` / "Reconnecting…" / "Retry now".
- forwardRef div extending Omit<React.HTMLAttributes<HTMLDivElement>,"children">.
Props: status? (controlled ConnectionState);
position = "top" | "bottom" | "inline" (default "top");
showWhenOnline = false; onlineDuration = 3000;
onRetry?: () => Promise<boolean>; retryDelay = 4000; maxRetryDelay = 60000;
labels?: Partial<ConnectionStatusLabels>. Remaining props spread on the bar,
className merged with cn(). The ref points at the bar, which is null while
the bar is hidden.
- Clamp numbers before use: baseDelay = max(1000, retryDelay),
capDelay = max(baseDelay, maxRetryDelay), hold = max(0, onlineDuration).
A 0 or negative delay would busy-loop the countdown and hammer the endpoint.
Behavior
- Browser status comes from useSyncExternalStore: subscribe adds window
"online"/"offline" listeners (and removes them on cleanup), getSnapshot reads
navigator.onLine, getServerSnapshot returns true. Never read navigator during
render — that breaks SSR and tears hydration; assuming "online" on the server
is the cheaper wrong guess (assuming offline flashes an error bar on every
first paint).
- Resolved state = status ?? successOverride ?? (browserOnline ? "online" :
onRetry ? "reconnecting" : "offline"). So: passing status hands control to
your own transport (WebSocket / SSE / polling); providing onRetry turns a
plain offline state into a retry loop; a real online/offline event always
clears the success override (the browser is the stronger signal).
- Retry loop (only while state === "reconnecting" and onRetry exists): a 1s
ticker counts secondsLeft down; at zero it awaits onRetry(). true =>
reconnected: uncontrolled sets its own state to online, controlled just stops
scheduling and waits for you to move status. false or a throw => attempt += 1,
next delay = min(baseDelay * 2 ** attempt, capDelay), countdown restarts.
The "Retry now" button fires the same attempt immediately, cancelling the
pending timer; a manual failure also advances the backoff, so mashing it
cannot beat the schedule into a tight loop. The button is disabled while an
attempt is in flight.
- Recovery: on any transition into "online" a hold window opens. With
showWhenOnline the bar stays visible for onlineDuration ms showing the online
label and then fades out; without it the bar leaves immediately but screen
readers still hear the recovery.
- Lifecycle: every setState happens in a timer / rAF / promise callback, never
in an effect body. One boolean guards the whole scheduler so a late resolve
after unmount touches nothing; timers, rAF handles and listeners are all
cleared on cleanup. State transitions (open the hold window, reset the
backoff plan) are done during render via prev-value comparison, not effects.
- Presence: the bar stays mounted for one transition duration after it should
disappear so the exit animation can play, then unmounts.
Rendering & styling
- Semantic tokens only. Offline and reconnecting share
border-destructive/30 + bg-[color-mix(in oklab,var(--destructive) 12%,var(--card))]
+ text-destructive; online uses the primary equivalent at 10% with
text-foreground and a text-primary icon. The mix lands on --card rather than
transparent so a fixed bar stays readable over scrolling content.
- position "top"/"bottom" = fixed inset-x-0 with z-50 and a border on the
content side; "inline" = relative, rounded, fully bordered. A fixed bar
overlays content instead of reserving space — pad your layout shell (or use
"inline" inside a sticky header) when the first row must stay readable, and
raise z-50 only if your own overlays sit above it.
- Enter/exit is opacity + translate. In Tailwind v4 -translate-y-full compiles
to the `translate` property, NOT `transform`, so the transition list must be
transition-[opacity,translate] — transition-transform silently animates
nothing. Mount hidden (-translate-y-full / translate-y-full / -translate-y-2
per position) and flip to translate-y-0 opacity-100 after a double
requestAnimationFrame, so the browser has a painted first frame to
interpolate from. While hidden the bar is pointer-events-none, otherwise a
transparent fixed bar keeps eating clicks. motion-reduce:transition-none
removes the movement but every state change still lands, and the spinner
carries motion-reduce:animate-none.
- Accessibility: a permanently mounted sr-only span with role="status"
aria-live="polite" aria-atomic="true" carries one stable sentence per state
("You're offline" / "Reconnecting…" / "Back online"), never the countdown —
announcing digits every second floods a screen reader. The visible copy is
aria-hidden so it is not read twice; the retry button stays a real focusable
button with a focus-visible ring. Keeping the live region mounted (instead of
putting role="status" on the bar itself) matters because a region inserted in
the same frame as its text is not reliably announced.
Customization levers
- Copy and i18n: pass labels, e.g.
labels={{ offline: "Keine Verbindung", retryingIn: s => `Neuer Versuch in ${s}s`,
retryNow: "Jetzt wiederholen" }} — retryingIn is a function so the number stays inside
your sentence.
- Retry rhythm: retryDelay sets the first wait, maxRetryDelay the ceiling
(2000 / 30000 feels responsive; 8000 / 300000 is polite to a struggling
backend). Want linear instead of exponential backoff? Replace
min(base * 2 ** attempt, cap) with min(base * (attempt + 1), cap), or add
jitter (delay * (0.8 + Math.random() * 0.4)) to avoid a thundering herd when
every client reconnects at once.
- Real reachability: navigator.onLine only means "an interface is up". Pass
status from your own health check or socket instead —
status={socketState} onRetry={() => socket.reconnect()} — and the browser
events stop deciding anything.
- Placement: position="inline" drops the bar into a header, a card or a
sidebar; add className="rounded-none border-x-0" for a full-bleed strip, or
className="max-w-md mx-auto rounded-full" for a floating pill (pair with
position="bottom" plus bottom-4 for a toast-like capsule).
- Density and tone: px-4 py-2 text-sm is the only sizing — pass py-1 text-xs
for a hairline strip. Swap the tone map to bg-muted/text-foreground for a
quieter product, or add a fourth state by extending ConnectionState, the tone
map and the icon lookup together.
- Drop the manual escape hatch by omitting the retry button when you want a
purely automatic loop; drop the countdown by always rendering the
reconnecting label.Concepts
- SSR-safe subscription —
useSyncExternalStoreturns theonline/offlineevents into a render-safe value:getServerSnapshotreturnstrue, so the server HTML and the hydration frame agree andnavigatoris never touched during render. - Exponential backoff with an escape hatch — each failed attempt doubles the wait up to a cap, so a dead backend is not hammered; the countdown makes the wait legible and Retry now lets an impatient user skip it (that manual attempt still advances the backoff).
- Controlled transport status —
statusoverrides the browser signal, becausenavigator.onLineonly proves an interface is up, not that your WebSocket is alive; pass your socket's state and the same bar reports it. - Announcement throttling by construction — the polite live region is mounted permanently and only ever holds one stable sentence per state; the ticking seconds live in an
aria-hiddennode, so a screen reader hears "You're offline" once instead of a new number every second. - Presence vs entered — visibility is two flags: the bar stays in the DOM for one transition after it should leave (so the exit plays), and only flips to its resting position after a double
requestAnimationFrame(so the enter has a painted first frame to interpolate from). - Translate is a property, not a transform — Tailwind v4 compiles
-translate-y-fullto CSStranslate, so the transition list namesopacityandtranslate; underprefers-reduced-motionthe movement is dropped while every state change still lands.
Permission Prompt
A pre-permission explainer card that says why a capability is needed before the browser's one-shot dialog is spent, with per-permission recovery steps once it's blocked.
Inline Error Summary
A top-of-form error summary where every row scrolls to and focuses the field it names — persistent live region, self-focusing panel, and an onNavigate hook for fields inside collapsed sections.