Save indicator
NewA corner sign that says the game is saving: a ring of breathing dots, then a check, then gone.
save-indicator/save-indicator.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- 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:
npx shadcn@latest add https://brunodelorence.com/r/save-indicator.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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 | 'Saving' | The word beside the ring. |
doneLabel | string | 'Saved' | The word beside the check. |
showLabel | boolean | true | The 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. |
plate | boolean | 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 | 1200 | The least time the ring shows, however soon saved comes. |
holdMs | number | 1400 | How long the check stays before the sign fades out. |
bare | boolean | false | The sign alone, without the stage and its Save game chip, to lay over a game of your own. |
autoplay | boolean | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, 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 (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;// 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.