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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/offline-indicator.jsonPrompt
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 reachable —
navigator.onLineis believed when it says false and treated as an unproven claim when it says true; only the injectedprobe()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
maxVisiblecap can never hide them. - The queue stays yours — the component reads
actionsand never mutates it; outcomes live in a private map keyed by id and come back throughonDrained, 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 controls — Check 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
offlineSinceprop and filled in only after mount, because aDate.now()during render makes the server and the hydration frame print two different numbers.
Undo Toast
An optimistic-delete toast: the row disappears at once, a countdown ring holds the destructive commit, and hover or focus pauses the window.
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.