Feedback

Inline status

A one-line autosave status: saving, saved or failed with a retry, announced politely as it changes.

Built on
role=status<button>
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
1.9 kB
Dependencies
React
Edit the title to save
Try its options

Loading its options.

Code
"use client";

import { useEffect, useRef, useState } from "react";
import { InlineStatus, type InlineStatusState } from "@/components/equip/inline-status";

export default function Demo() {
  const [state, setState] = useState<InlineStatusState>("idle");
  const timer = useRef<number>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return (
    <div style={{ display: "grid", gap: 6, width: 208 }}>
      <input
        aria-label="Doc title"
        defaultValue="Q3 planning notes"
        onChange={() => {
          setState("saving");
          window.clearTimeout(timer.current);
          timer.current = window.setTimeout(() => setState("saved"), 900);
        }}
        style={{
          boxSizing: "border-box",
          width: "100%",
          height: 34,
          padding: "0 12px",
          border: "1px solid var(--eq-line-strong)",
          borderRadius: "var(--eq-radius-m)",
          background: "var(--eq-raised)",
          color: "var(--eq-ink)",
          font: "inherit",
          fontSize: "var(--eq-text-m)",
        }}
      />
      <InlineStatus state={state} idleText="Edit the title to save" />
    </div>
  );
}
Your versionchange its options in Look, This part

Examples

Failed, then retry

The retry button sits outside the live region, so only the status text is announced, never the button's label.

Code
"use client";

import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/equip/button";
import { InlineStatus, type InlineStatusState } from "@/components/equip/inline-status";

export default function Example() {
  const [state, setState] = useState<InlineStatusState>("idle");
  const attempts = useRef(0);
  const timer = useRef<number>(undefined);
  const button = useRef<HTMLButtonElement>(null);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  function save() {
    attempts.current += 1;
    // The first attempt fails, as if the connection dropped; the retry goes through.
    const ok = attempts.current > 1;
    setState("saving");
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setState(ok ? "saved" : "failed"), 1100);
  }

  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12 }}>
      <Button ref={button} variant="outline" onClick={save}>Save draft</Button>
      <InlineStatus
        state={state}
        failedText="Couldn’t save. You’re offline."
        onRetry={() => {
          save();
          // The retry button leaves with the failed state, so focus goes back to the save button.
          button.current?.focus();
        }}
      />
    </div>
  );
}

States

Pass the age of the last save as text, such as "Saved 2 min ago"; the part never reads the clock.

No changes yetSaving…SavedSaved 2 min agoCouldn’t save
Code
"use client";

import { InlineStatus } from "@/components/equip/inline-status";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 8, justifyItems: "start" }}>
      <InlineStatus state="idle" idleText="No changes yet" />
      <InlineStatus state="saving" />
      <InlineStatus state="saved" />
      <InlineStatus state="saved" savedText="Saved 2 min ago" />
      <InlineStatus state="failed" onRetry={() => {}} />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
state"idle" | "saving" | "saved" | "failed"noneWhere the save stands. Drive it from your save logic.
savingTextstring"Saving…"Shown while saving.
savedTextstring"Saved"Shown once saved. Pass the age as text, such as Saved 2 min ago.
failedTextstring"Couldn’t save"Shown when the save failed.
idleTextstring""Shown before anything has been saved.
onRetry() => voidnoneAdds a retry button while the state is failed.
retryLabelstring"Retry"Text of the retry button.
...restComponentProps<"span">noneEvery native span attribute, applied to the root.

Anatomy

root.eq-inline-statusA span with data-state.
mark.eq-inline-status-markHidden from assistive tech. Holds the three marks in one cell.
spinner.eq-inline-status-spinnerTurns only while saving.
track.eq-inline-status-trackThe faint circle behind the spinner's arc.
check.eq-inline-status-checkDraws its stroke when saved.
alert.eq-inline-status-alertShown when failed, in the danger tone.
text.eq-inline-status-textThe one role="status" element. Stays mounted; only its text changes.
retry.eq-inline-status-retryOnly when failed with onRetry. Outside the live region.

Keyboard

EnterSpaceRetries the save when the retry button has focus.

Notes

  • One polite live region stays on the page and only its text changes, which is announced more reliably than a region inserted with its text.
  • The retry button sits outside the live region, so its label is not read with every status change.
  • The part never reads the clock; pass Saved 2 min ago as savedText and update it from your own timer.
  • Failed is the danger tone with an alert mark and its own words, so colour is never the only cue.
  • The spinner is the one loop and runs only while saving; under reduced motion it slows to 2.4 seconds instead of stopping.
  • The retry button unmounts as soon as the state leaves failed, which is the moment it is pressed, so move focus back to what was being edited in onRetry.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/inline-status.tsx63 lines
