Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.tsx
Live · interactive
Synced

The 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/sync-status.json

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

project-sync.tsx
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.
PropTypeDefaultDescription
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.
defaultOfflinebooleanfalseOpens with the Offline switch on, so the next simulated sync fails. Setting it turns the demo off.
autoplaybooleantrueWhile 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.
classNamestring—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.

sync-status.tsx
// 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.module.css
/* 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. */
goo.ts
/* * 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).
ghost-pointer.tsx
'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-autoplay.ts
'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.