Skip to content

SaveStatus

MD, under 1950B gzipped

The autosave indicator, announced — and distinguishing 'failed' from 'offline'.

Category
Feedback & status
Budget
md, several states or a live subscription
Runtime deps
None

Preview

Preview language

All changes saved

This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.

Install

npx shadcn@latest add https://kata-ui-rho.vercel.app/r/save-status.json

Copies the source into your project. Pulls in 5 primitives: cn, announce, use-locale, locale, use-network.

Source

"use client";

import { useEffect, useRef } from "react";
import { announce } from "../lib/announce";
import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";
import { useNetwork } from "../lib/use-network";

export type SaveState = "idle" | "saving" | "saved" | "failed";

export interface SaveStatusLabels {
  saving: string;
  saved: string;
  failed: string;
  /** Shown when a save failed and the device is offline. */
  offline: string;
}

const DEFAULT_LABELS: SaveStatusLabels = {
  saving: "Saving…",
  saved: "All changes saved",
  failed: "Couldn't save your changes",
  offline: "Offline — your changes are saved on this device",
};

export interface SaveStatusProps {
  state: SaveState;
  labels?: Partial<SaveStatusLabels>;
  className?: string;
}

/**
 * The autosave indicator, said out loud.
 *
 * Autosave UI is usually a small grey word that changes silently in a corner —
 * invisible to a screen reader, and invisible to anyone not watching that
 * corner. This announces each transition, and distinguishes "we could not
 * save" from "you are offline and it is being kept locally", because those ask
 * the user for completely different reactions.
 *
 * `saving` is deliberately not announced: on a slow connection it fires
 * constantly, and interrupting someone mid-sentence to say "Saving…" while
 * they type is worse than saying nothing.
 */
export function SaveStatus({ state, labels: labelOverrides, className }: SaveStatusProps) {
  const labels = { ...DEFAULT_LABELS, ...labelOverrides };
  const { online } = useNetwork();
  const { direction } = useLocale();
  const previous = useRef<SaveState | null>(null);

  const message =
    state === "saving"
      ? labels.saving
      : state === "saved"
        ? labels.saved
        : state === "failed"
          ? online
            ? labels.failed
            : labels.offline
          : null;

  useEffect(() => {
    if (previous.current === state) return;

    const wasFirst = previous.current === null;
    previous.current = state;
    if (wasFirst || state === "idle" || state === "saving") return;

    announce(
      state === "saved" ? labels.saved : online ? labels.failed : labels.offline,
      state === "failed" ? "assertive" : "polite",
    );
  }, [state, online, labels.saved, labels.failed, labels.offline]);

  return (
    // Mounted even when empty: a live region created at the same moment its
    // text appears is not announced by most screen readers.
    <p
      role="status"
      aria-live="polite"
      dir={direction}
      className={cn(
        "text-start text-sm",
        state === "failed" ? "text-red-700 dark:text-red-400" : "text-neutral-600 dark:text-neutral-400",
        className,
      )}
    >
      {message}
    </p>
  );
}

What CI checks

← All components