"use client";

import type { ComponentProps } from "react";
import "./inline-status.css";

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

export type InlineStatusProps = ComponentProps<"span"> & {
  /** Where the save stands. Drive it from your save logic. */
  state: InlineStatusState;
  /** Shown while saving. */
  savingText?: string;
  /** Shown once saved, such as "Saved 2 min ago". */
  savedText?: string;
  /** Shown when the save failed. */
  failedText?: string;
  /** Shown before anything has been saved. Empty by default. */
  idleText?: string;
  /** Adds a retry button while failed. */
  onRetry?: () => void;
  /** Text of the retry button. */
  retryLabel?: string;
};

export function InlineStatus({
  state,
  savingText = "Saving…",
  savedText = "Saved",
  failedText = "Couldn’t save",
  idleText = "",
  onRetry,
  retryLabel = "Retry",
  className,
  ...rest
}: InlineStatusProps) {
  const text = { idle: idleText, saving: savingText, saved: savedText, failed: failedText }[state];
  return (
    <span {...rest} className={["eq-inline-status", className].filter(Boolean).join(" ")} data-state={state}>
      <span className="eq-inline-status-mark" aria-hidden="true">
        <svg className="eq-inline-status-spinner" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
          <circle className="eq-inline-status-track" cx="8" cy="8" r="6.25" />
          <circle cx="8" cy="8" r="6.25" pathLength="100" strokeDasharray="28 100" />
        </svg>
        <svg className="eq-inline-status-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
          <path d="m3.5 8.25 2.75 2.75 6-6.5" pathLength="1" />
        </svg>
        <svg className="eq-inline-status-alert" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
          <circle cx="8" cy="8" r="6.25" />
          <path d="M8 4.75v3.75M8 11v.25" />
        </svg>
      </span>
      {/* The one live region. It stays mounted and only its text changes, so every change is announced. */}
      <span className="eq-inline-status-text" role="status">
        {text}
      </span>
      {state === "failed" && onRetry && (
        <button type="button" className="eq-inline-status-retry" onClick={onRetry}>
          {retryLabel}
        </button>
      )}
    </span>
  );
}
components/equip/inline-status.css122 lines
@layer equip {
  .eq-inline-status {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-2);
    min-width: 0;
    min-height: 24px;
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.25 var(--eq-font);
    vertical-align: middle;
    transition: color var(--eq-dur) var(--eq-ease);
  }

  .eq-inline-status[data-state="failed"] {
    color: var(--eq-danger);
  }

  /* The three marks share one 14px cell; only the one for the current state shows. */
  .eq-inline-status-mark {
    display: grid;
    flex: none;
    width: 14px;
    height: 14px;
  }

  .eq-inline-status[data-state="idle"] .eq-inline-status-mark {
    display: none;
  }

  .eq-inline-status-mark > svg {
    grid-area: 1 / 1;
    width: 14px;
    height: 14px;
    opacity: 0;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-inline-status[data-state="saving"] .eq-inline-status-spinner,
  .eq-inline-status[data-state="saved"] .eq-inline-status-check,
  .eq-inline-status[data-state="failed"] .eq-inline-status-alert {
    opacity: 1;
  }

  /* Spinner: turns only while saving. The one motion allowed to loop, copied from Spinner. */
  .eq-inline-status-track {
    opacity: 0.2;
  }

  .eq-inline-status[data-state="saving"] .eq-inline-status-spinner {
    animation: eq-inline-status-spin 700ms linear infinite;
  }

  @keyframes eq-inline-status-spin {
    from {
      rotate: -90deg;
    }

    to {
      rotate: 270deg;
    }
  }

  /* It slows down rather than stopping, because a still arc would read as finished.
     A literal duration, since --eq-dur is 0 here. */
  @media (prefers-reduced-motion: reduce) {
    .eq-inline-status[data-state="saving"] .eq-inline-status-spinner {
      animation-duration: 2.4s;
    }
  }

  /* Saved: the tick draws its stroke. The dash is twice the path so the spring can overshoot. */
  .eq-inline-status-check {
    color: var(--eq-ok);
  }

  .eq-inline-status-check > path {
    stroke-dasharray: 2 3;
    stroke-dashoffset: 2;
    transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
  }

  .eq-inline-status[data-state="saved"] .eq-inline-status-check > path {
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
  }

  .eq-inline-status-text {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .eq-inline-status-retry {
    flex: none;
    height: 24px;
    margin: 0;
    padding-inline: var(--eq-space-2);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-inline-status-retry:hover {
      background: var(--eq-tint);
    }
  }

  .eq-inline-status-retry:active {
    scale: var(--eq-press);
  }

  .eq-inline-status-retry:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index