Mobile

Offline Banner

A safe-area connectivity banner for phones that lists the changes waiting, sends them when the link returns, and can be swiped into a peek tab that keeps the count.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronDown,
  ChevronUp,
  CircleDashed,
  CloudOff,
  CloudUpload,
  RefreshCw,
  TriangleAlert,
  type LucideIcon,
} from "lucide-react"

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "OfflineBanner" component (lucide-react
icons, the local cn() helper; no network code, no clock, no gesture library).
It is phone chrome, not a narrow desktop bar: it is welded to a screen edge
inside the safe area, its action sits in the thumb arc, and it can be pushed
out of the way with one thumb without losing its state.

Contract
- forwardRef<HTMLDivElement> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "children">; className merges
  through cn() and the rest spreads on the root. The root always renders — an
  empty, pointer-transparent shell when there is nothing to say — so the ref and
  the live region survive between states.
- export type OfflineBannerPhase = "offline" | "pending" | "syncing" | "synced"
  | "failed"; OfflineBannerVariant = "strip" | "card" | "minimal";
  OfflineBannerPlacement = "bottom" | "top".
- export interface OfflineQueuedChange { id: string; label: string;
  detail?: string } — id is the React key and part of the batch signature, so
  it must be stable; label is a human sentence, detail the request it will send.
- Props: online (required, injected — the component never reads
  navigator.onLine); queued?: OfflineQueuedChange[] (defaults to one shared
  module-level empty array, never a fresh literal per render); onSync?: () => void | boolean |
  Promise<void | boolean>; autoSync = true; variant = "strip";
  placement = "bottom"; tuckable = true; tucked?/defaultTucked = false/
  onTuckedChange? (controlled and uncontrolled both supported);
  syncedDuration = 2400; label = "Connection status";
  labels?: Partial<OfflineBannerLabels> where every string carrying a number is
  a function ((count) => string), so digits stay inside the sentence for i18n.
- Resolving false or throwing from onSync is a refusal; anything else counts as
  sent. Rejections are swallowed on purpose — report them inside your handler.

Behavior
- Phase is derived, never stored: !online -> "offline"; sending -> "syncing";
  last verdict refused -> "failed"; last verdict sent -> "synced" (held for
  syncedDuration); queue non-empty -> "pending"; otherwise hidden. The link
  outranks everything, so a drop mid-flight says "you're offline" instead of
  still claiming to send.
- Auto-send is edge-triggered and batch-scoped: when online is true, the queue
  is non-empty and its id signature has not been attempted yet, call onSync
  once. A refusal does NOT re-arm it — one permanently broken write must never
  loop — but appending a new change does, and so does any change of `online`
  (which also clears the last verdict). A send in flight when the link drops
  returns no verdict at all, which is what lets the next reconnect try again.
- Sending is guarded by a ref read AND written synchronously in the handler, so
  a double tap and the auto-send colliding in one frame still send once. State
  set after the await is skipped if the component unmounted.
- Tuck gesture (the mobile part): a pointer drag on the panel toward its edge.
  Pointer Events only, never separate mouse/touch handlers. Below 4px it is
  still a tap; a drag pointing away from the edge is handed back for good;
  once claimed, setPointerCapture on the element that started it and the offset
  follows the finger 1:1, clamped to the panel's own height (measured at
  pointerdown). Release past 40% of that height, or faster than 0.45 px/ms,
  tucks it; anything else springs back. touch-action: none on the panel, and
  never preventDefault on a passive listener — the CSS does that job. The queue
  list is a no-drag zone with touch-action: pan-y so it scrolls instead.
- Equal non-gesture paths, always: a chevron button tucks it, Escape tucks it
  while focus is inside (scoped to the panel, never a window listener), and the
  peek tab — a 44px pill carrying the phase icon and the count — brings it back.
- Focus discipline: tucking makes the panel `inert`, which drops focus to
  <body>, so the peek tab takes it, and the chevron takes it back on restore.
  Both hand-offs only fire when the control being removed was the focused one
  and focus really fell to the document. A separate layout effect catches every
  other unmount (the send button leaving when the queue empties) and hands focus
  to the panel. No control is ever natively `disabled` — the busy send button
  reports aria-disabled and its handler refuses.
- The "sent" line steps back on its own, but the timer never starts while focus
  is inside the banner. A refusal never expires: it is waiting for a decision.
- Cleanup: the drag rAF, the exit timer, the entrance double-rAF and the hold
  timer are all cancelled on unmount and on dependency change.

