Network Status
An online/offline indicator on navigator.onLine and the online/offline events: a live pill plus a toast that slides in on every change and auto-dismisses.
demo mode · the toast slides in bottom-right on each change
Usage
import { NetworkStatus } from "~/components/network/network-status";<NetworkStatus />
Or just the useNetworkStatus hook for the boolean:
import { useNetworkStatus } from "~/hooks/use-network-status";function Banner() {const { online } = useNetworkStatus();if (online) return null;return <div>You're offline. Changes will sync when you reconnect.</div>;}
One property, two events
navigator.onLine is a boolean you can read at any time, and the window fires online / offline events when it flips. Read once on mount, subscribe to both events, and you've got reactive connectivity in a few lines.
One honest caveat: onLine only means the device has a network interface that's up, not that the internet is actually reachable. A captive wifi portal or a dead router will still report online. So treat it as a fast hint for the obvious case (airplane mode, cable pulled) and fall back to a real request when you need certainty.
// Current value, synchronously:navigator.onLine; // boolean// React to changes:window.addEventListener("online", () => setOnline(true));window.addEventListener("offline", () => setOnline(false));
Props
| prop | type | default | description |
|---|---|---|---|
| demo | boolean | false | Drive the state from a button instead of the real connection, so the toast is visible without disconnecting. |
| className | string | — | Extra classes on the wrapper. |
Inspired by Bilal Hussain.
Source
download .zipThe full implementation, across 3 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useEffect, useRef, useState } from "react";import { Wifi, WifiOff } from "lucide-react";import { cn } from "~/lib/utils";import { useNetworkStatus } from "~/hooks/use-network-status";/*** Online/offline indicator on navigator.onLine + the online/offline events. A* pill shows the current state; a toast slides in whenever it changes and* auto-dismisses. Pass `demo` to drive it from a button instead of the real* connection (so the toast is visible without disconnecting). Inspired by Bilal* Hussain (@BilliCodes).*/export function NetworkStatus({demo = false,className,}: {demo?: boolean;className?: string;}) {const real = useNetworkStatus();const [demoOnline, setDemoOnline] = useState(true);const online = demo ? demoOnline : real.online;const [toast, setToast] = useState<{ online: boolean } | null>(null);const mounted = useRef(false);const timer = useRef<number | null>(null);useEffect(() => {// Skip the initial render — only toast on a real change.if (!mounted.current) {mounted.current = true;return;}setToast({ online });if (timer.current) clearTimeout(timer.current);timer.current = window.setTimeout(() => setToast(null), 3000);return () => {if (timer.current) clearTimeout(timer.current);};}, [online]);const toastOnline = toast?.online ?? online;return (<div className={cn("flex flex-col items-center gap-4", className)}><span className="net-pill" data-online={online}><span className="net-dot" />{online ? "Online" : "Offline"}</span>{demo && (<buttontype="button"onClick={() => setDemoOnline((o) => !o)}className="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-3.5 py-2 text-sm font-medium transition-colors hover:bg-[hsl(var(--muted))]">Simulate going {online ? "offline" : "online"}</button>)}<div className={cn("net-toast", toast && "show")} data-online={toastOnline} role="status">{toastOnline ? <Wifi className="size-4" /> : <WifiOff className="size-4" />}{toastOnline ? "You're back online" : "You're offline"}</div></div>);}
import { useEffect, useState } from "react";/*** Track online/offline status via navigator.onLine and the window online/offline* events. SSR-safe: starts optimistic (online) and corrects on mount.** const { online } = useNetworkStatus();*/export function useNetworkStatus() {const [online, setOnline] = useState(true);useEffect(() => {setOnline(navigator.onLine);const on = () => setOnline(true);const off = () => setOnline(false);window.addEventListener("online", on);window.addEventListener("offline", off);return () => {window.removeEventListener("online", on);window.removeEventListener("offline", off);};}, []);return { online };}
/* ── Network status (online / offline) ───────────────────────────────────── */.net-pill {display: inline-flex;align-items: center;gap: 9px;border: 1px solid hsl(var(--border));background: hsl(var(--card));border-radius: 999px;padding: 8px 18px;font-size: 14px;font-weight: 500;}.net-dot {position: relative;width: 9px;height: 9px;border-radius: 50%;flex: none;}.net-pill[data-online="true"] .net-dot {background: hsl(142 71% 45%);}.net-pill[data-online="false"] .net-dot {background: hsl(0 84% 60%);}.net-pill[data-online="true"] .net-dot::after {content: "";position: absolute;inset: 0;border-radius: 50%;background: hsl(142 71% 45%);animation: net-ping 1.7s ease-out infinite;}@keyframes net-ping {0% { transform: scale(1); opacity: 0.55; }100% { transform: scale(2.6); opacity: 0; }}.net-toast {position: fixed;right: 20px;bottom: 20px;z-index: 50;display: flex;align-items: center;gap: 10px;min-width: 210px;padding: 12px 16px;border-radius: 10px;color: #fff;font-size: 14px;font-weight: 500;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);transform: translateY(150%);opacity: 0;transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1), opacity 0.45s ease;pointer-events: none;}.net-toast.show {transform: translateY(0);opacity: 1;}.net-toast[data-online="true"] {background: hsl(142 64% 36%);}.net-toast[data-online="false"] {background: hsl(0 72% 48%);}@media (prefers-reduced-motion: reduce) {.net-toast { transition: opacity 0.25s ease; }.net-dot::after { animation: none; }}