# Save indicator

> A corner sign that says the game is saving: a ring of breathing dots, then a check, then gone.

The small sign in a corner of a game’s screen while progress is written, so the player does not quit or switch off. Six dots on a ring breathe in turn, large and small, beside the word Saving; when the write is done they gather into a check, the word turns to Saved, and the sign fades away by itself. It shows long enough to be seen even when the save is instant.

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

Category: Games · HUD.

## Anatomy

1. **Stage**: The lab’s plain 16:9 stage, white or near-black, with the sign in its middle and the Save game chip under it; a container, so the sign scales with its width. `bare` leaves it out.
2. **Sign**: The glyph and the word in a row on a dark pill of its own, so the pale sign reads on a light stage, a dark one or a game. Sized in em of its word (22 px in a game, up to 40 px on the stage); drawn here at `--save-size: 55px` so its parts can be told apart.
3. **Glyph**: A 40-unit SVG: six dots on a hexagon, alternately large and small, and a check that is drawn once the save is done.
4. **Large dot**: A dot of radius 5. While saving it shrinks as its small neighbours grow, every 300 ms, so the ring seems to turn.
5. **Small dot**: The same dot drawn at 0.6 scale; it swells as the large ones shrink. Measured live, so the size moves with the breath.
6. **Word**: `label` (Saving) in medium italic beside the ring; `doneLabel` (Saved) shares its grid cell, so the sign keeps its width when the two cross over.

## When to use

- A game writing the player’s progress (an autosave at a checkpoint, a manual save, a sync) where quitting or switching off now could lose it.
- A write that may be instant or may take seconds: the sign stays at least `minSavingMs`, so even an instant save is seen and trusted.
- A HUD that must not get in the way: one small sign in a safe corner, with no button and no focus to take, gone by itself once done.
- Confirming the save without words to read: the ring turns into a check, so a glance at the corner is enough.

## When not to use

- A save that failed or needs a choice (overwrite a slot, retry). Use a dialog or a toast with actions; the sign has no error state.
- Loading a level or a long download with a known length. Use a progress bar or a loading screen with its percentage.
- Saving a document or a form in an app. Use the app’s own saved status beside the title, or a small toast.
- Telling the player something they must read or act on. Use a notification or an action prompt.
- Busy states inside a button or a list. Use a spinner in that control; this one sits over a whole screen.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/save-indicator.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 { useState } from 'react';
import { SaveIndicator, type SaveState } from './save-indicator';

