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
Built from
ButtonSpinner
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.

Q4 hiring plan

Two backend roles in Lisbon, one designer remote.

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

PropTypeDefaultWhat it does
onlinebooleannoneControlled connectivity. Leave it out to follow navigator.onLine and the online and offline events.
pendingnumber0Changes queued on this device and not yet synced.
syncingbooleanfalseTrue while queued changes are being sent.
onRetry() => voidnoneAdds 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.
showSavedbooleanfalseShows a quiet Saved for 2 seconds after a sync finishes with nothing left.
...restComponentProps<"div">noneNative attributes for the status root.

Anatomy

root.eq-connectionThe polite status region, present from the first client render.
message.eq-connection-messagedata-state is offline, syncing, saved, back or waiting.
icon.eq-connection-icon
tick.eq-connection-tickDraws in for Back online and Saved.
text.eq-connection-text
retry.eq-connection-retry

Keyboard

EnterSpaceActivates 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index