Feedback
Connection
Says when the app is offline, when it is back, and how many changes are waiting to sync.
- Built on
navigator.onLineonline and offline eventsrole="status"@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.7 kB
Code
"use client";
import { useEffect, useState } from "react";
import { Connection } from "@/components/equip/connection";
export default function Demo() {
const [offline, setOffline] = useState(true);
const [pending, setPending] = useState(3);
useEffect(() => {
if (offline || pending === 0) return;
const timer = window.setTimeout(() => setPending(0), 1500);
return () => window.clearTimeout(timer);
}, [offline, pending]);
return (
<div style={{ display: "grid", gap: 12, justifyItems: "center" }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, font: "var(--eq-text-s) / 1 var(--eq-font)", color: "var(--eq-ink)" }}>
<input
type="checkbox"
role="switch"
checked={offline}
onChange={(event) => {
setOffline(event.target.checked);
if (event.target.checked) setPending(3);
}}
/>
Simulate offline
</label>
<div style={{ height: 30 }}>
<Connection variant="pill" online={!offline} pending={pending} syncing={!offline && pending > 0} showSaved />
</div>
</div>
);
}Examples
Bar across an editor
The bar sits on the top edge of its nearest positioned container and slides in over the content without moving it.
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { Connection } from "@/components/equip/connection";
export default function Example() {
const [online, setOnline] = useState(true);
const [pending, setPending] = useState(0);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
if (!online || pending === 0) return;
setSyncing(true);
const timer = window.setTimeout(() => {
setSyncing(false);
setPending(0);
}, 1200);
return () => window.clearTimeout(timer);
}, [online, pending]);
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 440 }}>
<div
style={{
position: "relative",
height: 150,
padding: "48px 16px 16px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
overflow: "hidden",
font: "var(--eq-text-m) / 1.5 var(--eq-font)",
}}
>
<Connection online={online} pending={pending} syncing={syncing} showSaved />
<strong>Q4 hiring plan</strong>
<p style={{ margin: "4px 0 0", color: "var(--eq-ink-2)" }}>Two backend roles in Lisbon, one designer remote.</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Button variant="outline" size="s" onClick={() => setOnline((v) => !v)}>
{online ? "Go offline" : "Reconnect"}
</Button>
<Button variant="outline" size="s" disabled={online} onClick={() => setPending((n) => n + 1)}>
Edit while offline
</Button>
</div>
</div>
);
}Changes that did not sync
Online with changes still queued means a sync failed; onRetry adds a Try again button so people are not left wondering.
Code
"use client";
import { useState } from "react";
import { Connection } from "@/components/equip/connection";
export default function Example() {
const [pending, setPending] = useState(2);
const [syncing, setSyncing] = useState(false);
function retry() {
setSyncing(true);
window.setTimeout(() => {
setSyncing(false);
setPending(0);
}, 1200);
}
return <Connection variant="pill" online pending={pending} syncing={syncing} onRetry={retry} showSaved />;
}Following the browser
Without online it reads navigator.onLine and listens for the online and offline events. Turn off your network to try it.
Nothing shows while you are online.
Code
import { Connection } from "@/components/equip/connection";
export default function Example() {
return (
<div style={{ display: "grid", gap: 8, font: "var(--eq-text-s) / 1.4 var(--eq-font)", color: "var(--eq-ink-2)" }}>
<span>Nothing shows while you are online.</span>
<Connection variant="pill" />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
online | boolean | none | Controlled connectivity. Leave it out to follow navigator.onLine and the online and offline events. |
pending | number | 0 | Changes queued on this device and not yet synced. |
syncing | boolean | false | True while queued changes are being sent. |
onRetry | () => void | none | Adds a Try again button while offline or while changes are waiting online. |
variant | "bar" | "pill" | "bar" | A strip along the top edge of the nearest positioned container, or an inline pill. |
showSaved | boolean | false | Shows a quiet Saved for 2 seconds after a sync finishes with nothing left. |
...rest | ComponentProps<"div"> | none | Native attributes for the status root. |
Anatomy
| root | .eq-connection | The polite status region, present from the first client render. |
|---|---|---|
| message | .eq-connection-message | data-state is offline, syncing, saved, back or waiting. |
| icon | .eq-connection-icon | |
| tick | .eq-connection-tick | Draws in for Back online and Saved. |
| text | .eq-connection-text | |
| retry | .eq-connection-retry |
Keyboard
| EnterSpace | Activates Try again when it is shown. |
|---|
Notes
- Every state is said in words with its own icon, never by colour alone.
- Online with nothing queued shows nothing; Back online stays for 3 seconds after a reconnect, then slides away.
- Priority runs offline, syncing, saved, back online, then changes waiting, so only one message shows at a time.
- The server render is empty. The status root appears on the first client render and keeps its place, so later changes are announced politely.
- If the app is already offline when it loads, that first message appears together with the status root, which some screen readers do not announce.
- The bar variant is absolutely positioned: give its container position: relative, and leave room or accept that it covers the top of the content.
- navigator.onLine only knows whether there is a network, not whether your server answers; pass online yourself when you know better.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/connection.tsx187 lines
"use client";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { Button } from "./button";
import { Spinner } from "./spinner";
import "./connection.css";
export type ConnectionProps = ComponentProps<"div"> & {
/** Controlled connectivity. Leave it out to follow navigator.onLine and the online and offline events. */
online?: boolean;
/** Changes queued on this device and not yet synced. */
pending?: number;
/** True while queued changes are being sent. */
syncing?: boolean;
/** Shows a Try again button while offline or while changes are waiting. */
onRetry?: () => void;
/** A strip along the top edge of the positioned container, or an inline pill. */
variant?: "bar" | "pill";
/** Shows a quiet "Saved" for 2 seconds after a sync finishes. */
showSaved?: boolean;
};
type State = "offline" | "syncing" | "saved" | "back" | "waiting";
const BACK_MS = 3000;
const SAVED_MS = 2000;
const changes = (n: number) => `${n} ${n === 1 ? "change" : "changes"}`;
function words(state: State, pending: number, pill: boolean) {
switch (state) {
case "offline":
if (pill) return pending > 0 ? `Offline, ${changes(pending)} waiting` : "Offline";
return pending > 0
? `You are offline. ${changes(pending)} will sync when you reconnect.`
: "You are offline. Changes you make will sync when you reconnect.";
case "syncing":
return pending > 0 ? `Syncing ${changes(pending)}` : "Syncing changes";
case "saved":
return "Saved";
case "back":
return "Back online";
case "waiting":
return `${changes(pending)} not synced yet`;
}
}
function Icon({ state }: { state: State }) {
if (state === "syncing") return <Spinner size="s" label="" role="none" aria-hidden="true" />;
return (
<svg
key={state}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{state === "offline" && (
<>
<path d="M4.5 12.5h7a3 3 0 0 0 .6-5.94A4.25 4.25 0 0 0 5 5.2" />
<path d="M3.2 7.4A2.6 2.6 0 0 0 4.5 12.5" />
<path d="m2.5 2.5 11 11" />
</>
)}
{(state === "back" || state === "saved") && <path className="eq-connection-tick" pathLength="1" d="m3.5 8.5 3 3 6-7" />}
{state === "waiting" && (
<>
<circle cx="8" cy="8" r="5.75" />
<path d="M8 5v3.25M8 10.75h.01" />
</>
)}
</svg>
);
}
export function Connection({
online,
pending = 0,
syncing = false,
onRetry,
variant = "bar",
showSaved = false,
className,
...rest
}: ConnectionProps) {
const controlled = online !== undefined;
const [mounted, setMounted] = useState(false);
const [browserOnline, setBrowserOnline] = useState(true);
const [back, setBack] = useState(false);
const [saved, setSaved] = useState(false);
const prevOnline = useRef<boolean | null>(null);
const prevSyncing = useRef(syncing);
const last = useRef<{ state: State; text: string } | null>(null);
const isOnline = online ?? browserOnline;
useEffect(() => {
setMounted(true);
if (controlled) return;
const update = () => setBrowserOnline(navigator.onLine);
update();
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, [controlled]);
useEffect(() => {
if (!mounted) return;
const prev = prevOnline.current;
prevOnline.current = isOnline;
if (!isOnline) {
setBack(false);
setSaved(false);
return;
}
if (prev === false) setBack(true);
}, [isOnline, mounted]);
useEffect(() => {
if (!back) return;
const timer = window.setTimeout(() => setBack(false), BACK_MS);
return () => window.clearTimeout(timer);
}, [back]);
useEffect(() => {
const was = prevSyncing.current;
prevSyncing.current = syncing;
if (showSaved && was && !syncing && pending === 0 && isOnline) setSaved(true);
}, [syncing, pending, isOnline, showSaved]);
useEffect(() => {
if (!saved) return;
const timer = window.setTimeout(() => setSaved(false), SAVED_MS);
return () => window.clearTimeout(timer);
}, [saved]);
if (!mounted) return null;
const state: State | null = !isOnline
? "offline"
: syncing
? "syncing"
: saved
? "saved"
: back
? "back"
: pending > 0
? "waiting"
: null;
const view = state ? { state, text: words(state, pending, variant === "pill") } : null;
if (view) last.current = view;
const shown = view ?? last.current;
const retry = onRetry && (state === "offline" || state === "waiting");
return (
<div
{...rest}
role="status"
className={["eq-connection", className].filter(Boolean).join(" ")}
data-variant={variant}
>
{shown && (
<div className="eq-connection-message" data-state={shown.state} data-open={view ? "" : undefined}>
<span className="eq-connection-icon">
<Icon state={shown.state} />
</span>
<span className="eq-connection-text">{shown.text}</span>
{retry && (
<Button
size="s"
variant={variant === "pill" ? "ghost" : "outline"}
className="eq-connection-retry"
onClick={onRetry}
>
Try again
</Button>
)}
</div>
)}
</div>
);
}components/equip/connection.css130 lines
@layer equip {
.eq-connection {
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-connection[data-variant="bar"] {
position: absolute;
inset: 0 0 auto;
z-index: 1;
overflow: clip;
pointer-events: none;
}
.eq-connection[data-variant="pill"] {
display: inline-flex;
}
.eq-connection-message {
--_from: 0 -4px;
display: none;
align-items: center;
gap: var(--eq-space-2);
opacity: 0;
translate: var(--_from);
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete;
}
.eq-connection-message[data-open] {
display: flex;
opacity: 1;
translate: 0 0;
scale: 1;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete;
}
@starting-style {
.eq-connection-message[data-open] {
opacity: 0;
translate: var(--_from);
}
}
.eq-connection[data-variant="bar"] .eq-connection-message {
--_from: 0 -100%;
min-height: var(--eq-h-m);
padding: var(--eq-space-1) var(--eq-space-3);
border-bottom: 1px solid var(--eq-line-strong);
background: var(--eq-raised);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
pointer-events: auto;
}
.eq-connection[data-variant="pill"] .eq-connection-message {
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
font-size: var(--eq-text-xs);
scale: 0.96;
}
.eq-connection[data-variant="pill"] .eq-connection-message[data-open] {
scale: 1;
}
@starting-style {
.eq-connection[data-variant="pill"] .eq-connection-message[data-open] {
scale: 0.96;
}
}
.eq-connection[data-variant="pill"] .eq-connection-message:has(.eq-connection-retry) {
padding-inline-end: 0;
}
.eq-connection-message[data-state="offline"] .eq-connection-text {
font-weight: var(--eq-weight-strong);
}
.eq-connection-message:is([data-state="saved"], [data-state="back"]) {
color: var(--eq-ink-2);
}
.eq-connection-icon {
flex: none;
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
}
.eq-connection-icon > svg {
width: 16px;
height: 16px;
}
.eq-connection-tick {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-connection-tick {
stroke-dashoffset: 1;
}
}
.eq-connection-text {
min-width: 0;
}
.eq-connection-retry {
flex: none;
margin-inline-start: auto;
}
}