A pipeline stepper for imports and syncs with live tabular record counts, connectors that fill to the running stage's fraction, spring icon swaps on completion or failure, and an offscreen-paused spinner arc.
npx shadcn@latest add @paragon/progress-steps-data"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type DataStepStatus = "pending" | "active" | "complete" | "error";
export interface DataStep {
id: string;
label: string;
status: DataStepStatus;
/** Records processed so far — rendered tabular under the label. */
count?: number;
/** Expected total. Drives the active connector's partial fill. */
total?: number;
/** One-line result under the count, e.g. "78 duplicates dropped". */
detail?: string;
}
export interface ProgressStepsDataProps
extends Omit<React.ComponentProps<"ol">, "children"> {
steps: DataStep[];
orientation?: "horizontal" | "vertical";
formatCount?: (count: number) => string;
/** Disables the spinner and fill/swap motion. */
static?: boolean;
}
const spinnerStyles = `
@keyframes pgsd-spin { to { rotate: 1turn; } }
@media (prefers-reduced-motion: reduce) {
.pgsd-spinner { animation: none; }
}
`;
/**
* A pipeline stepper for data jobs — imports, syncs, migrations — where
* each stage carries a live record count. Discs swap icons with the house
* spring crossfade as stages complete (or fail), the connector after the
* running stage fills linearly to `count / total` so progress reads at a
* glance, and stages behind an error stay pending. The running disc's
* spinner arc pauses offscreen and under prefers-reduced-motion. Counts
* are `aria-live` so screen readers hear the run advance.
*/
export function ProgressStepsData({
steps,
orientation = "horizontal",
formatCount = (count) => count.toLocaleString("en-US"),
static: isStatic = false,
className,
...props
}: ProgressStepsDataProps) {
const ref = React.useRef<HTMLOListElement>(null);
const inView = useInView(ref);
const reducedMotion = useReducedMotion() ?? false;
const noMotion = reducedMotion || isStatic;
const horizontal = orientation === "horizontal";
const fillAfter = (step: DataStep): number => {
if (step.status === "complete") return 1;
if (step.status === "active" && step.total)
return Math.min(1, Math.max(0, (step.count ?? 0) / step.total));
return 0;
};
return (
<>
<style href="paragon-progress-steps-data" precedence="paragon">
{spinnerStyles}
</style>
<ol
ref={ref}
data-slot="progress-steps-data"
data-orientation={orientation}
className={cn(
"w-full",
horizontal ? "flex items-start" : "flex flex-col",
className,
)}
{...props}
>
{steps.map((step, index) => {
const last = index === steps.length - 1;
const fraction = fillAfter(step);
const disc = (
<span
className={cn(
"relative flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-medium",
"transition-[background-color,border-color,color] duration-(--duration-base) ease-(--ease-out)",
step.status === "complete" &&
"bg-primary text-primary-foreground",
step.status === "error" &&
"bg-destructive text-destructive-foreground",
step.status === "active" &&
"border border-primary/40 bg-card text-foreground",
step.status === "pending" &&
"border bg-card text-muted-foreground",
)}
>
{/* Spinner arc while running — pauses offscreen. */}
{step.status === "active" && !isStatic && (
<svg
aria-hidden
viewBox="0 0 28 28"
className="pgsd-spinner pointer-events-none absolute inset-0 size-full animate-[pgsd-spin_1s_linear_infinite]"
style={{ animationPlayState: inView ? "running" : "paused" }}
>
<circle
cx="14"
cy="14"
r="13"
fill="none"
stroke="var(--color-primary)"
strokeWidth="1.5"
strokeLinecap="round"
strokeDasharray="20 62"
/>
</svg>
)}
{noMotion ? (
<DiscGlyph step={step} index={index} />
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={step.status}
className="flex items-center justify-center"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<DiscGlyph step={step} index={index} />
</motion.span>
</AnimatePresence>
)}
</span>
);
const text = (
<span className="flex min-w-0 flex-col gap-0.5">
<span
className={cn(
"truncate text-[13px] font-medium",
"transition-colors duration-(--duration-base)",
step.status === "pending"
? "text-muted-foreground"
: step.status === "error"
? "text-destructive"
: "text-foreground",
)}
title={step.label}
>
{step.label}
</span>
{step.count !== undefined && (
<span
aria-live={step.status === "active" ? "polite" : undefined}
className="truncate text-[11px] text-muted-foreground tabular-nums"
>
{formatCount(step.count)}
{step.total !== undefined &&
step.status !== "complete" &&
` of ${formatCount(step.total)}`}
<span className="sr-only"> records, {step.status}</span>
</span>
)}
{step.detail && (
<span
className={cn(
"truncate text-[11px]",
step.status === "error"
? "text-destructive"
: "text-muted-foreground/80",
)}
title={step.detail}
>
{step.detail}
</span>
)}
</span>
);
const connector = !last && (
<span
aria-hidden
className={cn(
"overflow-hidden rounded-full bg-border",
// The track fills the gap between adjacent discs (horizontal:
// sits inline beside the disc on the node row with a symmetric
// 4px inset so it visibly meets both checkmark circles without
// piercing them; vertical: a thin hairline off the disc center).
horizontal ? "mx-1 h-0.5 flex-1" : "my-1 ml-[13px] w-0.5 flex-1",
)}
>
<span
className={cn(
"block size-full",
step.status === "error" ? "bg-destructive" : "bg-primary",
horizontal ? "origin-left" : "origin-top",
)}
style={{
transform: horizontal
? `scaleX(${step.status === "error" ? 1 : fraction})`
: `scaleY(${step.status === "error" ? 1 : fraction})`,
// Linear is correct here: constant-rate progress motion.
transition: noMotion
? undefined
: step.status === "active"
? "transform 300ms linear"
: "transform 300ms var(--ease-out)",
}}
/>
</span>
);
return horizontal ? (
<li
key={step.id}
aria-current={step.status === "active" ? "step" : undefined}
className={cn("flex min-w-0 flex-col gap-2", last ? "flex-none" : "flex-1")}
>
{/* Node row: the disc and the connector share one centered track,
so the line runs from this disc's edge to the next disc's edge
and visibly meets both checkmark circles. */}
<span className="flex items-center">
{disc}
{connector}
</span>
{/* Label sits beneath the disc, not on the connector track. */}
<span className="min-w-0 pr-3">{text}</span>
</li>
) : (
<li
key={step.id}
aria-current={step.status === "active" ? "step" : undefined}
className="flex min-w-0 gap-3"
>
<span className={cn("flex flex-col", !last && "min-h-14")}>
{disc}
{connector}
</span>
<span className="min-w-0 flex-1 pt-1 pb-5">{text}</span>
</li>
);
})}
</ol>
</>
);
}
function DiscGlyph({ step, index }: { step: DataStep; index: number }) {
if (step.status === "complete")
return (
<>
<Check aria-hidden className="size-3.5" strokeWidth={2.5} />
<span className="sr-only">{step.label} complete</span>
</>
);
if (step.status === "error")
return (
<>
<X aria-hidden className="size-3.5" strokeWidth={2.5} />
<span className="sr-only">{step.label} failed</span>
</>
);
return <span aria-hidden>{index + 1}</span>;
}