Feedback

Offline Indicator

An offline bar for apps that queue work — it names the changes waiting, proves reconnection with an injected probe instead of navigator.onLine, and replays the queue with a per-action verdict instead of declaring success.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  CircleAlert,
  CircleCheck,
  CircleDashed,
  CloudOff,
  CloudUpload,
  LoaderCircle,
  RefreshCw,
  ShieldAlert,
  type LucideIcon,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/offline-indicator.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "OfflineIndicator" component: the bar an
app shows when it is offline with unsent work. lucide-react for icons; no data
library, no popover library, and no network code of its own — connectivity and
replay both arrive as injected functions.

Contract
- forwardRef<HTMLDivElement> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "children">; className is merged
  with cn() and the rest spreads on the bar. The ref is null whenever the bar
  has nothing to say, because it unmounts rather than hiding.
- export type OfflineActionState = "queued" | "syncing" | "synced" | "failed".
- export interface OfflineAction { id: string; label: string; detail?: string }
  — id is both the React key and the key of the outcome map, label is a human
  sentence ("Rename \"Q3 report\""), detail is the request it will send.
- export interface OfflineDrainResult { synced: string[]; failed: string[] }.
- Props: actions?: OfflineAction[] (your queue; default []),
  online?: boolean (controlled link signal),
  probe?: () => Promise<boolean> (reachability proof),
  onReplay?: (action: OfflineAction) => Promise<boolean | void>,
  onDrained?: (result: OfflineDrainResult) => void,
  offlineSince?: number (epoch ms), probeInterval = 15000,
  successDuration = 4000, maxVisible = 4,
  position: "top" | "bottom" | "inline" = "top",
  labels?: Partial<OfflineIndicatorLabels>.
- Clamp every number before use: probeInterval to >= 3000 (a 0 would busy-poll
  the endpoint), successDuration to >= 0, maxVisible to >= 1.
- The queue belongs to the consumer. The component never adds, removes or
  reorders an action; it keeps a private map of outcomes keyed by id and hands
  the ids back through onDrained so the caller decides what to drop. Outcomes
  for ids that leave `actions` are pruned, so "1 refused" can never linger over
  an empty queue.
- labels is the i18n lever; every label that carries a number is a function
  (waiting(count), syncing(count), partial(synced, failed), offlineFor(minutes),
  retryFailed(count), more(count)) so the digits stay inside your sentence.

Behavior
- Two signals, not one. `linkUp = online ?? navigator.onLine` only means an
  interface is up. navigator.onLine is trustworthy when it says false and is
  merely a claim when it says true — a captive portal, a hotel Wi-Fi splash
  page or a half-connected VPN all report "online". So a probe verdict is kept
  next to it: "up" | "down" | null (null = unproven). connected = linkUp &&
  (no probe || verdict === "up").
- Any change of linkUp invalidates the verdict (null): a new link has to prove
  itself again. Going down also clears the last run's report, so a stale
  "3 saved" never rides on an offline bar.
- The verdict starts optimistic ("up") so a healthy page load fires zero
  requests, EXCEPT when actions are already queued at mount — then it starts
  null, because there is work at stake and a portal may already be in the way.
- Probe scheduling: while probe exists && linkUp && verdict !== "up", run one
  attempt immediately and then every probeInterval. Nothing is probed while
  linkUp is false — that request is guaranteed to fail, and the answer is
  already known. A "Check now" button runs the same attempt on demand.
- One attempt at a time: a ref is read AND written synchronously at the top of
  the handler, so a double click cannot open a second probe. An attempt that
  resolves faster than 450ms is held to that minimum beat, otherwise a
  synchronous failure just flickers the button and reads as "nothing happened".
  A throw is a failed attempt, identical to resolving false.
- Replay (the drain) runs only when connected && onReplay exists, and only over
  actions this session has never attempted. Sequential, in queue order, because
  a mutation queue is ordered — "rename then move" replayed concurrently can
  land backwards. Each action goes queued -> syncing -> synced | failed;
  resolving false or throwing is a refusal, resolving anything else (including
  undefined) means it landed.
- Failed actions are NOT retried automatically. They wait for the explicit
  "Retry N failed" button or the next reconnection, otherwise one permanently
  invalid request replays forever the moment the link returns.
- Without onReplay the component never drains and never reports: it shows the
  queue while offline and then steps aside. Declaring a success it did not
  perform is the one thing this component exists to avoid.
- The outcome is a verdict per action, not a mood: "3 saved · 1 couldn't be
  saved" stays on screen with a Retry, while an all-clear run shows one green
  summary and dismisses itself after successDuration.
- Interruption: if connectivity drops mid-drain, an abort flag stops the loop
  before the next request and the in-flight row is rolled back to "queued" —
  its request may still have landed on the server, so replays must carry an
  idempotency key. Anything already confirmed is still reported through
  onDrained, or the caller would resend work that is already durable.
- The list shows up to maxVisible rows sorted failed -> syncing -> queued ->
  synced, so a refusal can never hide behind the cap; the remainder is counted
  on an explicit "+N more" line instead of being silently truncated.
- Elapsed time ("offline for 12 min") is derived from the injected offlineSince
  instant, never from a clock read during render — that would disagree between
  SSR and hydration. The elapsed value starts as null (server renders nothing)
  and is filled in by a 30s interval after mount; minute granularity does not
  deserve a per-second re-render.

