UI

Workflow Status

Compact workflow badges with semantic defaults, custom states, icon and colour overrides, two sizes, and an icon-only form.

Props

Size
Icon onlyDrops the label, keeps it in the a11y tree

Installation

Workflow Status is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/workflow-status

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the Components page.

Usage

import { WorkflowStatusBadge } from "@/components/ui/workflow-status";
 
export function DeploymentState() {
  return <WorkflowStatusBadge status="in-progress" />;
}

The component includes seven workflow states: pending, in-progress, submitted, in-review, success, failed, and expired.

Custom labels

Use label when the state needs product-specific language. The semantic colour and icon remain tied to the selected state.

<WorkflowStatusBadge status="in-review" label="Awaiting approval" />

Compact size

Use size="sm" to reduce the badge height, spacing, text, and icon size in dense layouts.

<WorkflowStatusBadge status="in-review" size="sm" />

Icon only

For dense interfaces, render the icon-only form. It works with either size. The state label stays in the accessibility tree as visually hidden text, so screen readers still announce it and no aria-label is needed.

<WorkflowStatusBadge status="success" iconOnly />
<WorkflowStatusBadge status="success" iconOnly size="sm" />

Custom states

Pass any status string and provide an icon and colour classes for product-specific states. If label is omitted, values such as awaiting_payment are displayed as “Awaiting payment”.

import { Ban, PackageCheck } from "lucide-react";
 
<WorkflowStatusBadge
  status="queued"
  label="Queued for release"
  icon={PackageCheck}
  colorClassName="bg-cyan-500/12 text-cyan-700 dark:text-cyan-300"
/>
 
<WorkflowStatusBadge
  status="cancelled"
  icon={Ban}
  colorClassName="bg-orange-500/12 text-orange-700 dark:text-orange-300"
/>

The exported workflowStatusPresentations map is available when filters, menus, or other UI need to reuse the built-in presentation metadata.

Status labels

getWorkflowStatusLabel returns the default or inferred label for a state, so filters and legends beside the badge can reuse the same copy instead of duplicating it.

import { getWorkflowStatusLabel, workflowStatuses } from "@/components/ui/workflow-status";
 
const options = workflowStatuses.map((status) => ({
  value: status,
  label: getWorkflowStatusLabel(status),
}));

Props

PropTypeDefaultNotes
status RequiredWorkflowStatusValue-Selects a built-in state or identifies a custom state.
labelstringstate labelReplaces the visible and accessible label.
iconLucideIconstate iconReplaces the built-in icon. Custom states fall back to CircleDashed.
colorClassNamestringstate coloursReplaces the badge background and foreground colour classes.
iconClassNamestringAdds classes to the icon without changing the badge.
size"sm" | "default""default"Reduces the badge and icon footprint when set to "sm".
iconOnlybooleanfalseRenders a circular badge without visible text.
classNamestringExtra classes for placement and spacing.

Notes & features

  • Semantic states. Every built-in state pairs a distinct icon and colour, so users do not need to decode colour alone.
  • No wrapper. The component only renders the badge. Use a list, table, or card around it according to your layout.
  • Accessible by default. The icon is decorative and the visible label carries the state. Icon-only badges keep that label as visually hidden text rather than an aria-label, which is not reliably exposed on a generic element.
  • Custom states. Use any status string with icon and colorClassName, or reuse and extend the exported workflowStatusPresentations map.
  • Predictable overrides. Colour overrides are explicit through colorClassName; layout overrides remain in className. Both merge through tailwind-merge.
  • Reduced motion. Only the in-progress icon spins. Operating-system reduced-motion preferences stop the browser animation.

Manual installation

Rather not use the CLI? Everything Workflow Status needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install lucide-react clsx tailwind-merge

Source

components/ui/workflow-status.tsx169 lines
import { clsx, type ClassValue } from "clsx";
import {
  AlertTriangle,
  CheckCircle2,
  CircleDashed,
  CircleX,
  Clock3,
  ScanSearch,
  Send,
  type LucideIcon
} from "lucide-react";
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";

export const workflowStatuses = [
  "pending",
  "in-progress",
  "submitted",
  "in-review",
  "success",
  "failed",
  "expired",
] as const;

