# 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.

“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.

A React component by Bruno Delorence: https://brunodelorence.com/components/sync-status. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Feedback.

## Anatomy

1. **Stage**: A rounded panel that sets the type size from its own width (a container query), so the pill grows with the room it has.
2. **Pill**: The status itself, raised on the stage with a hairline and a soft shadow. Its width follows the words, and springs to the new one when they change.
3. **Status icon**: A ten-dot spinner, a green check or a red alert. The old icon shrinks and blurs out as the new one springs in.
4. **Label**: Only its end changes (“Sync|ing”, “Sync|ed”, “Sync| failed”), rising in with a touch of blur; red on a failure.
5. **Retry button**: Shown only on a failure: it buds off the pill’s round end like a drop of liquid and flows back in when pressed. Labelled “Retry sync”.
6. **Sync now**: Starts a sync at any time; `aria-disabled` and greyed while one runs. Focus lands here when the retry button goes.
7. **Offline switch**: A real `role="switch"` that makes the simulated sync fail. It is left out when you pass your own `sync`.

## 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.

## Installation

Install it with the shadcn CLI, into components/sync-status of any React project:

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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} />;
}
```

## States

- **Synced**: The resting state: a green check and “Synced”. Sync now is ready.
- **Syncing**: The spinner keeps turning, in ten steps a second; nothing else moves. Sync now is greyed.
- **Sync failed**: The words turn red, the alert shows, and the retry button sits beside the pill in the ink colour.
- **Offline on**: Still synced, but the switch is red: the next simulated sync will fail.
- **Dark**: With `colorScheme="dark"` every token takes its dark side; the retry button turns light.

## How it works

- 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.

## API reference

- **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'`, default `'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`, default `false`): Opens with the Offline switch on, so the next simulated sync fails. Setting it turns the demo off.
- **autoplay** (`boolean`, default `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.

## Accessibility

- 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.

## Source

### sync-status/sync-status.tsx

```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").
 * - The demo syncs, then fails, then a ghost pointer retries; under reduced motion states change
 *   at once and the demo does not run.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. The moving bud's colours are mixed from
 *   the two the CSS resolves for the SVG layer, read as it draws.
 */

export type SyncState = 'syncing' | 'synced' | 'failed';

/** How long the simulated sync takes, in ms. */
const SYNC_TIME = 1600;

/** The end of the label after "Sync", for each state. */
const ENDINGS: Record<SyncState, string> = {
  syncing: 'ing',
  synced: 'ed',
  failed: ' failed',
};

const ANNOUNCEMENTS: Record<SyncState, string> = {
  syncing: 'Syncing.',
  synced: 'Synced.',
  failed: 'Sync failed. Retry?',
};

/** A damped spring, from 0 to 1: `damping` below 1 overshoots, `frequency` sets the pace. */
type Spring = { damping: number; frequency: number };
/** The pill moving and resizing. */
const SHAPE: Spring = { damping: 0.78, frequency: 14 };
/** The button pulling away: lively, so the goo stretches and snaps. */
const SPLIT: Spring = { damping: 0.5, frequency: 13 };
/** The button flowing back in. */
const MERGE: Spring = { damping: 0.85, frequency: 16 };

/** The spring's value `t` seconds in. */
function spring(t: number, { damping, frequency }: Spring) {
  if (t <= 0) return 0;
  const decay = damping * frequency;
  const swing = frequency * Math.sqrt(1 - damping * damping);
  return 1 - Math.exp(-decay * t) * (Math.cos(swing * t) + (decay / swing) * Math.sin(swing * t));
}
/** How long until it is still, in seconds (within a thousandth). */
const settled = ({ damping, frequency }: Spring) => 6.9 / (damping * frequency);

const lerp = (from: number, to: number, t: number) => from + (to - from) * t;
const smoothstep = (from: number, to: number, x: number) => {
  const t = Math.min(1, Math.max(0, (x - from) / (to - from)));
  return t * t * (3 - 2 * t);
};
/** A computed sRGB colour's red, green and blue, from 0 to 255; null for other colour spaces. */
const channels = (colour: string) => {
  const legacy = colour.startsWith('rgb');
  if (!legacy && !colour.startsWith('color(srgb ')) return null;
  const values = (colour.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
  return values.length === 3 ? values.map((value) => (legacy ? value : value * 255)) : null;
};
/**
 * The pill's colour turning to the button's, `t` from 0 to 1. sRGB colours mix to rgb(), so the
 * goo's gradient blends in sRGB, as it always has; colours in other spaces go to color-mix().
 */
const mix = (from: string, to: string, t: number) => {
  const [a, b] = [channels(from), channels(to)];
  if (!a || !b) return `color-mix(in srgb, ${to} ${t * 100}%, ${from})`;
  return `rgb(${a.map((channel, i) => Math.round(lerp(channel, b[i], t))).join(' ')})`;
};

/** Where the shapes are, in px in the group's box: the pill, and the button's centre if out. */
type Shapes = { left: number; width: number; bud: number | null };

/** A move between two layouts. */
type Motion = { from: Shapes; to: Shapes; start: number; snapped: boolean };

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function SyncStatus({
  sync,
  autoplay = true,
  colorScheme,
  defaultState,
  defaultOffline,
  className = '',
}: {
  /**
   * Runs a sync: resolves when it is done, rejects when it fails. Without it, the stage simulates
   * one (1.6 s), which fails while its Offline switch is on.
   */
  sync?: () => Promise<unknown>;
  /** While on screen and untouched, a demo pointer plays with it on its own. */
  autoplay?: boolean;
  /**
   * Light or dark colours, or the system's ('system' follows the reader's setting). Without it,
   * it takes the page's color-scheme, which is light unless the page says otherwise.
   */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens in this state, at rest, instead of "Synced". No sync runs for it: "syncing" spins until
   * a sync is started elsewhere. Setting it turns the demo off.
   */
  defaultState?: SyncState;
  /** Opens with the Offline switch on, so the next simulated sync fails. Turns the demo off. */
  defaultOffline?: boolean;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [state, setState] = useState<SyncState>(defaultState ?? 'synced');
  // A status opened in a given state is shown as it is: no demo plays with it.
  const demo = autoplay && defaultState === undefined && defaultOffline === undefined;
  /** Counts the changes, to key the icons and words. */
  const [version, setVersion] = useState(0);
  /**
   * The state before the last change, whose icon and words leave as the new ones come in. They
   * stay, invisible: their exit animation starts from visible, so nothing has to clear them.
   */
  const [previous, setPrevious] = useState<{ state: SyncState; version: number } | null>(null);
  const [offline, setOffline] = useState(defaultOffline ?? false);
  const [message, setMessage] = useState('');
  const gradient = useId();

  const group = useRef<HTMLDivElement>(null);
  const pill = useRef<HTMLDivElement>(null);
  const retry = useRef<HTMLButtonElement>(null);
  const syncNow = useRef<HTMLButtonElement>(null);
  const layer = useRef<SVGSVGElement>(null);
  const pillShape = useRef<SVGRectElement>(null);
  const goo = useRef<SVGPathElement>(null);
  const gooEnd = useRef<SVGStopElement>(null);
  const gooGradient = useRef<SVGLinearGradientElement>(null);
  const bud = useRef<SVGCircleElement>(null);
  const budIcon = useRef<SVGGElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  /** Where the shapes were when the state changed, to move from there. */
  const before = useRef<Shapes | null>(null);
  const motion = useRef<Motion | null>(null);
  /** The shapes as last drawn, so a change in mid-move starts from where they are. */
  const drawn = useRef<Shapes | null>(null);
  const frame = useRef(0);
  /** Counts the syncs, so only the latest one's result lands. */
  const runs = useRef(0);
  /** The state of the latest render, for the syncs' results, which land later. */
  const latest = useRef({ state, version });
  const refocus = useRef(false);
  const stopDemo = useRef<(() => void) | null>(null);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** The shapes at rest, as laid out now. */
  const measure = (): Shapes | null => {
    const box = group.current?.getBoundingClientRect();
    const shape = pill.current?.getBoundingClientRect();
    const button = retry.current?.getBoundingClientRect();
    if (!box || !shape || !group.current) return null;
    // Screen pixels to the group's own, should the stage sit in a scaled container.
    const scale = box.width / group.current.offsetWidth || 1;
    return {
      left: (shape.left - box.left) / scale,
      width: shape.width / scale,
      bud: button?.width ? (button.left + button.width / 2 - box.left) / scale : null,
    };
  };

  const change = (next: SyncState) => {
    const now = latest.current;
    if (now.state === next) return;
    before.current = reducedMotion() ? null : (drawn.current ?? measure());
    // The retry button goes with the failure: the focus moves to Sync now.
    refocus.current = document.activeElement === retry.current;
    latest.current = { state: next, version: now.version + 1 };
    setPrevious({ state: now.state, version: now.version });
    setState(next);
    setVersion(now.version + 1);
    announce(ANNOUNCEMENTS[next]);
  };

  /** A simulated sync, `SYNC_TIME` long. */
  const simulate = (succeed: boolean) =>
    new Promise<void>((resolve, reject) => {
      window.setTimeout(() => (succeed ? resolve() : reject(new Error('Offline'))), SYNC_TIME);
    });

  /** Syncs: with the reader's function, or the simulation (forced to `outcome` by the demo). */
  const run = (outcome?: boolean) => {
    if (state === 'syncing') return;
    const id = (runs.current += 1);
    change('syncing');
    const attempt = () => (sync ? sync() : simulate(!offline));
    // A function that throws fails like one that rejects.
    const job = outcome === undefined ? Promise.resolve().then(attempt) : simulate(outcome);
    job.then(
      () => runs.current === id && change('synced'),
      () => runs.current === id && change('failed'),
    );
  };

  /** Draws the shapes at `now`; false once they have settled. */
  const draw = (now: number) => {
    const move = motion.current;
    const height = pill.current?.offsetHeight ?? 0;
    if (!move || !height) return false;
    const r = height / 2;
    const t = (now - move.start) / 1000;
    const p = spring(t, SHAPE);
    const left = lerp(move.from.left, move.to.left, p);
    const width = lerp(move.from.width, move.to.width, p);
    // The centre of the pill's round right end, where the button comes out and goes back in.
    const end = left + width - r;
    const endOf = (shapes: Shapes) => shapes.left + shapes.width - r;
    let x: number | null = null;
    let duration = settled(SHAPE);
    if (move.from.bud === null && move.to.bud !== null) {
      // It pulls away from the end, a moment after the pill starts to change.
      x = end + (move.to.bud - endOf(move.to)) * spring(t - 0.06, SPLIT);
      duration = Math.max(duration, settled(SPLIT) + 0.06);
    } else if (move.from.bud !== null && move.to.bud === null) {
      x = end + (move.from.bud - endOf(move.from)) * (1 - spring(t, MERGE));
      duration = Math.max(duration, settled(MERGE));
    } else if (move.from.bud !== null && move.to.bud !== null) {
      x = lerp(move.from.bud, move.to.bud, p);
    }

    pillShape.current?.setAttribute('x', left.toFixed(2));
    pillShape.current?.setAttribute('width', width.toFixed(2));
    pillShape.current?.setAttribute('height', height.toFixed(2));
    pillShape.current?.setAttribute('rx', r.toFixed(2));
    if (x === null) {
      bud.current?.setAttribute('r', '0');
      goo.current?.setAttribute('d', '');
      budIcon.current?.setAttribute('opacity', '0');
    } else {
      const distance = x - end;
      // The pill's colour inside it, the button's own once it is away. Both are read from the CSS
      // each frame, so they follow the colour scheme, even should it change during the move.
      const colours = layer.current && getComputedStyle(layer.current);
      const ink = smoothstep(r, 2.2 * r, distance);
      const shade = colours ? mix(colours.fill, colours.color, ink) : '';
      bud.current?.setAttribute('cx', x.toFixed(2));
      bud.current?.setAttribute('cy', r.toFixed(2));
      bud.current?.setAttribute('r', r.toFixed(2));
      bud.current?.style.setProperty('fill', shade);
      // Pulling away, the goo stretches until it snaps for good; coming back, it joins sooner.
      const splitting = move.to.bud !== null;
      const reach = (splitting ? 2.62 : 2.3) * r;
      if (splitting && distance >= reach) move.snapped = true;
      const path = move.snapped ? '' : bridge({ x: end, y: r, r }, { x, y: r, r }, reach);
      goo.current?.setAttribute('d', path);
      // The pill's colour at its edge, the button's at its centre.
      gooGradient.current?.setAttribute('x1', (end + r * 0.8).toFixed(2));
      gooGradient.current?.setAttribute('x2', Math.max(end + r, x).toFixed(2));
      gooEnd.current?.style.setProperty('stop-color', shade);
      const size = height * 0.44;
      budIcon.current?.setAttribute(
        'transform',
        `translate(${(x - size / 2).toFixed(2)} ${(r - size / 2).toFixed(2)}) scale(${(size / 20).toFixed(3)})`,
      );
      budIcon.current?.setAttribute('opacity', (ink * ink).toFixed(3));
    }
    // The words ride on the pill, clipped to its right end as it narrows or widens.
    const words = pill.current;
    if (words) {
      words.style.transform = `translateX(${(left - move.to.left).toFixed(2)}px)`;
      words.style.clipPath = `inset(-4px ${(move.to.width - width).toFixed(2)}px -4px -4px)`;
    }
    drawn.current = { left, width, bud: x };
    return t < duration;
  };

  const finish = () => {
    motion.current = null;
    drawn.current = null;
    group.current?.removeAttribute('data-goo');
    pill.current?.style.removeProperty('transform');
    pill.current?.style.removeProperty('clip-path');
  };

  // After a change: from where the shapes were to where the new layout puts them.
  useLayoutEffect(() => {
    if (refocus.current) {
      refocus.current = false;
      syncNow.current?.focus();
    }
    const from = before.current;
    before.current = null;
    const element = group.current;
    if (!from || !element) return;
    cancelAnimationFrame(frame.current);
    finish();
    const to = measure();
    if (!to) return;
    motion.current = { from, to, start: performance.now(), snapped: false };
    element.setAttribute('data-goo', '');
    draw(performance.now());
    const loop = () => {
      if (draw(performance.now())) frame.current = requestAnimationFrame(loop);
      else finish();
    };
    frame.current = requestAnimationFrame(loop);
    // `measure`, `draw` and `finish` read refs only; the state is all this depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state]);

  // Nothing keeps moving, and no sync lands, once the status is gone.
  useEffect(
    () => () => {
      cancelAnimationFrame(frame.current);
      runs.current += 1;
    },
    [],
  );

  // The demo calls the handlers of the latest render.
  const actions = useRef({ run, state });
  useLayoutEffect(() => {
    actions.current = { run, state };
  });

  // The demo: a sync that works, one that fails, and a ghost pointer that retries; again while on
  // screen and untouched. The demo decides each outcome; the Offline switch is the reader's.
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !playing || !stage || !ghost) return;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const act = () => actions.current;
      const retryNow = async () => {
        await moveTo(retry.current);
        await wait(350);
        await press(() => act().run(true));
        await wait(SYNC_TIME + 1500);
        await moveTo(stage, { x: 0.7, y: 0.82 });
      };
      await wait(700);
      for (;;) {
        // A failure the reader left is retried first.
        if (act().state === 'failed') await retryNow();
        act().run(true);
        await wait(SYNC_TIME + 1600);
        act().run(false);
        await wait(SYNC_TIME + 1100);
        await retryNow();
      }
    });
    stopDemo.current = stop;
    return stop;
  }, [demo, playing, root]);

  /** The reader takes over: the demo stops at once, and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    stopDemo.current = null;
    take();
  };

  const failed = state === 'failed';
  const busy = state === 'syncing';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
    >
      <div className={styles.scene}>
        <div ref={group} className={styles.group} data-state={state}>
          <svg ref={layer} className={styles.goo} aria-hidden="true">
            <defs>
              <linearGradient ref={gooGradient} id={gradient} gradientUnits="userSpaceOnUse">
                <stop offset="0" className={styles.gooStop} />
                <stop ref={gooEnd} offset="1" className={styles.gooStop} />
              </linearGradient>
            </defs>
            {/* The pill comes last: the button goes in and out from inside its round end. */}
            <path ref={goo} fill={`url(#${gradient})`} />
            <circle ref={bud} cy="0" r="0" />
            <g ref={budIcon} className={styles.budIcon} opacity="0">
              <RetryPath />
            </g>
            <rect ref={pillShape} />
          </svg>

          <div ref={pill} className={styles.pill} data-part="pill">
            <span className={styles.icon} data-part="icon">
              {previous && (
                <StateIcon
                  key={`out-${previous.version}`}
                  state={previous.state}
                  className={styles.iconOut}
                />
              )}
              <StateIcon
                key={`in-${version}`}
                state={state}
                className={styles.iconIn}
                entering={!!previous}
              />
            </span>
            <span className={styles.label} data-part="label">
              Sync
              <span className={styles.ending}>
                {previous && (
                  <span
                    key={`out-${previous.version}`}
                    className={styles.endingOut}
                    aria-hidden="true"
                  >
                    {ENDINGS[previous.state]}
                  </span>
                )}
                <span
                  key={`in-${version}`}
                  className={styles.endingIn}
                  data-entering={previous ? '' : undefined}
                >
                  {ENDINGS[state]}
                </span>
              </span>
            </span>
          </div>

          <button
            ref={retry}
            type="button"
            className={styles.retry}
            data-part="retry"
            aria-label="Retry sync"
            inert={!failed}
            onClick={() => run()}
          >
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <RetryPath />
            </svg>
          </button>
        </div>

        <div className={styles.controls}>
          <button
            ref={syncNow}
            type="button"
            className={styles.action}
            data-part="sync-now"
            aria-disabled={busy}
            onClick={() => run()}
          >
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <RetryPath />
            </svg>
            Sync now
          </button>
          {!sync && (
            <button
              type="button"
              role="switch"
              className={styles.switch}
              data-part="offline"
              aria-checked={offline}
              onClick={() => setOffline((now) => !now)}
            >
              <span className={styles.track} aria-hidden="true" />
              Offline
            </button>
          )}
        </div>
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A circular arrow, in a 20 × 20 box. */
function RetryPath() {
  return (
    <path
      d="M15.6 9.9a5.6 5.6 0 1 1-1.64-3.96M14.4 2.9v3.4H11"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.9"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  );
}

/** The state's icon: a spinner, a check, or an alert. */
function StateIcon({
  state,
  className,
  entering,
}: {
  state: SyncState;
  className: string;
  entering?: boolean;
}) {
  return (
    <svg
      className={className}
      viewBox="0 0 20 20"
      data-icon={state}
      data-entering={entering || undefined}
      aria-hidden="true"
    >
      {state === 'syncing' &&
        Array.from({ length: 10 }, (_, i) => (
          <circle
            key={i}
            cx="10"
            cy="2.6"
            r="1.35"
            transform={`rotate(${i * 36} 10 10)`}
            style={{ '--i': i } as CSSProperties}
          />
        ))}
      {state === 'synced' && (
        <>
          <circle cx="10" cy="10" r="8.5" />
          <path d="m6.3 10.3 2.5 2.5 5-5.3" pathLength={1} />
        </>
      )}
      {state === 'failed' && (
        <>
          <circle cx="10" cy="10" r="8.5" />
          <path d="M10 5.8v5.2" />
          <circle cx="10" cy="14" r="1.1" />
        </>
      )}
    </svg>
  );
}
```

### sync-status/sync-status.module.css

```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. */
  --shadow: rgb(0 0 0 / 0.08);
  --control-shadow: rgb(0 0 0 / 0.05);
  --knob-shadow: rgb(0 0 0 / 0.2);
  /* The retry button, and its arrow. */
  --accent: #1c1c1e;
  --accent-ink: #fff;
  /* A failure (the words, the alert, Offline on) and a sync done (the check's disc). */
  --red: #e5484d;
  --green: #30a46c;
  /* Offline, off. */
  --track: #e2e2e8;
  /* The same on both schemes: the marks on the red and green discs, the switch's knob, the focus
     ring, and the demo's pointer, which looks like the system's. */
  --badge-ink: #fff;
  --knob: #fff;
  --focus: #0a84ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --spring: linear(
    0,
    0.054,
    0.18,
    0.336,
    0.496,
    0.641,
    0.764,
    0.861,
    0.932,
    0.982,
    1.014,
    1.031,
    1.038,
    1.039,
    1.036,
    1.03,
    1.024,
    1.017,
    1.012,
    1.008,
    1.004,
    1.002,
    1,
    0.999,
    0.999,
    0.998,
    1
  );
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#efeff3, #121214);
    --surface-2: light-dark(#fff, #242427);
    --ink: light-dark(#1c1c1e, #ececef);
    --ink-muted: light-dark(#8e8e93, #8e8e95);
    --spinner-out: light-dark(#000, var(--ink));
    --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.1));
    --control-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08));
    --shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
    --control-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --knob-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.4));
    --accent: light-dark(#1c1c1e, #ececef);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --red: light-dark(#e5484d, #f06063);
    --green: light-dark(#30a46c, #2b9a63);
    --track: light-dark(#e2e2e8, #3a3a3f);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

.scene {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: max(380px, 56cqi);
  padding: 2em 16px 1.75em;
  font-size: clamp(14px, 9.5px + 1.1cqi, 20px);
}

/* The pill and the retry button, side by side and centred. The shadow is the group's, so it
   follows whatever is drawn: the HTML at rest, the SVG while it moves. */
.group {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6em;
  width: 100%;
  height: 2.75em;
  filter: drop-shadow(0 0 0.5px var(--edge)) drop-shadow(0 5px 10px var(--shadow));
}
.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  height: 100%;
  padding: 0 1.15em 0 0.8em;
  border-radius: 999px;
  background: var(--surface-2);
  white-space: nowrap;
}
.retry {
  display: none;
  place-items: center;
  width: 2.75em;
  height: 2.75em;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
  cursor: pointer;
  transition: scale 0.3s var(--spring);
}
.group[data-state='failed'] .retry {
  display: grid;
}
.retry svg {
  width: 1.2em;
  height: 1.2em;
}
.retry:hover {
  scale: 1.05;
}
.retry:active {
  scale: 0.92;
}
.retry:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
/* While the shapes move, the SVG draws them. */
.group[data-goo] .pill,
.group[data-goo] .retry {
  background: transparent;
}
.group[data-goo] .retry svg {
  visibility: hidden;
}
.goo {
  position: absolute;
  inset: 0;
  display: none;
  width: 100%;
  height: 100%;
  overflow: visible;
  /* The pill's colour (fill) and the button's (color). The button buds off in the first and takes
     the second as it gets away: the script reads both, resolved for the scheme in use, and mixes
     them for the bud and the end of the goo's neck. */
  color: var(--accent);
  fill: var(--surface-2);
  pointer-events: none;
}
.group[data-goo] .goo {
  display: block;
}
.budIcon {
  color: var(--accent-ink);
}
.gooStop {
  stop-color: var(--surface-2);
}

/* The icon and the words. Those of the state before stay, invisible: they leave from visible. */
.icon {
  display: grid;
  width: 1.25em;
  height: 1.25em;
}
.iconIn,
.iconOut {
  grid-area: 1 / 1;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.iconIn[data-entering] {
  animation: arrive 0.42s var(--spring) backwards;
}
.iconOut {
  opacity: 0;
  animation: depart 0.22s ease-in backwards;
}
.iconOut[data-icon='syncing'] {
  fill: var(--spinner-out);
}
.iconIn[data-icon='syncing'] {
  fill: var(--ink);
  animation: spin 1s steps(10) infinite;
}
.iconIn[data-icon='syncing'][data-entering] {
  animation:
    arrive 0.42s var(--spring) backwards,
    spin 1s steps(10) infinite;
}
.icon [data-icon='syncing'] circle {
  opacity: calc(0.22 + var(--i) * 0.085);
}
.icon [data-icon='synced'] circle {
  fill: var(--green);
}
.icon [data-icon='synced'] path {
  fill: none;
  stroke: var(--badge-ink);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
}
.iconIn[data-icon='synced'][data-entering] path {
  animation: draw 0.36s var(--ease-out) 0.16s backwards;
}
.icon [data-icon='failed'] circle:first-child {
  fill: var(--red);
}
.icon [data-icon='failed'] path {
  stroke: var(--badge-ink);
  stroke-width: 2.1;
  stroke-linecap: round;
}
.icon [data-icon='failed'] circle:last-child {
  fill: var(--badge-ink);
}
.label {
  display: inline-flex;
  font-size: 0.95em;
  font-weight: 600;
  letter-spacing: -0.01em;
  transition: color 0.3s ease;
}
.group[data-state='failed'] .label {
  color: var(--red);
}
.ending {
  position: relative;
  display: inline-grid;
  white-space: pre;
}
.endingIn[data-entering] {
  animation: rise 0.34s var(--ease-out) 0.08s backwards;
}
.endingOut {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  animation: lift 0.2s ease-in backwards;
}

/* The stage's controls: sync now, and the switch that makes syncs fail. */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.6em;
  margin-top: 3.5em;
  font-size: 0.8em;
}
.controls .action,
.controls .switch {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  height: 2.2em;
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--control-edge),
    0 1px 2px var(--control-shadow);
  font-weight: 600;
  cursor: pointer;
}
.controls .action {
  padding: 0 0.95em 0 0.7em;
}
.action svg {
  width: 1.1em;
  height: 1.1em;
}
.action[aria-disabled='true'] {
  color: var(--ink-muted);
  cursor: default;
}
.controls .switch {
  padding: 0 0.95em 0 0.45em;
}
.track {
  position: relative;
  width: 2.1em;
  height: 1.3em;
  border-radius: 99px;
  background: var(--track);
  transition: background-color 0.2s ease;
}
.track::after {
  content: '';
  position: absolute;
  top: 0.15em;
  left: 0.15em;
  width: 1em;
  height: 1em;
  border-radius: 50%;
  background: var(--knob);
  box-shadow: 0 1px 2px var(--knob-shadow);
  transition: translate 0.35s var(--spring);
}
.switch[aria-checked='true'] .track {
  background: var(--red);
}
.switch[aria-checked='true'] .track::after {
  translate: 0.8em 0;
}
.action:focus-visible,
.switch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* For screen readers only: the announcements. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes spin {
  to {
    rotate: 1turn;
  }
}
@keyframes arrive {
  from {
    opacity: 0;
    scale: 0.4;
    filter: blur(3px);
  }
}
@keyframes depart {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    scale: 0.4;
    filter: blur(3px);
  }
}
@keyframes draw {
  from {
    stroke-dashoffset: 1;
  }
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 0.35em;
    filter: blur(4px);
  }
}
@keyframes lift {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    translate: 0 -0.35em;
    filter: blur(4px);
  }
}