Rendering & styling
- Semantic tokens only: offline / blocked / partial share border-destructive/30
  + bg-[color-mix(in oklab,var(--destructive) 12%,var(--card))] + text-
  destructive; checking / syncing are border-border + bg-card; the all-clear
  uses the primary equivalent at 10% with a text-primary icon. The mix lands on
  --card, not transparent, so a fixed bar stays readable over scrolling
  content. Rows use text-muted-foreground, text-primary and text-destructive.
- 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.
- Presence is two flags: the bar stays mounted 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). It also remembers the last visible phase, or it would fade
  out as an empty shell. In Tailwind v4 -translate-y-full compiles to the
  `translate` property, so the transition list must name opacity and translate.
- Motion is decoration: spinners carry motion-reduce:animate-none and every
  transition motion-reduce:transition-none. With motion off the bar simply
  appears and every state change still lands.
- ARIA: a permanently mounted sr-only span with role="status" aria-live=
  "polite" aria-atomic="true" carries exactly one stable sentence per phase —
  never the per-item progress and never the ticking elapsed minutes, which
  would flood a screen reader. The visible headline block is aria-hidden so it
  is not read twice; the buttons are NOT inside that block, because aria-hidden
  around a focusable element is an ARIA error. The row list is a real <ul> with
  an aria-label ("4 changes waiting"), and the drain progress is a
  role="progressbar" with aria-valuemin/valuenow/valuemax.
- Every row states its outcome with an icon shape AND a word ("Waiting",
  "Sending…", "Saved", "Couldn't save"), never colour alone.
- Keyboard: the bar has no roving grid — the whole surface is at most two
  buttons ("Check now" / "Retry N failed"), so Tab reaches them and Enter or
  Space activates them natively. What matters is the exits: the root carries
  tabIndex={-1} so it can receive focus programmatically without joining the
  tab order.
- Never the native disabled attribute: both buttons can go inert while the user
  is standing on them, and the browser would blur them to <body>. They use
  aria-disabled plus an early return in the handler.
- Focus successor: the buttons unmount when the phase moves under the user
  (Check now dies when the link is proven, Retry dies the moment the drain
  starts). Remember the last element focused inside the bar; after any commit,
  if that element is no longer isConnected, move focus to the bar root. The
  test is "the node we were standing on left the document", not "activeElement
  is body", so clicking elsewhere on the page is never stolen back.
- The success auto-dismiss does not start while focus is inside the bar; the
  timer is armed by the focusout instead. Nothing removes itself from under a
  keyboard user.
- Cleanup: the probe loop, the minimum-beat timeout, the elapsed interval, the
  hold timer, the enter rAF pair and the exit timeout are all cancelled on
  unmount and on dependency change; an alive flag (re-armed on mount, so
  StrictMode's double invoke is survivable) makes every late resolve after
  unmount a no-op.

Customization levers
- Reachability: probe is the whole point — point it at a cheap authenticated
  endpoint (`fetch("/api/ping", {cache: "no-store"}).then(r => r.ok)`), or at
  your socket (`() => socket.ping()`). Add a timeout by racing it, since a
  captive portal often hangs instead of refusing. Drop probe entirely and the
  component trusts navigator.onLine, which is the weaker guarantee.
- Transport: pass `online` from your own connection state and the browser
  events stop deciding anything; leave it out for the plain browser behaviour.
- Replay policy: swap the sequential loop for Promise.all when your writes are
  independent (and say so in the copy), add a per-action attempt counter with
  backoff, or return false from onReplay for anything the server refuses with
  4xx so it stays visible rather than retrying forever.
- Density: px-4 py-2.5 with a text-sm headline and text-xs rows is the only
  sizing — drop the detail column (already hidden below sm) or the whole row
  list for a one-line strip, or raise maxVisible for a support-facing build.
- 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
  max-w-md mx-auto rounded-full with position="bottom" for a floating capsule.
- Tone: swap the destructive mix for bg-muted/text-foreground in a calmer
  product, or recolour the progress fill to var(--chart-2) to match a dashboard.
  Keep the icon-plus-word status so the meaning survives without colour.
- Copy: labels covers every string, including the elapsed line
  (offlineFor: m => `已断开 ${m} 分钟`) and the overflow counter.

Concepts

  • Link up is not reachablenavigator.onLine is believed when it says false and treated as an unproven claim when it says true; only the injected probe() promotes "an interface exists" to "our server answered", which is the difference between being online and being stuck behind a hotel Wi-Fi splash page.
  • A verdict per action, not a mood — the drain reports each queued write separately, so a run that saves three edits and gets one rejected says exactly that instead of collapsing into a green "Synced"; refusals sort to the top of the list so the maxVisible cap can never hide them.
  • The queue stays yours — the component reads actions and never mutates it; outcomes live in a private map keyed by id and come back through onDrained, so you decide what is durable enough to delete, and outcomes for ids you removed are pruned automatically.
  • At-least-once, therefore idempotent — a link that dies mid-request leaves that action's fate unknown, so the row rolls back to queued and will be sent again; the replay must carry an idempotency key, and everything already confirmed is still reported so it is not resent.
  • Focus successor over disappearing controlsCheck now and Retry both unmount when the phase moves, so the bar remembers the node you were standing on and, when that node leaves the document, hands focus to its own root (tabIndex={-1}); the success auto-dismiss additionally refuses to start while focus is still inside.
  • Injected instant, not a clock read — "offline for 12 min" is measured from the offlineSince prop and filled in only after mount, because a Date.now() during render makes the server and the hydration frame print two different numbers.

On This Page