Skip to content
← components

Network Status

new

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.

Online
You're back online

demo mode · the toast slides in bottom-right on each change

Usage

example.tsx
import { NetworkStatus } from "~/components/network/network-status";
<NetworkStatus />

Or just the useNetworkStatus hook for the boolean:

use-network-status.tsx
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.

network.ts
// Current value, synchronously:
navigator.onLine; // boolean
// React to changes:
window.addEventListener("online", () => setOnline(true));
window.addEventListener("offline", () => setOnline(false));

Props

proptypedefaultdescription
demobooleanfalseDrive the state from a button instead of the real connection, so the toast is visible without disconnecting.
classNamestringExtra classes on the wrapper.

Inspired by Bilal Hussain.

The 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, …).

components/network/network-status.tsx
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 && (
<button
type="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>
);
}
hooks/use-network-status.ts
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 };
}
app/styles/app.css
/* ── 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; }
}