Sync status
A sync pill that turns red when a sync fails, while a round retry button buds off it like a drop of liquid and flows back in on retry.
sync-status/sync-status.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Saying whether local changes have reached the server: a notes app, a design tool, a shared document.
- Background work with three outcomes worth telling apart at a glance:
Syncing,Synced,Sync failed. - A failure the person can fix themselves by trying again: the retry button appears right where they are looking.
- A status that changes on its own while the person keeps working: every change is announced politely to screen readers.
When not to use
- Progress that can be measured, like an upload of 40 MB. Use a progress bar with a percentage.
- Errors that need more than a retry (a conflict, a full disk, a sign-in). Use a banner or a dialog that says what to do.
- One-off confirmations like “Saved”. A toast that leaves on its own is quieter.
- Dense toolbars where a 44 px pill and a moving button don’t fit. Use a small icon with a tooltip.
- Several things syncing at once. Show a status per item, or a list, not one pill for all.
You can add this component with the shadcn CLI or by hand:
Into components/sync-status of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/sync-status.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { SyncStatus } from './sync-status';async function syncProject() { const response = await fetch('/api/sync', { method: 'POST' }); if (!response.ok) throw new Error('Sync failed');}// Resolves: Synced. Rejects: Sync failed, with the retry button.export default function ProjectSync() { return <SyncStatus sync={syncProject} autoplay={false} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
“Syncing” settles on “Synced” with a check. A failure turns the pill red, and a round retry button, in ink, swells out of its end, stretches a gooey neck and snaps free. Retry, and the button flows back into the pill, which syncs again; switch the stage to Offline to make syncs fail.
- At rest it is plain HTML and CSS: the pill and the button side by side, centred, so it renders without JavaScript.
- Between states, one SVG layer draws the pill, the button and the goo for the length of the move. Closed-form springs drive it in a requestAnimationFrame loop that stops once they settle. The pill’s place and width come from the layouts before and after the change (FLIP), and the words ride along with a transform and a clip.
- The goo is the classic metaball bridge between the pill’s round end and the button (goo.ts). It spreads while they overlap and narrows to a thread as they part; pulling away, it stretches further before snapping, and coming back, it joins sooner.
- The button buds off white from inside the pill and turns to ink as it gets away, along a gradient neck. A lively spring makes it overshoot, so the thread snaps, then settles.
- Only the end of the label changes (“Sync|ing”, “Sync|ed”, “Sync| failed”), cross-fading with a little blur. The spinner, the check and the alert swap the same way.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. While the goo moves, the script reads the pill’s and the button’s resolved colours from the SVG layer each frame, so a switch mid-move follows on the next frame.
| Prop | Type | Default | Description |
|---|---|---|---|
sync | () => Promise<unknown> | — | Runs a sync: resolves when it is done, rejects or throws when it fails. Without it, the stage simulates a 1.6 s sync, which fails while its Offline switch is on (the switch is hidden when sync is given). |
defaultState | 'syncing' | 'synced' | 'failed' | 'synced' | Opens in this state, at rest; no sync runs for it, so 'syncing' spins until one starts. Setting it turns the demo off. |
defaultOffline | boolean | false | Opens with the Offline switch on, so the next simulated sync fails. Setting it turns the demo off. |
autoplay | boolean | true | While on screen and untouched, a demo pointer syncs, fails and retries on its own. |
colorScheme | 'light' | 'dark' | 'system' | — | Forces light or dark colours, or follows the reader’s system setting; without it the component takes the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';import { bridge } from './goo';import { GhostPointer, runDemo } from './ghost-pointer';import { useAutoplay } from './use-autoplay';import styles from './sync-status.module.css';/* * Sync status: a pill that says how the sync is going. "Syncing", with a spinner, settles on * "Synced" with a check; when a sync fails the pill turns red, "Sync failed", and a round black * retry button buds off its end and pulls away, like a drop of liquid splitting in two. Retry * and the button flows back into the pill, which syncs again. * * - At rest, everything is plain HTML and CSS: the pill and the button, side by side, centred. * - Between states, the shapes are drawn in one SVG layer for the length of the move: the pill, * the button and the goo between them (goo.ts), all driven by springs in a requestAnimationFrame * loop that stops when they settle. The pill's width and place come from the layouts before and * after the change (FLIP); the button leaves from inside the pill's round end, in the pill's * colour, and takes its own as it gets away. * - The words cross-fade with a touch of blur; only the end changes ("Sync|ing", "Sync|ed", * "Sync| failed")./* Sync status (sync-status.tsx). Sizes are in em: the stage sets the font-size from its own width (a container query), so the pill grows with the room it has. The group's state (syncing, synced, failed) is a data attribute; while the shapes move, the group also has data-goo, and the SVG layer draws them in place of the HTML backgrounds. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). */.root { /* The stage, and what is raised on it: the pill, Sync now and Offline. */ --surface: #efeff3; --surface-2: #fff; /* The words, the spinner and Sync now's arrow; Sync now while a sync runs. */ --ink: #1c1c1e; --ink-muted: #8e8e93; /* The spinner as it leaves: SVG's default black on light, as it always was; the ink on dark, where black would vanish into the pill. */ --spinner-out: #000; /* The stage's edge, and the hairlines round the pill and button, and round the controls. */ --line: rgb(0 0 0 / 0.05); --edge: rgb(0 0 0 / 0.14); --control-edge: rgb(0 0 0 / 0.08); /* The lift under the pill and button, under the controls, and under the switch's knob. *//* * The goo between two circles: the curved bridge that joins them while they are close, narrows * as they draw apart, and snaps. Drawn in the same colour as the shapes it joins, it makes them * read as one drop of liquid splitting in two, or two drops merging. * * The curve is the classic metaball construction: two points on each circle, where the bridge * leaves it, and cubic curves between them whose handles follow the circles' tangents. */export type Circle = { x: number; y: number; r: number };type Point = { x: number; y: number };const towards = (from: Point, angle: number, length: number): Point => ({ x: from.x + Math.cos(angle) * length, y: from.y + Math.sin(angle) * length,});const write = ({ x, y }: Point) => `${x.toFixed(2)} ${y.toFixed(2)}`;/** * The bridge from `a` to `b`, as an SVG path, or '' when there is none. It spreads widest while * the circles overlap, and narrows to nothing as the distance between their centres reaches * `reach` (px).'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */ press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- States are announced in a polite live region: “Syncing.”, “Synced.”, “Sync failed. Retry?”.
- Retry, Sync now and Offline (a switch, with aria-checked) are real buttons. While a sync runs, Sync now is aria-disabled but stays focusable.
- When the retry button leaves with the failure, focus moves to Sync now instead of being lost.
- Under reduced motion there is no goo or spring, and the spinner stands still.