Progress Steps Data
Data Display

Progress Steps Data

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.

Install

npx shadcn@latest add @paragon/progress-steps-data

progress-steps-data.tsx

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