Workflow Status

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

With labels

Pending
In progress
Submitted
In review
Success
Failed
Expired

Icon only

Pending
In progress
Submitted
In review
Success
Failed
Expired

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
statusWorkflowStatusValuerequiredSelects 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

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>
  );
}