{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "network-status",
  "title": "Network Status",
  "description": "An offline banner that is right about being offline: it tells someone the page has lost the network, and — the harder half — only tells them it is back once that has actually been verified. Reach for it wherever losing the connection loses work or misleads the reader: a long form, an editor or a checkout someone is mid-way through; a dashboard, wallboard or monitoring view whose numbers stop being true the moment the feed dies; a chat, inbox or collaborative document where silence reads as \"nobody is talking\"; a PWA, field app or point-of-sale used on a phone that drifts in and out of coverage; and any app that queues writes to flush on reconnect. Common asks it answers: \"offline banner\", \"offline detection react\", \"network status component\", \"useOnline hook\", \"useNetworkStatus\", \"detect offline react\", \"navigator.onLine react\", \"connection lost banner\", \"reconnecting indicator\", \"internet connection detector\", \"react-detect-offline alternative\", \"shadcn offline banner\", \"no internet message component\", \"online offline event react\", \"captive portal detection\", \"heartbeat ping component\". The reason to install one rather than write it is that the three-line version everyone writes is wrong, and wrong in the direction that matters. navigator.onLine does not report whether the internet works; it reports whether the machine has a network interface that is up. A laptop joined to a café or hotel access point whose portal has not been logged into reads online. So does one on Wi-Fi whose upstream has died, one behind a captive portal that answers for every server with its own login page, and one where DNS alone has stopped resolving. Every one of those is true while nothing whatsoever loads — so a banner built on that flag stays hidden through precisely the outages people complain about, and the window.addEventListener(\"online\") that clears it fires when an interface came up, not when anything can be reached. The false direction is the trustworthy one: the browser is not wrong about having no interface at all. So this component believes false immediately and treats true as a claim to be checked, by actually asking the network for something. What that costs is kept honest, because a component that invents a request every few seconds forever is one people rip out. Mount does not probe — the page in front of the user arrived over the very network in question, and its own load is the freshest evidence there is. The online event starts a probe instead of being believed, and only the probe's answer clears the banner. The offline event lands immediately, with nothing to wait for. While unreachable, probes back off exponentially with jitter, because everyone whose access point rebooted starts their backoff on the same tick and would otherwise arrive back together at the worst possible moment — and they stop entirely when the interface itself is down, since there is nothing to ask and the event will say when there is. A hidden tab probes nothing at all and restarts on visibilitychange, which is also what catches the laptop that slept and woke up on a different network. Steady polling while everything is fine is off by default and there when a wallboard needs it. The probe itself is two details a hand-rolled fetch misses. It refuses to follow redirects, which is what turns a captive portal's 302-to-its-own-login-page back into the failure it is rather than a perfectly good 200. And it counts any HTTP response as reachable, a 404 or a 502 included: the question is whether packets get to a server and back, and a 404 answers it as well as a 200 does — which is why the default /favicon.ico is safe on a site that does not have one, and why a version checking res.ok reports such a site as permanently offline. A deadline is enforced too, because a black-holed connection does not fail, it hangs. Official shadcn/ui has nothing here: no offline, online or network item, and navigator.onLine, the online/offline events and any form of reachability check appear nowhere in its sixty-three components. Within pulld it is the detector, not another notifier — toast is the right home for \"it worked / it failed\" messages your code decides to send, while this one works out, on its own, whether the network is actually there. useNetworkStatus() is exported for a bar of your own design, or for pausing polling, disabling a submit button and flushing a queue on reconnect, and it hands back a check() to call the moment one of your own requests fails — a far better signal than any poll. checkReachable() and nextProbeDelay() are exported too. The wording sits in an always-mounted polite live region, because a live region inserted together with its text is not reliably announced and the banner would be silent for exactly the people who cannot see it. Every colour is a shadcn token, so it follows light and dark, and the whole thing is one file.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/network-status.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, Wifi, WifiOff } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The path the reachability probe asks for by default.\n *\n * A favicon is the one file almost every site already serves, it is same-origin, and it is small\n * enough to ask for repeatedly. Whether it actually exists does not matter — see `checkReachable`\n * for why a 404 counts as reachable — so this stays a safe default even where the file is missing.\n */\nexport const DEFAULT_PROBE_URL = \"/favicon.ico\"\n\n/** A probe that has not answered within this many milliseconds is treated as a failure. */\nconst DEFAULT_TIMEOUT = 5000\n/** The first retry after a failed probe waits about this long. */\nconst DEFAULT_INITIAL_DELAY = 1000\n/** However many failures pile up, retries never space out further apart than this. */\nconst DEFAULT_MAX_DELAY = 30000\n\nexport interface ProbeDelayOptions {\n  /** Ceiling on the first retry, doubling from there. */\n  initialDelay?: number\n  /** Ceiling the doubling stops at. */\n  maxDelay?: number\n  /** Source of the jitter. Injectable so a test can pin the delay; defaults to `Math.random`. */\n  random?: () => number\n}\n\n/**\n * How long to wait before the next probe, after `attempt` consecutive failures.\n *\n * Doubling on its own is not enough, and the reason is specific to this component. Connections do\n * not fail one user at a time: an access point reboots, an upstream link flaps, a deploy takes an\n * API down — and every tab in every browser starts its backoff on the same tick, stays in lockstep\n * all the way up the curve, and arrives back together at the exact moment the server is least able\n * to absorb it. So half of each delay is fixed and half is drawn at random, which spreads that herd\n * across the window. Half rather than all of it, because full jitter occasionally draws a delay near\n * zero, and a retry that lands immediately is the thing the backoff exists to prevent.\n */\nexport function nextProbeDelay(attempt: number, options: ProbeDelayOptions = {}): number {\n  const {\n    initialDelay = DEFAULT_INITIAL_DELAY,\n    maxDelay = DEFAULT_MAX_DELAY,\n    random = Math.random,\n  } = options\n  const exponent = Math.max(0, Math.floor(attempt))\n  // The doubling overflows to Infinity long before any of this matters, and Math.min simply pins\n  // an overflowed ceiling to maxDelay, so a long outage needs no separate guard.\n  const ceiling = Math.min(maxDelay, initialDelay * 2 ** exponent)\n  return Math.round(ceiling / 2 + random() * (ceiling / 2))\n}\n\nexport interface ReachabilityOptions {\n  /** HTTP method for the probe. HEAD by default — the answer is in the round trip, not the body. */\n  method?: string\n  /** Milliseconds before an unanswered probe is abandoned. */\n  timeout?: number\n  /** Aborts the probe from outside, e.g. when the component unmounts. */\n  signal?: AbortSignal\n}\n\nfunction withCacheBuster(url: string): string {\n  return `${url}${url.includes(\"?\") ? \"&\" : \"?\"}_=${Date.now()}`\n}\n\n/**\n * Asks the network whether it can still carry a request, and resolves true when it can.\n *\n * What counts as reachable is deliberately wide: any HTTP response at all, a 404 or a 502 included.\n * The question is whether packets reach a server and come back, and a 404 answers it exactly as\n * well as a 200 — which is what makes a possibly-absent favicon a safe default. Only a failure\n * below HTTP means unreachable: DNS, TLS, a refused or black-holed connection, or the timeout.\n *\n * Two failure modes are handled here that `fetch(url).then(() => true)` is not:\n *\n * `redirect: \"error\"` is what catches a captive portal. A hotel, café or airport gateway answers\n * for somebody else's server with a redirect to its own login page, and a fetch that follows it\n * comes back with a perfectly good response — this is precisely the state `navigator.onLine` is\n * already reporting as online. Refusing to follow the redirect turns it back into the failure it\n * is. Portals that silently drop traffic instead are caught by the timeout.\n *\n * The timeout is the other one. A connection that black-holes packets does not fail, it hangs, and\n * a probe with no deadline hangs with it — leaving the page reporting whatever it last knew for as\n * long as the socket stays open.\n *\n * One thing it cannot see through: a service worker with a cache-first strategy answers the probe\n * itself, without touching the network, and reports the site as reachable from a plane. `cache:\n * \"no-store\"` governs the HTTP cache and not the worker. Point `url` at a path the worker does not\n * handle, or hand `useNetworkStatus` a `probe` of your own.\n */\nexport async function checkReachable(\n  url: string = DEFAULT_PROBE_URL,\n  options: ReachabilityOptions = {}\n): Promise<boolean> {\n  const { method = \"HEAD\", timeout = DEFAULT_TIMEOUT, signal } = options\n  if (signal?.aborted) return false\n\n  const controller = new AbortController()\n  const abort = () => controller.abort()\n  signal?.addEventListener(\"abort\", abort)\n  const timer = setTimeout(abort, timeout)\n\n  try {\n    await fetch(withCacheBuster(url), {\n      method,\n      cache: \"no-store\",\n      redirect: \"error\",\n      credentials: \"omit\",\n      signal: controller.signal,\n    })\n    return true\n  } catch {\n    return false\n  } finally {\n    clearTimeout(timer)\n    signal?.removeEventListener(\"abort\", abort)\n  }\n}\n\nexport interface NetworkStatusOptions extends Omit<ReachabilityOptions, \"signal\"> {\n  /** Same-origin path the probe asks for. An API health route is the better choice where you have one. */\n  url?: string\n  /** Ceiling on the first retry after a failure, doubling from there. */\n  initialDelay?: number\n  /** Ceiling the retry spacing stops growing at. */\n  maxDelay?: number\n  /**\n   * Milliseconds between probes while everything is fine. Off by default, and that default is the\n   * point: a component dropped into a page should not invent a request every few seconds forever to\n   * re-learn something no one has contradicted. The events below cover a connection that goes away,\n   * and `check()` covers the app's own failed request, which is a far better signal than a poll.\n   * Turn this on for a screen that must notice an upstream dying while it sits untouched — a wallboard,\n   * a trading view, a live dashboard.\n   */\n  pollInterval?: number\n  /** Replaces the built-in probe entirely, e.g. a GraphQL ping or a WebSocket liveness check. */\n  probe?: (signal: AbortSignal) => Promise<boolean>\n  /** Called on each transition, and only on transitions — good for flushing a queue on reconnect. */\n  onStatusChange?: (online: boolean) => void\n}\n\nexport interface NetworkStatusState {\n  /** The best current answer. Starts optimistic; see the note on the first render in `useNetworkStatus`. */\n  online: boolean\n  /** A probe is in flight. Distinct from being offline: the answer is not known yet. */\n  checking: boolean\n  /** Consecutive failed probes. Drives the retry spacing, and worth showing after a few. */\n  failedAttempts: number\n  /** Probes now, whatever the schedule says. Call it when one of your own requests has just failed. */\n  check: () => Promise<boolean>\n}\n\n/**\n * Whether the page can actually reach the network — which is a different question from the one\n * `navigator.onLine` answers.\n *\n * `navigator.onLine` reports whether the machine has a network interface that is up. That is all.\n * A laptop joined to a café access point whose portal has not been logged into is online by that\n * measure; so is one on a Wi-Fi network whose upstream has died, and one where DNS alone has\n * stopped resolving. Every one of those reads `true` while nothing whatsoever loads, which is why\n * a three-line offline banner built on it stays hidden through exactly the outages users complain\n * about. The `false` direction is trustworthy — the browser is not wrong about having no interface\n * at all — so this hook believes `false` immediately and treats `true` as a claim to be checked.\n *\n * Checking means a real request, and the traffic that costs is kept honest:\n *\n *   - **Mount does not probe.** The page in front of the user arrived over the very network in\n *     question, so its own load is the freshest evidence available, and re-establishing it on\n *     every page view would be a request for nothing.\n *   - **The `online` event probes rather than being believed.** It fires when an interface came up,\n *     not when anything can be reached — joining the portal-guarded Wi-Fi fires it, and so does a\n *     laptop waking onto a dead network. A banner that clears here tells the user they are back\n *     when they are not, so only the probe's answer clears it.\n *   - **The `offline` event lands immediately.** No probe: it is the trustworthy direction.\n *   - **While offline, probes back off with jitter** (see `nextProbeDelay`), and stop entirely when\n *     the interface itself is down, since there is nothing to ask and the event will say when there is.\n *   - **A hidden tab probes nothing.** No one is reading it, and background timers are throttled to a\n *     minute or more anyway, so the loop parks and `visibilitychange` restarts it — which also covers\n *     the machine that slept and woke up somewhere else.\n *\n * The first render is deliberately optimistic even where `navigator.onLine` is already `false`. The\n * server cannot know a client's connection, and a first client render that disagrees with the markup\n * is a hydration mismatch; the effect corrects it before paint is noticed.\n */\nexport function useNetworkStatus(options: NetworkStatusOptions = {}): NetworkStatusState {\n  const {\n    url = DEFAULT_PROBE_URL,\n    method = \"HEAD\",\n    timeout = DEFAULT_TIMEOUT,\n    initialDelay = DEFAULT_INITIAL_DELAY,\n    maxDelay = DEFAULT_MAX_DELAY,\n    pollInterval = 0,\n    probe,\n    onStatusChange,\n  } = options\n\n  const [state, setState] = React.useState({ online: true, checking: false, failedAttempts: 0 })\n\n  // The two function props are read through refs so that passing them inline — which every caller\n  // does — does not tear down the listeners and the retry schedule on every render.\n  const probeRef = React.useRef(probe)\n  const onStatusChangeRef = React.useRef(onStatusChange)\n  probeRef.current = probe\n  onStatusChangeRef.current = onStatusChange\n\n  // Mirrors state.online outside React, so the loop can compare against it without becoming a\n  // dependency of itself, and so `check()` has something to answer with before the effect has run.\n  const onlineRef = React.useRef(true)\n  const runRef = React.useRef<((force: boolean) => Promise<boolean>) | null>(null)\n\n  React.useEffect(() => {\n    let cancelled = false\n    let timer: ReturnType<typeof setTimeout> | null = null\n    let controller: AbortController | null = null\n    let inFlight: Promise<boolean> | null = null\n    let attempt = 0\n\n    function clearTimer() {\n      if (timer !== null) {\n        clearTimeout(timer)\n        timer = null\n      }\n    }\n\n    function publish(online: boolean, checking: boolean) {\n      if (cancelled) return\n      setState((prev) =>\n        prev.online === online && prev.checking === checking && prev.failedAttempts === attempt\n          ? prev\n          : { online, checking, failedAttempts: attempt }\n      )\n      if (online !== onlineRef.current) {\n        onlineRef.current = online\n        onStatusChangeRef.current?.(online)\n      }\n    }\n\n    const isVisible = () =>\n      typeof document === \"undefined\" || document.visibilityState !== \"hidden\"\n\n    /** The browser is certain there is no network at all. This is the direction it cannot be wrong in. */\n    const isInterfaceDown = () => typeof navigator !== \"undefined\" && navigator.onLine === false\n\n    function schedule(delay: number) {\n      clearTimer()\n      timer = setTimeout(() => {\n        timer = null\n        void run(false)\n      }, delay)\n    }\n\n    function run(force: boolean): Promise<boolean> {\n      if (cancelled) return Promise.resolve(onlineRef.current)\n      // A second caller during a probe waits on the same request rather than opening another. The\n      // `online` event and a returning tab often arrive within the same tick.\n      if (inFlight) return inFlight\n      clearTimer()\n\n      if (isInterfaceDown()) {\n        attempt = 0\n        publish(false, false)\n        // No retry is scheduled: probing a machine with no interface only wakes the radio to fail,\n        // and the `online` listener restarts the loop the moment there is something to ask.\n        return Promise.resolve(false)\n      }\n\n      if (!force && !isVisible()) {\n        publish(onlineRef.current, false)\n        return Promise.resolve(onlineRef.current)\n      }\n\n      publish(onlineRef.current, true)\n      controller = new AbortController()\n      const signal = controller.signal\n      const custom = probeRef.current\n\n      inFlight = (custom ? custom(signal) : checkReachable(url, { method, timeout, signal }))\n        .catch(() => false)\n        .then((reachable) => {\n          inFlight = null\n          if (cancelled) return reachable\n          if (reachable) {\n            attempt = 0\n            publish(true, false)\n            if (pollInterval > 0) schedule(pollInterval)\n          } else {\n            attempt += 1\n            publish(false, false)\n            schedule(nextProbeDelay(attempt - 1, { initialDelay, maxDelay }))\n          }\n          return reachable\n        })\n      return inFlight\n    }\n\n    runRef.current = run\n\n    function handleOnline() {\n      attempt = 0\n      void run(true)\n    }\n\n    function handleOffline() {\n      attempt = 0\n      clearTimer()\n      controller?.abort()\n      publish(false, false)\n    }\n\n    function handleVisibilityChange() {\n      if (!isVisible()) return\n      // Returning to a tab is when the last answer is most likely to be stale — the machine may have\n      // slept, moved network, or simply sat there with its backoff timer throttled. Nothing is asked\n      // while things are known to be fine and no poll is configured.\n      if (!onlineRef.current || pollInterval > 0) void run(false)\n    }\n\n    if (isInterfaceDown()) publish(false, false)\n    else if (pollInterval > 0) schedule(pollInterval)\n\n    window.addEventListener(\"online\", handleOnline)\n    window.addEventListener(\"offline\", handleOffline)\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange)\n\n    return () => {\n      cancelled = true\n      runRef.current = null\n      clearTimer()\n      controller?.abort()\n      window.removeEventListener(\"online\", handleOnline)\n      window.removeEventListener(\"offline\", handleOffline)\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange)\n    }\n  }, [url, method, timeout, initialDelay, maxDelay, pollInterval])\n\n  const check = React.useCallback(\n    () => runRef.current?.(true) ?? Promise.resolve(onlineRef.current),\n    []\n  )\n\n  return { ...state, check }\n}\n\nexport interface NetworkStatusProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onChange\">,\n    NetworkStatusOptions {\n  /** Fixed to an edge of the viewport, or laid out wherever you put it. */\n  position?: \"top\" | \"bottom\" | \"inline\"\n  /** Shown for as long as the connection is unreachable. */\n  offlineMessage?: React.ReactNode\n  /** Shown briefly once a probe succeeds again. */\n  restoredMessage?: React.ReactNode\n  /** How long the restored message stays. 0 leaves the bar silent on the way back. */\n  restoredDuration?: number\n  /** Offers a manual retry while offline, ahead of the next scheduled probe. */\n  showRetry?: boolean\n  /** Label of that button. */\n  retryLabel?: string\n}\n\n/**\n * The bar that tells someone the page has lost the network, and — this being the harder half —\n * only tells them it is back once that has been verified rather than merely announced.\n *\n * Every colour is a shadcn token, so it follows light and dark. For a bar of your own design, take\n * `useNetworkStatus` and leave this one out; the detection is all in the hook.\n *\n * The wording lives in a `role=\"status\"` region that stays mounted whether or not anything is\n * showing. A live region inserted into the page together with its text is not reliably announced,\n * so a bar that mounts on going offline is silent for exactly the people who cannot see it. It stays\n * `polite`, and not because losing a connection is unimportant: `aria-live` is read when the region\n * registers, so switching it to `assertive` on the offline transition would not take effect — and the\n * message stays on screen until it is resolved, so nothing is missed by waiting for a pause.\n *\n * The icon and the retry button sit outside that region on purpose. Inside it, the button's label\n * would be read out again on every transition, alongside the sentence that actually changed.\n */\nexport function NetworkStatus({\n  position = \"top\",\n  offlineMessage = \"You're offline. Trying to reconnect…\",\n  restoredMessage = \"Back online\",\n  restoredDuration = 3000,\n  showRetry = true,\n  retryLabel = \"Retry\",\n  url,\n  method,\n  timeout,\n  initialDelay,\n  maxDelay,\n  pollInterval,\n  probe,\n  onStatusChange,\n  className,\n  ...props\n}: NetworkStatusProps) {\n  const status = useNetworkStatus({\n    url,\n    method,\n    timeout,\n    initialDelay,\n    maxDelay,\n    pollInterval,\n    probe,\n    onStatusChange,\n  })\n\n  const [restored, setRestored] = React.useState(false)\n  // Whether there is anything to celebrate on the way back. A page that has been online since it\n  // loaded should not flash \"Back online\" at someone who never went anywhere.\n  const wasOffline = React.useRef(false)\n\n  React.useEffect(() => {\n    if (!status.online) {\n      wasOffline.current = true\n      setRestored(false)\n      return\n    }\n    if (!wasOffline.current) return\n    wasOffline.current = false\n    if (restoredDuration <= 0) return\n    setRestored(true)\n    const timer = setTimeout(() => setRestored(false), restoredDuration)\n    return () => clearTimeout(timer)\n  }, [status.online, restoredDuration])\n\n  const message = status.online ? (restored ? restoredMessage : null) : offlineMessage\n  const showing = message !== null\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none flex justify-center\",\n        position === \"top\" && \"fixed inset-x-0 top-0 z-50 p-3\",\n        position === \"bottom\" && \"fixed inset-x-0 bottom-0 z-50 p-3\",\n        className\n      )}\n      {...props}\n    >\n      <div\n        className={cn(\n          showing\n            ? cn(\n                \"pointer-events-auto flex max-w-full items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-sm\",\n                status.online\n                  ? \"border-border bg-background text-foreground\"\n                  : \"border-destructive/40 bg-destructive/10 text-foreground\"\n              )\n            : \"sr-only\"\n        )}\n      >\n        {showing ? (\n          status.online ? (\n            <Wifi className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n          ) : status.checking ? (\n            <Loader2 className=\"h-4 w-4 shrink-0 animate-spin text-destructive\" aria-hidden=\"true\" />\n          ) : (\n            <WifiOff className=\"h-4 w-4 shrink-0 text-destructive\" aria-hidden=\"true\" />\n          )\n        ) : null}\n        <span role=\"status\" aria-live=\"polite\" className=\"min-w-0\">\n          {message}\n        </span>\n        {/*\n          The retry is deliberately not disabled while a probe is in flight. A disabled button loses\n          focus to the document body, so a keyboard user who pressed Retry would be thrown back to\n          the top of the page by their own click; the spinner beside it already says a probe is\n          running, and a second press waits on the same request rather than opening another.\n        */}\n        {showing && !status.online && showRetry ? (\n          <button\n            type=\"button\"\n            onClick={() => void status.check()}\n            aria-busy={status.checking || undefined}\n            className=\"shrink-0 rounded-sm font-medium underline underline-offset-2 hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {retryLabel}\n          </button>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}