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
| Prop | Type | Default | What it does |
|---|---|---|---|
state | "idle" | "saving" | "saved" | "failed" | none | Where the save stands. Drive it from your save logic. |
savingText | string | "Saving…" | Shown while saving. |
savedText | string | "Saved" | Shown once saved. Pass the age as text, such as Saved 2 min ago. |
failedText | string | "Couldn’t save" | Shown when the save failed. |
idleText | string | "" | Shown before anything has been saved. |
onRetry | () => void | none | Adds a retry button while the state is failed. |
retryLabel | string | "Retry" | Text of the retry button. |
...rest | ComponentProps<"span"> | none | Every native span attribute, applied to the root. |
Anatomy
| root | .eq-inline-status | A span with data-state. |
|---|---|---|
| mark | .eq-inline-status-mark | Hidden from assistive tech. Holds the three marks in one cell. |
| spinner | .eq-inline-status-spinner | Turns only while saving. |
| track | .eq-inline-status-track | The faint circle behind the spinner's arc. |
| check | .eq-inline-status-check | Draws its stroke when saved. |
| alert | .eq-inline-status-alert | Shown when failed, in the danger tone. |
| text | .eq-inline-status-text | The one role="status" element. Stays mounted; only its text changes. |
| retry | .eq-inline-status-retry | Only when failed with onRetry. Outside the live region. |
Keyboard
| EnterSpace | Retries 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;
}
}