Rendering & styling
- Semantic tokens only. Offline is the highest-priority thing it says, so it
  INVERTS (bg-foreground / text-background) rather than taking a colour; the
  neutral phases are border/bg-card/text-foreground; colour is spent only on a
  refusal (border-destructive/40, text-destructive, bg-destructive/10 for the
  icon, bg-destructive text-destructive-foreground for the retry button).
- Safe area: every inset is max(var(--safe-area-inset-<edge>,
  env(safe-area-inset-<edge>, 0px)), floor). The custom property lets a device
  frame stage a home indicator on a desktop browser; the floor keeps a gutter
  where the OS honestly reports 0. `strip` carries the inset itself so its
  background runs under the home indicator / notch; `card` and `minimal` float
  and put the inset on the root instead. Both add the left/right insets for a
  landscape notch.
- Variants are geometry, not colour: strip = full-bleed bar welded to the edge
  (rounded on the inner corners only); card = inset rounded-2xl with a shadow;
  minimal = a centred capsule with a count chip and no list at all.
- Type ladder: headline text-sm/600 truncating on one line, subline 11px,
  queue rows 13px with an 11px request line, counts tabular-nums. Cards are
  rounded-2xl, inner blocks rounded-lg, chips rounded.
- Root is pointer-events-none so the app keeps its taps; the panel and the tab
  switch pointer events back on for themselves. The root is absolutely
  positioned — give the shell `position: relative` and `overflow: hidden`, or
  override to `fixed` through className.
- Accessibility: the panel is role-free chrome with an aria-label, id and
  tabIndex -1; the chevron and the tab both carry aria-expanded + aria-controls
  pointing at it; the queue is a real <ul> with an accessible name; one
  permanently mounted sr-only role="status" aria-live="polite" region outside
  the panel carries one stable sentence per phase (never per-item progress,
  which would flood a screen reader while a queue drains).
- Motion is CSS only: transform transitions and the spinner both drop under
  motion-reduce, and every state stays legible as text without them.

Customization levers
- Geometry: variant (strip / card / minimal) and placement (bottom for thumb
  reach, top when your chrome is top-anchored). The floors inside inset() set
  how far the panel sits off the edge.
- Content: `labels` replaces any sentence (all count-carrying ones are
  functions); `detail` on a change is optional; drop the queue list entirely by
  choosing minimal, or raise/lower max-h-32 on the list to show more rows.
- Behaviour: autoSync off makes sending an explicit decision; syncedDuration
  tunes how long the success line stays; tuckable={false} welds it in place;
  pass tucked/onTuckedChange to let a shell remember the choice between screens.
- Thresholds: DRAG_START_PX (4), TUCK_RATIO (0.4), FLING_VELOCITY (0.45 px/ms)
  and TUCK_PERCENT (112) are the whole gesture feel; SETTLE_MS must stay in
  sync with the duration-300 class.
- Tone: swap the TONE record to change which phase inverts. Keep exactly one
  inverted phase — if everything shouts, nothing does.

Concepts

  • Safe-area edge chrome — every inset reads --safe-area-inset-* first, env() second and a floor third. That ordering is what lets a device frame stage a home indicator on a laptop while real hardware still wins, and it is why the strip variant tints the inset instead of stopping short of it in a stripe of app content.
  • Swipe-to-tuck, not dismiss — on a 390px screen persistent chrome sits on top of the composer, so the banner folds into a peek tab that keeps the count. Nothing is dismissed and no state is lost; only the pixels move. That is the difference from a toast, which leaves on its own and takes its message with it.
  • Gesture parity — the drag is never the only way in or out. The chevron, Escape and the tab drive the same two transitions, which is also what makes the component usable with a keyboard, with a screen reader, and on a desktop preview where nobody is going to swipe anything.
  • One attempt per batch — the auto-send is keyed on the queue's id signature, not on a boolean. A refusal leaves that signature marked as attempted, so a permanently broken write cannot loop; appending a change or losing and regaining the link both re-arm it honestly.
  • Verdict-free drop — if the link fails while a send is in flight, the run returns no verdict at all instead of guessing. Neither “sent” nor “failed” is true at that moment, and staying silent is what lets the next reconnect try the batch again.
  • Focus successor over <body>inert removes the tucked panel from the focus order, which drops focus to the document. The peek tab it collapsed into takes focus instead, and the chevron takes it back — each only when the control being removed was the one focused, so a user who has already moved on is never yanked back.

On This Page