export function Checkpoint() {
  const [save, setSave] = useState<SaveState>('idle');
  async function reachCheckpoint() {
    setSave('saving');
    await writeProgress();
    setSave('saved');
  }
  return <SaveIndicator bare state={save} onDone={() => setSave('idle')} />;
}
```

## States

- **Saving**: `state="saving"`: the dots breathe in turn beside the word. The ring stays at least `minSavingMs`.
- **Spin**: `motion="spin"`: the large size runs round the ring instead, a chase.
- **Saved**: `state="saved"`: the dots have gathered into a check and the word reads Saved. Held here; on its own it fades out after `holdMs` and calls `onDone`.
- **Glyph only**: `showLabel={false}`: the ring alone. The live region still says Saving… and Saved.
- **Right corner**: `corner="top-right"`: meant for a right corner, the word comes first, so the glyph sits by the screen’s edge, and it leaves to the right.
- **Own words**: `label="Autosaving"`: any word, kept on one line.
- **Idle**: Uncontrolled with `autoplay={false}`: nothing is saving, so only the empty stage and its Save game chip show.
- **Bare**: `bare`: the sign alone, without stage or chip, to lay over a game of your own; its plate carries it on the page.
- **No plate**: `plate={false}`: the dots and word alone, for a game dark enough behind them; shown here on the dark stage.
- **Light**: `colorScheme="light"`: the white stage. The sign keeps its own colours on its dark plate, as a game’s HUD does.
- **Dark**: `colorScheme="dark"`: the near-black stage; the same sign, its plate a step lighter with a fine edge.

## How it works

- The glyph is one SVG of six dots of radius 5 on a hexagon in a 40-unit box, alternately large and small (drawn at 3 by a 0.6 scale). While saving, keyframes on each dot’s transform swap the two sizes every 300 ms with ease-in-out, so the ring seems to turn by a sixth each beat; motion="spin" sends the large size round the ring instead, with each dot’s keyframes a sixth of a turn behind the last.
- The entrance pops each dot from nothing by the separate scale property, 30 ms apart round the ring, and the gathering moves them by translate and scale: both add to the breathing transform, so the three never fight.
- The ring stays at least minSavingMs, however soon the save ends. Then the dots fall into the middle in 240 ms, a check as thick as a large dot draws itself by its dash offset (280 ms, after 170 ms) and pops, and the word crosses over: both words share one grid cell, so the sign never changes width.
- After holdMs the whole sign fades out with a slight drift in 520 ms and onDone is called. Sent back to idle while saving, it simply fades out.
- The sign is 22 px medium italic type beside a 40 px glyph, 10 px apart, on a dark pill of its own (90% opaque, a 1 px light edge, a soft box-shadow lift), so its pale dots and words read on a white page, a dark one or a game, as a HUD keeps its own colours. Everything is SVG and type, with no blur filter. plate={false} leaves the pill out over a game dark enough already.
- On the lab’s plain stage the sign sits in the middle and grows with the stage’s width, up to 40 px (a container query), with a small Save game chip under it; bare, it takes its size from --save-size and corner sets which side the glyph is on. Controlled by state, or on its own with the chip and a demo autosave while on screen and untouched. The breathing pauses offscreen or in a hidden tab.

## API reference

- **state** (`'idle' | 'saving' | 'saved'`): Controlled: saving shows the ring, saved the check (then the sign fades out by itself), idle hides it. Turns the demo and the Save game chip off.
- **onDone** (`() => void`): Called once the sign has faded out after a save.
- **label** (`string`, default `'Saving'`): The word beside the ring.
- **doneLabel** (`string`, default `'Saved'`): The word beside the check.
- **showLabel** (`boolean`, default `true`): The word beside the glyph; without it, the glyph alone (screen readers still hear it).
- **motion** (`'pulse' | 'spin'`, default `'pulse'`): pulse: the dots breathe in turn, large and small. spin: the large size runs round the ring.
- **corner** (`'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'`, default `'bottom-left'`): The screen corner the sign is meant for: on a right one the word comes first and the sign leaves to the right. The stage shows it centred; bare, you place it.
- **plate** (`boolean`, default `true`): The sign’s own dark pill, so it reads on light and dark; leave it out over a game that is dark enough.
- **minSavingMs** (`number`, default `1200`): The least time the ring shows, however soon saved comes.
- **holdMs** (`number`, default `1400`): How long the check stays before the sign fades out.
- **bare** (`boolean`, default `false`): The sign alone, without the stage and its Save game chip, to lay over a game of your own.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, an autosave plays now and then. Any input hands control to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark stage, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Classes for the root element.

## Accessibility

- The drawn sign is aria-hidden; a polite status region says “Saving…” when a save starts and “Saved” when it ends, once each.
- The Save game chip is a real button, reachable by keyboard, with a visible focus ring; while a save runs it is aria-disabled rather than removed, so focus stays on it.
- Under reduced motion the ring stands still (the kit’s first frame) and the changes are short cross-fades; the demo does not play.
- The breathing pauses while the stage is offscreen or the tab is hidden.

## Source

### save-indicator/save-indicator.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useEffectEvent, useRef, useState, type CSSProperties } from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './save-indicator.module.css';

/*
 * Save indicator: the small sign in a corner of a game's screen that says progress is being
 * written, so the player does not quit or switch off. A ring of six dots and the word "Saving".
 *
 * - The glyph is six dots on a hexagon, alternately large and small. While saving they breathe:
 *   every 300 ms (ease-in-out) the large ones shrink as the small ones grow, so the ring seems to
 *   turn by a sixth each beat. `motion="spin"` sends the large size round the ring instead, a
 *   chase. Each dot is scaled by a transform, so no SVG is ever restyled.
 * - It stays on screen for at least `minSavingMs`, so an instant save is still seen. Then the dots
 *   gather into the middle, a check mark draws itself with a little pop, and the word crosses over
 *   to "Saved". After `holdMs` the whole sign fades out with a slight drift, and `onDone` is called.
 * - Back to 'idle' while saving, it simply fades out.
 * - Controlled by `state`, or on its own: the stage has a Save button, and a demo plays an autosave
 *   now and then while on screen and untouched. The breathing pauses offscreen or in a hidden tab;
 *   under reduced motion the ring stands still and the changes are cross-fades.
 * - The stage is the lab's plain one, light or dark, with the sign in its middle and a small Save
 *   game chip under it. The sign keeps its own colours on both, as a game's HUD does: pale dots and
 *   words on a dark plate of its own, so it reads on any background. `bare` draws the sign alone,
 *   to lay over a game of your own; `plate={false}` leaves its plate out there.
 */

export type SaveState = 'idle' | 'saving' | 'saved';
/** What the sign shows: nothing, the ring, the check, or the check fading out. */
type Phase = 'hidden' | 'saving' | 'saved' | 'leaving';

/** The six dots, from the top, clockwise: centres in the 40-unit box, and large or small. */
const DOTS: { x: number; y: number; large: boolean }[] = [
  { x: 20, y: 6, large: true },
  { x: 32, y: 13, large: false },
  { x: 32, y: 27, large: true },
  { x: 20, y: 34, large: false },
  { x: 8, y: 27, large: true },
  { x: 8, y: 13, large: false },
];

/** How long the fade out takes, in ms (the CSS has the same). */
const LEAVE = 520;

/** The demo: an autosave, from its start, in ms. */
const DEMO_SAVE = 900;
const DEMO_DONE = 3300;
const DEMO_LENGTH = 7600;

export function SaveIndicator({
  state,
  onDone,
  label = 'Saving',
  doneLabel = 'Saved',
  showLabel = true,
  motion = 'pulse',
  corner = 'bottom-left',
  plate = true,
  minSavingMs = 1200,
  holdMs = 1400,
  bare = false,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Controlled: 'saving' shows the ring, 'saved' the check (then it fades out by itself), 'idle' hides it. Turns the demo and the Save button off. */
  state?: SaveState;
  /** Called once the sign has faded out after a save. */
  onDone?: () => void;
  /** The word beside the ring while saving. */
  label?: string;
  /** The word beside the check. */
  doneLabel?: string;
  /** The word beside the glyph; without it, the glyph alone (it still speaks to screen readers). */
  showLabel?: boolean;
  /** 'pulse': the dots breathe in turn, large and small. 'spin': the large size runs round the ring. */
  motion?: 'pulse' | 'spin';
  /** The screen corner the sign is meant for: on a right one the word comes first and the sign leaves to the right. The stage shows it centred; bare, you place it. */
  corner?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
  /** The sign's own dark plate, so it reads on light and dark; leave it out over a game that is dark enough. */
  plate?: boolean;
  /** The least time the ring shows, in ms, however soon 'saved' comes. */
  minSavingMs?: number;
  /** How long the check stays before the sign fades out, in ms. */
  holdMs?: number;
  /** The sign alone, without the stage and its Save game chip, to lay over a game of your own. */
  bare?: boolean;
  /** While on screen and untouched, an autosave plays now and then. */
  autoplay?: boolean;
  /** Light or dark stage, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, take } = useAutoplay<HTMLDivElement>(7000);
  const controlled = state !== undefined;
  const [own, setOwn] = useState<SaveState>('idle');
  const target = state ?? own;
  const demo = autoplay && playing && !controlled;

  const [phase, setPhase] = useState<Phase>('hidden');
  /** Bumped on every new appearance, so the entrance plays again. */
  const [round, setRound] = useState(0);
  const [message, setMessage] = useState('');
  /** When the ring last appeared, for the least time it shows. */
  const since = useRef(0);
  const timer = useRef(0);
  /** The pretend write the Save button starts. */
  const write = useRef(0);
  const phaseNow = useRef<Phase>('hidden');

  const go = (next: Phase) => {
    phaseNow.current = next;
    setPhase(next);
  };
  const later = (ms: number, run: () => void) => {
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(run, ms);
  };
  useEffect(
    () => () => {
      window.clearTimeout(timer.current);
      window.clearTimeout(write.current);
    },
    [],
  );

  const done = useEffectEvent(() => {
    if (!controlled) setOwn('idle');
    onDone?.();
  });
  const hide = () => {
    go('leaving');
    later(LEAVE, () => {
      go('hidden');
      done();
    });
  };
  const confirm = () => {
    go('saved');
    setMessage(doneLabel);
    later(holdMs, hide);
  };

  // The phases follow the state asked for, with the sign's own timing in between.
  const follow = useEffectEvent((wanted: SaveState) => {
    const now = phaseNow.current;
    if (wanted === 'saving') {
      window.clearTimeout(timer.current);
      if (now !== 'saving') {
        if (now === 'hidden' || now === 'leaving') setRound((n) => n + 1);
        since.current = performance.now();
        go('saving');
        setMessage(`${label}…`);
      }
    } else if (wanted === 'saved') {
      if (now === 'saving') {
        later(Math.max(0, since.current + minSavingMs - performance.now()), confirm);
      } else if (now === 'hidden' || now === 'leaving') {
        setRound((n) => n + 1);
        confirm();
      }
    } else if (now === 'saving') {
      window.clearTimeout(timer.current);
      setMessage('');
      hide();
    }
  });
  useEffect(() => follow(target), [target]);

  // The demo: an autosave now and then, while on screen and untouched.
  useEffect(() => {
    if (!demo) return;
    const timers: number[] = [];
    const run = () => {
      timers.push(window.setTimeout(() => setOwn('saving'), DEMO_SAVE));
      timers.push(window.setTimeout(() => setOwn('saved'), DEMO_DONE));
    };
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    return () => {
      window.clearInterval(loop);
      timers.forEach((t) => window.clearTimeout(t));
    };
  }, [demo]);

  const save = () => {
    take();
    if (own === 'saving') return;
    setOwn('saving');
    // A pretend write: it takes a moment, as a real one would.
    window.clearTimeout(write.current);
    write.current = window.setTimeout(() => setOwn('saved'), 1400);
  };

  const busy = phase === 'saving';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-bare={bare || undefined}
      data-paused={!visible || undefined}
      onPointerDownCapture={controlled ? undefined : take}
      onKeyDownCapture={controlled ? undefined : take}
    >
      <div
        key={round}
        className={styles.sign}
        data-part="sign"
        data-phase={phase}
        data-motion={motion}
        data-corner={corner}
        data-plate={plate || undefined}
        data-label={showLabel || undefined}
        aria-hidden="true"
      >
        <svg className={styles.glyph} viewBox="0 0 40 40" focusable="false" data-part="glyph">
          {DOTS.map((dot, index) => (
            <circle
              key={index}
              className={styles.dot}
              cx={dot.x}
              cy={dot.y}
              r={5}
              data-large={dot.large || undefined}
              style={
                {
                  '--i': index,
                  '--to-x': `${20 - dot.x}px`,
                  '--to-y': `${20 - dot.y}px`,
                } as CSSProperties
              }
            />
          ))}
          <path className={styles.check} d="M11.5 20.5 17.5 26.5 29 14.5" pathLength={1} />
        </svg>
        {showLabel && (
          <span className={styles.words} data-part="label">
            <span className={styles.word} data-word="saving">
              {label}
            </span>
            <span className={styles.word} data-word="saved">
              {doneLabel}
            </span>
          </span>
        )}
      </div>
      {!bare && !controlled && (
        <button
          type="button"
          className={styles.save}
          data-part="save"
          aria-disabled={busy || undefined}
          onClick={save}
        >
          Save game
        </button>
      )}
      <p className={styles.live} role="status">
        {message}
      </p>
    </div>
  );
}
```

