Animated Status
SaveAn inline status for async work whose icon and label transition between idle, working, success, and error.
- Category
- Core
- Version
- 0.1.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Animated Status preview
Ready
Installation
npx @seamlessui/cli add animated-statusPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add animated-status --dry-runUsage
animated-status-example.tsx
import { AnimatedStatus } from "@/components/seamless/ui/animated-status";
export default function AnimatedStatusExample() {
return (
<div className="grid gap-2">
<AnimatedStatus status="idle" />
<AnimatedStatus status="working" labels={{ working: "Saving…" }} />
<AnimatedStatus status="success" labels={{ success: "Saved" }} />
<AnimatedStatus status="error" labels={{ error: "Couldn’t save" }} />
</div>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "idle" | "working" | "success" | "error" | — | Current state of the work. |
| labels | Partial<Record<AnimatedStatusValue, ReactNode>> | — | Text for any status. Defaults: Ready, Working…, Done, Failed. |
| ...props | ComponentProps<"span"> | — | Any native span attribute except role and children. |
Accessibility
- The wrapper has role=status, so screen readers politely announce the new label each time the status changes.
- aria-busy is set while the status is working.
- Every status has its own text label and its own icon shape; color is never the only signal.
- Icons are decorative (aria-hidden); the label carries the meaning.
- With reduced motion, the label swaps without fading or sliding and the working spinner stops rotating.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/animated-status.tsxanimated-status- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/animated-status/0.1.0.json.
ui/animated-status.tsxShowHide
ui/animated-status.tsx
import type { ComponentProps, ReactNode } from "react";
export type AnimatedStatusValue = "idle" | "working" | "success" | "error";
const defaultLabels: Record<AnimatedStatusValue, string> = {
idle: "Ready",
working: "Working…",
success: "Done",
error: "Failed",
};
const tones: Record<AnimatedStatusValue, string> = {
idle: "text-zinc-600 dark:text-zinc-400",
working: "text-sky-700 dark:text-sky-300",
success: "text-emerald-700 dark:text-emerald-300",
error: "text-red-700 dark:text-red-400",
};
export interface AnimatedStatusProps extends Omit<ComponentProps<"span">, "children" | "role"> {
status: AnimatedStatusValue;
/** Overrides the text shown for any status. Defaults: Ready, Working…, Done, Failed. */
labels?: Partial<Record<AnimatedStatusValue, ReactNode>>;
}
/**
* An inline status for async work. The icon and label swap with a short CSS fade and slide
* when `status` changes; screen readers announce the new label.
*/
export function AnimatedStatus({ status, labels, className, ...props }: AnimatedStatusProps) {
const label = labels?.[status] ?? defaultLabels[status];
return (
<span
role="status"
aria-busy={status === "working" || undefined}
data-status={status}
className={["inline-flex items-center text-sm font-medium", tones[status], className].filter(Boolean).join(" ")}
{...props}
>
{/* Keyed by status so each change remounts the content and replays the @starting-style entry. */}
<span
key={status}
className={[
"inline-flex items-center gap-1.5 whitespace-nowrap",
"transition-[opacity,translate] duration-200 ease-out starting:translate-y-1 starting:opacity-0",
"motion-reduce:transition-none",
].join(" ")}
>
<StatusIcon status={status} />
<span>{label}</span>
</span>
</span>
);
}
function StatusIcon({ status }: { status: AnimatedStatusValue }) {
const common = { "aria-hidden": true, viewBox: "0 0 16 16", fill: "none", className: "size-4 shrink-0" } as const;
switch (status) {
case "idle":
return (
<svg {...common}>
<circle cx="8" cy="8" r="5.25" stroke="currentColor" strokeWidth="1.5" strokeDasharray="2 2.12" />
</svg>
);
case "working":
return (
<svg {...common} className="size-4 shrink-0 motion-safe:animate-spin">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
case "success":
return (
<svg {...common}>
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />
<path d="m5.5 8.25 1.75 1.75 3.25-3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
case "error":
return (
<svg {...common}>
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="11" r="0.875" fill="currentColor" />
</svg>
);
}
}