/* Final states at once (the spinner stays still). */
@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    animation-iteration-count: 1 !important;
    transition: none !important;
  }
}
```

### sync-status/goo.ts

```tsx
/*
 * 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).
 */
export function bridge(a: Circle, b: Circle, reach: number): string {
  const distance = Math.hypot(b.x - a.x, b.y - a.y);
  const touching = a.r + b.r;
  if (distance >= reach || distance <= Math.abs(a.r - b.r)) return '';
  // How far round each circle the bridge spreads: 0.5 while they overlap, down to 0 (a thread).
  const spread =
    distance <= touching ? 0.5 : 0.5 * (1 - (distance - touching) / (reach - touching));
  // Where the circles cross, while they overlap.
  const overlap = distance < touching;
  const u1 = overlap
    ? Math.acos((a.r * a.r + distance * distance - b.r * b.r) / (2 * a.r * distance))
    : 0;
  const u2 = overlap
    ? Math.acos((b.r * b.r + distance * distance - a.r * a.r) / (2 * b.r * distance))
    : 0;
  const angle = Math.atan2(b.y - a.y, b.x - a.x);
  const widest = Math.acos((a.r - b.r) / distance);
  const a1 = angle + u1 + (widest - u1) * spread;
  const a2 = angle - u1 - (widest - u1) * spread;
  const b1 = angle + Math.PI - u2 - (Math.PI - u2 - widest) * spread;
  const b2 = angle - Math.PI + u2 + (Math.PI - u2 - widest) * spread;
  const p1 = towards(a, a1, a.r);
  const p2 = towards(a, a2, a.r);
  const q1 = towards(b, b1, b.r);
  const q2 = towards(b, b2, b.r);
  // The handles shorten as the bridge narrows, and as the circles come very close.
  const handle =
    Math.min(spread * 2.4, Math.hypot(p1.x - q1.x, p1.y - q1.y) / touching) *
    Math.min(1, (distance * 2) / touching);
  const h1 = towards(p1, a1 - Math.PI / 2, a.r * handle);
  const h2 = towards(p2, a2 + Math.PI / 2, a.r * handle);
  const k1 = towards(q1, b1 + Math.PI / 2, b.r * handle);
  const k2 = towards(q2, b2 - Math.PI / 2, b.r * handle);
  return [
    `M${write(p1)}`,
    `C${write(h1)} ${write(k1)} ${write(q1)}`,
    `A${b.r} ${b.r} 0 ${distance > a.r ? 1 : 0} 0 ${write(q2)}`,
    `C${write(k2)} ${write(h2)} ${write(p2)}`,
    'Z',
  ].join(' ');
}
```

### sync-status/ghost-pointer.tsx

```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 {}

type Point = { x: number; y: number };

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** Its colours are the stage's tokens (sync-status.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo's effects are announced. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### sync-status/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```