### save-indicator/save-indicator.module.css

```css
/* Save indicator (save-indicator.tsx): the lab's plain stage, white by day and near-black at
   night, with the sign in its middle and a small Save game chip under it. The sign's sizes are in
   em of its word (22 px in a game, drawn larger on the stage from its width by a container
   query), and a bare sign takes its size from --save-size.

   Colours are the tokens below, light-dark(light, dark) where the two differ. The sign keeps its
   own colours on both schemes, as a game's HUD does: pale dots and words on a dark plate of its
   own, so it reads on a light stage, a dark one, or a game. Everything is drawn sharp: SVG and
   type, no blur filter and no raster; the plate's lift is a box-shadow behind it.

   Motion: the dots breathe by keyframes on their transform; the entrance pops them by `scale`,
   and the gathering moves them by `translate` and `scale`, which add to the transform, so the
   three never fight. The check draws by its dash offset; the words and the sign fade by
   opacity and translate. */

.root {
  /* The sign: the glyph's dots and check, its word, its plate, the plate's edge and lift. */
  --glyph: #ece8de;
  --word: #f1ebdf;
  --plate: rgb(22 23 21 / 0.9);
  --plate-edge: rgb(255 255 255 / 0.1);
  --lift: rgb(0 0 0 / 0.16);
  /* The stage, its edge, and the Save game chip. */
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  --chip: #f3f3f3;
  --chip-hover: #e9e9ea;
  --chip-ink: #17191d;
  --chip-edge: rgb(17 17 17 / 0.06);
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(20px, 4.4cqi, 40px);
  box-sizing: border-box;
  width: 100%;
  min-height: 260px;
  aspect-ratio: 16 / 9;
  padding: 24px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--chip-ink);
  font-family:
    Roboto,
    system-ui,
    -apple-system,
    'Segoe UI',
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate: light-dark(rgb(22 23 21 / 0.9), rgb(38 39 37 / 0.92));
    --plate-edge: light-dark(rgb(255 255 255 / 0.1), rgb(255 255 255 / 0.1));
    --lift: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.45));
    --surface: light-dark(#fff, #121214);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --chip: light-dark(#f3f3f3, transparent);
    --chip-hover: light-dark(#e9e9ea, #1d1d20);
    --chip-ink: light-dark(#17191d, #ececef);
    --chip-edge: light-dark(rgb(17 17 17 / 0.06), rgb(255 255 255 / 0.12));
  }
}
/* 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;
}

.root[data-bare] {
  display: inline-block;
  width: auto;
  min-height: 0;
  aspect-ratio: auto;
  padding: 0;
  overflow: visible;
  border-radius: 0;
  background: none;
  box-shadow: none;
  container-type: normal;
}

/* The sign: the glyph, 10 px, then the word, on a dark pill of its own (or bare of it, with
   plate={false}, over a game that is dark enough already). */
.sign {
  display: flex;
  align-items: center;
  gap: 0.4545em;
  box-sizing: border-box;
  padding: 0.36em 0.95em 0.36em 0.5em;
  border-radius: 999px;
  font-size: var(--save-size, 22px);
  line-height: normal;
  color: var(--word);
  pointer-events: none;
  transition:
    opacity 520ms cubic-bezier(0.4, 0, 0.7, 0.4),
    translate 520ms cubic-bezier(0.4, 0, 0.7, 0.4);
}
.sign[data-plate] {
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    0 0.12em 0.6em var(--lift);
}
.sign:not([data-label]) {
  padding: 0.36em;
}
.sign[data-corner$='right'] {
  flex-direction: row-reverse;
  padding: 0.36em 0.5em 0.36em 0.95em;
}
.sign[data-corner$='right']:not([data-label]) {
  padding: 0.36em;
}
/* On the stage it sits in the middle, drawn larger as the stage grows (40 px past 800 px). */
.root:not([data-bare]) .sign {
  font-size: var(--save-size, clamp(18px, 5cqi, 40px));
}

.sign[data-phase='saving'],
.sign[data-phase='saved'] {
  animation: arrive 260ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
}
.sign[data-phase='leaving'] {
  opacity: 0;
  translate: -0.35em 0;
}
.sign[data-corner$='right'][data-phase='leaving'] {
  translate: 0.35em 0;
}
.sign[data-phase='hidden'] {
  visibility: hidden;
  opacity: 0;
  transition: none;
}

/* The glyph: 40 by 40 at full size, six dots of radius 5, the small ones drawn at 3. */
.glyph {
  flex: none;
  width: 1.818em;
  height: 1.818em;
  overflow: visible;
}
.dot {
  fill: var(--glyph);
  transform-box: fill-box;
  transform-origin: center;
  transform: scale(1);
  transition:
    translate 240ms cubic-bezier(0.5, 0, 0.8, 0.4),
    scale 240ms cubic-bezier(0.5, 0, 0.8, 0.4),
    opacity 240ms;
}
.dot:not([data-large]) {
  transform: scale(0.6);
}
/* The entrance: each dot pops out of nothing, one after the other round the ring. */
.sign[data-phase='saving'] .dot {
  animation:
    pop 360ms calc(var(--i) * 30ms) cubic-bezier(0.3, 1.6, 0.5, 1) backwards,
    breathe-large 600ms ease-in-out infinite;
}
.sign[data-phase='saving'] .dot:not([data-large]) {
  animation-name: pop, breathe-small;
}
.sign[data-phase='saving'][data-motion='spin'] .dot,
.sign[data-phase='saving'][data-motion='spin'] .dot:not([data-large]) {
  animation:
    pop 360ms calc(var(--i) * 30ms) cubic-bezier(0.3, 1.6, 0.5, 1) backwards,
    chase 1200ms calc((6 - var(--i)) * -200ms) ease-in-out infinite;
}
.root[data-paused] .dot {
  animation-play-state: paused;
}
/* Done: the dots gather into the middle and go. */
.sign[data-phase='saved'] .dot,
.sign[data-phase='leaving'] .dot {
  translate: var(--to-x) var(--to-y);
  scale: 0;
  opacity: 0;
}

/* The check: a thick stroke, as thick as a large dot is wide, drawing itself with a pop. */
.check {
  fill: none;
  stroke: var(--glyph);
  stroke-width: 5;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
}
.sign[data-phase='saved'] .check,
.sign[data-phase='leaving'] .check {
  stroke-dashoffset: 0;
  opacity: 1;
  transition:
    stroke-dashoffset 280ms 170ms cubic-bezier(0.3, 0, 0.2, 1),
    opacity 1ms 170ms;
  animation: check-pop 420ms 300ms cubic-bezier(0.3, 1.6, 0.5, 1) backwards;
}

/* The words: both in one cell, so the sign keeps its width; one leaves as the other comes. */
.words {
  display: grid;
}
.word {
  grid-area: 1 / 1;
  font-size: 1em;
  font-style: italic;
  font-weight: 500;
  white-space: nowrap;
  transition:
    opacity 220ms,
    translate 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.word[data-word='saved'] {
  opacity: 0;
  translate: 0 0.3em;
}
.sign[data-phase='saved'] .word[data-word='saving'],
.sign[data-phase='leaving'] .word[data-word='saving'] {
  opacity: 0;
  translate: 0 -0.3em;
}
.sign[data-phase='saved'] .word[data-word='saved'],
.sign[data-phase='leaving'] .word[data-word='saved'] {
  opacity: 1;
  translate: 0 0;
  transition-delay: 120ms;
}

/* The Save game chip, under the sign: a small neutral control of the lab, not of the game. */
.save {
  flex: none;
  min-height: 32px;
  padding: 0 14px;
  border: 1px solid var(--chip-edge);
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-ink);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 150ms,
    opacity 150ms,
    scale 150ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.save:hover {
  background: var(--chip-hover);
}
.save:active {
  scale: 0.95;
}
.save[aria-disabled='true'] {
  opacity: 0.55;
  cursor: default;
  scale: 1;
}
.save:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes arrive {
  from {
    opacity: 0;
  }
}
@keyframes pop {
  from {
    scale: 0;
  }
}
/* The kit's loop: large to small and back, 300 ms each way; the small dots the other way round. */
@keyframes breathe-large {
  50% {
    transform: scale(0.6);
  }
}
@keyframes breathe-small {
  0%,
  100% {
    transform: scale(0.6);
  }
  50% {
    transform: scale(1);
  }
}
/* The chase: each dot swells as the wave reaches it, then rests small. */
@keyframes chase {
  0%,
  100% {
    transform: scale(1);
  }
  33%,
  83% {
    transform: scale(0.6);
  }
}
@keyframes check-pop {
  from {
    scale: 0.7;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sign,
  .dot,
  .word,
  .save {
    transition: opacity 200ms;
    translate: none !important;
  }
  .sign[data-phase] .dot,
  .sign[data-phase] .check,
  .sign[data-phase] {
    animation: none;
  }
  .sign[data-phase='saved'] .check,
  .sign[data-phase='leaving'] .check {
    transition: opacity 200ms;
  }
}
```

### save-indicator/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```