Skip to content
Seamless UI

Animated Status

Save

An 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-status

Pre-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-run

Usage

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

PropTypeDefaultDescription
status"idle" | "working" | "success" | "error"—Current state of the work.
labelsPartial<Record<AnimatedStatusValue, ReactNode>>—Text for any status. Defaults: Ready, Working…, Done, Failed.
...propsComponentProps<"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.tsxShow
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>
      );
  }
}