Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Save indicator

New

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

save-indicator/save-indicator.tsx
Live · interactive

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

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

You can add this component with the shadcn CLI or by hand:

Into components/save-indicator of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/save-indicator.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

checkpoint.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')} />;}

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.

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.

  • 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.
PropTypeDefaultDescription
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.
labelstring'Saving'The word beside the ring.
doneLabelstring'Saved'The word beside the check.
showLabelbooleantrueThe word beside the glyph; without it, the glyph alone (screen readers still hear it).
motion'pulse' | 'spin''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''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.
platebooleantrueThe sign’s own dark pill, so it reads on light and dark; leave it out over a game that is dark enough.
minSavingMsnumber1200The least time the ring shows, however soon saved comes.
holdMsnumber1400How long the check stays before the sign fades out.
barebooleanfalseThe sign alone, without the stage and its Save game chip, to lay over a game of your own.
autoplaybooleantrueWhile 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.
classNamestring—Classes for the root element.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

save-indicator.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
save-indicator.module.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;
use-autoplay.ts
// 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);
  • 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.