Feedback

Connection Status

An online / offline / reconnecting bar with an exponential-backoff retry countdown, a manual retry button and SSR-safe browser subscription.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { RefreshCw, Wifi, WifiOff } from "lucide-react"
import { cn } from "@/lib/utils"

export type ConnectionState = "online" | "offline" | "reconnecting"

export interface ConnectionStatusLabels {
  /** Shown after the connection comes back (only rendered when showWhenOnline). */
  online: string
  /** Shown when there is no connection and no retry loop is running. */
  offline: string
  /** Shown while the bar counts down to the next automatic attempt. */

Installation

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

Prompt

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 subscriptionuseSyncExternalStore turns the online/offline events into a render-safe value: getServerSnapshot returns true, so the server HTML and the hydration frame agree and navigator is 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 statusstatus overrides the browser signal, because navigator.onLine only 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-hidden node, 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-full to CSS translate, so the transition list names opacity and translate; under prefers-reduced-motion the movement is dropped while every state change still lands.

On This Page