Infrastructure & DevOps

Deployment Pipeline

A build-test-deploy-verify pipeline with per-stage status, a live progress bar, and a shimmer on the running stage.

Install

npx shadcn@latest add @paragon/deployment-pipeline

deployment-pipeline.tsx

"use client";

import * as React from "react";
import { Check, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type StageStatus = "done" | "running" | "pending" | "failed";

export interface PipelineStage {
  name: string;
  status: StageStatus;
  /** 0–1, only meaningful while running. */
  progress?: number;
  /** e.g. "1m 12s" */
  duration?: string;
}

export interface DeploymentPipelineProps extends React.ComponentProps<"div"> {
  stages?: PipelineStage[];
}

const DEFAULT_STAGES: PipelineStage[] = [
  { name: "Build", status: "done", duration: "48s" },
  { name: "Test", status: "done", duration: "2m 05s" },
  { name: "Deploy", status: "running", progress: 0.62, duration: "1m 12s" },
  { name: "Verify", status: "pending" },
];

const STYLE = `
@keyframes paragon-pipeline-shimmer {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(200%); }
}
@media (prefers-reduced-motion: reduce) {
  [data-pipeline-shimmer] { animation: none !important; opacity: 0.4; }
}
`;

function StatusIcon({ status }: { status: StageStatus }) {
  if (status === "done")
    return <Check className="size-3.5 text-success-foreground" />;
  if (status === "failed")
    return <X className="size-3.5 text-destructive-foreground" />;
  if (status === "running")
    return (
      <Loader2 className="size-3.5 animate-spin text-primary-foreground motion-reduce:animate-none" />
    );
  return <span className="size-1.5 rounded-full bg-muted-foreground" />;
}

function iconBg(status: StageStatus) {
  switch (status) {
    case "done":
      return "var(--color-success)";
    case "failed":
      return "var(--color-destructive)";
    case "running":
      return "var(--color-primary)";
    default:
      return "var(--color-secondary)";
  }
}

export function DeploymentPipeline({
  stages = DEFAULT_STAGES,
  className,
  ...props
}: DeploymentPipelineProps) {
  return (
    <>
      <style href="paragon-deployment-pipeline" precedence="paragon">
        {STYLE}
      </style>
      <div
        className={cn(
          "w-full max-w-xl rounded-xl bg-card p-4 text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div className="flex flex-col gap-1">
          {stages.map((s, i) => {
            const running = s.status === "running";
            const pct = Math.round((s.progress ?? 0) * 100);
            const last = i === stages.length - 1;
            return (
              <div key={s.name} className="relative flex gap-3">
                {/* rail */}
                <div className="flex flex-col items-center">
                  <span
                    className="flex size-6 items-center justify-center rounded-full transition-colors duration-200 ease-out"
                    style={{ background: iconBg(s.status) }}
                  >
                    <StatusIcon status={s.status} />
                  </span>
                  {!last && (
                    <span
                      aria-hidden
                      className="w-px flex-1 transition-colors duration-200"
                      style={{
                        background:
                          s.status === "done"
                            ? "var(--color-success)"
                            : "var(--color-border)",
                        minHeight: 20,
                      }}
                    />
                  )}
                </div>
                <div className={cn("min-w-0 flex-1", last ? "pb-0" : "pb-4")}>
                  <div className="flex items-center justify-between gap-2">
                    <span
                      className={cn(
                        "text-sm font-medium",
                        s.status === "pending" && "text-muted-foreground",
                      )}
                    >
                      {s.name}
                    </span>
                    <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
                      {running ? `${pct}%` : (s.duration ?? "—")}
                    </span>
                  </div>
                  {running && (
                    <div className="relative mt-1.5 h-1 overflow-hidden rounded-full bg-secondary">
                      <div
                        className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
                        style={{ width: `${pct}%` }}
                      />
                      <div
                        aria-hidden
                        data-pipeline-shimmer
                        className="pointer-events-none absolute inset-y-0 left-0 w-1/3"
                        style={{
                          background:
                            "linear-gradient(90deg, transparent, color-mix(in oklch, var(--color-primary-foreground) 55%, transparent), transparent)",
                          animation:
                            "paragon-pipeline-shimmer 1.6s linear infinite",
                        }}
                      />
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
}