A build-test-deploy-verify pipeline with per-stage status, a live progress bar, and a shimmer on the running stage.
npx shadcn@latest add @paragon/deployment-pipeline"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>
</>
);
}