export type WorkflowStatus = (typeof workflowStatuses)[number];
export type WorkflowStatusValue = WorkflowStatus | (string & {});
export type WorkflowStatusBadgeSize = "sm" | "default";

export interface WorkflowStatusBadgeProps extends ComponentProps<"span"> {
  status: WorkflowStatusValue;
  /** Overrides the default or inferred label for the selected state. */
  label?: string;
  /** Replaces the state's default icon. */
  icon?: LucideIcon;
  /** Replaces the state's background and foreground colour classes. */
  colorClassName?: string;
  /** Adds classes to the icon. */
  iconClassName?: string;
  /** Reduces the badge footprint for dense interfaces. */
  size?: WorkflowStatusBadgeSize;
  /**
   * Shows the icon-only form. The state label stays in the accessibility tree
   * as visually hidden text, so no aria-label is required.
   */
  iconOnly?: boolean;
}

type StatusPresentation = {
  label: string;
  icon: LucideIcon;
  className: string;
  iconClassName?: string;
};

export const workflowStatusPresentations: Record<
  WorkflowStatus,
  StatusPresentation
> = {
  pending: {
    label: "Pending",
    icon: AlertTriangle,
    className: "bg-amber-500/12 text-amber-700 dark:text-amber-300",
  },
  "in-progress": {
    label: "In progress",
    icon: CircleDashed,
    className: "bg-sky-500/12 text-sky-700 dark:text-sky-300",
  },
  submitted: {
    label: "Submitted",
    icon: Send,
    className: "bg-violet-500/12 text-violet-700 dark:text-violet-300",
  },
  "in-review": {
    label: "In review",
    icon: ScanSearch,
    className: "bg-yellow-500/12 text-yellow-700 dark:text-yellow-300",
  },
  success: {
    label: "Success",
    icon: CheckCircle2,
    className: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
  },
  failed: {
    label: "Failed",
    icon: CircleX,
    className: "bg-rose-500/12 text-rose-700 dark:text-rose-300",
  },
  expired: {
    label: "Expired",
    icon: Clock3,
    className: "bg-zinc-500/12 text-zinc-700 dark:text-zinc-300",
  },
};

/** Default label for a state. Useful for filters and legends built alongside the badge. */
export function getWorkflowStatusLabel(status: WorkflowStatusValue): string {
  return isWorkflowStatus(status)
    ? workflowStatusPresentations[status].label
    : humanizeStatus(status);
}

function isWorkflowStatus(status: WorkflowStatusValue): status is WorkflowStatus {
  return status in workflowStatusPresentations;
}

function humanizeStatus(status: string) {
  const label = status.trim().replace(/[-_]+/g, " ");
  return label ? label.charAt(0).toUpperCase() + label.slice(1) : "Status";
}

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

/**
 * A compact semantic label for workflow states. Each state has an icon, colour,
 * and accessible text without requiring a separate icon library or stylesheet.
 */
export function WorkflowStatusBadge({
  status,
  label,
  icon,
  colorClassName,
  iconClassName,
  size = "default",
  iconOnly = false,
  className,
  ...props
}: WorkflowStatusBadgeProps) {
  const presentation = isWorkflowStatus(status)
    ? workflowStatusPresentations[status]
    : undefined;
  const Icon = icon ?? presentation?.icon ?? CircleDashed;
  const resolvedLabel = label ?? presentation?.label ?? humanizeStatus(status);

  return (
    <span
      data-status={status}
      className={cn(
        "inline-flex shrink-0 items-center justify-center font-medium tracking-tight",
        iconOnly
          ? size === "sm"
            ? "size-6 rounded-full"
            : "size-8 rounded-full"
          : size === "sm"
            ? "min-h-6 gap-1 rounded-lg px-2 py-1 text-xs"
            : "min-h-8 gap-1.5 rounded-xl px-3 py-1.5 text-sm",
        colorClassName ??
          presentation?.className ??
          "bg-muted text-muted-foreground",
        className,
      )}
      {...props}
    >
      <Icon
        aria-hidden="true"
        strokeWidth={2.25}
        className={cn(
          "shrink-0 motion-reduce:animate-none",
          size === "sm" ? "size-3.5" : "size-4.5",
          presentation?.iconClassName,
          iconClassName,
        )}
      />
      {iconOnly ? <span className="sr-only">{resolvedLabel}</span> : resolvedLabel}
    </span>
  );
}