A fulfillment rail from placed to delivered where completed legs fill and the active leg shimmers in progress.
npx shadcn@latest add @paragon/order-status-trackerAlso installs: tooltip
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check, Package, Home, Truck, MapPin, ShoppingBag } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
export interface OrderStage {
id: string;
label: string;
/** Optional caption, e.g. a date or note. */
caption?: string;
icon?: LucideIcon;
}
export interface OrderStatusTrackerProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
stages?: OrderStage[];
/** Index of the current stage (0-based). Stages before it are complete. */
current?: number;
orientation?: "horizontal" | "vertical";
/** Opt out of enter + shimmer + pulse motion. */
static?: boolean;
}
const DEFAULT_STAGES: OrderStage[] = [
{ id: "placed", label: "Placed", icon: ShoppingBag },
{ id: "packed", label: "Packed", icon: Package },
{ id: "shipped", label: "Shipped", icon: Truck },
{ id: "out", label: "Out for delivery", icon: MapPin },
{ id: "delivered", label: "Delivered", icon: Home },
];
// Node geometry drives the shared coordinate scale used by both the SVG
// connector track and the CSS-grid label row, so lines land on node centers.
const NODE = 32; // node diameter (px)
/**
* A fulfillment rail. Node centers sit on a shared scale: the connector is one
* SVG line whose segment boundaries are the computed node centers, so completed
* / active / pending fills land exactly between nodes — never eyeballed widths.
* Completed legs fill solid; the active leg fills to a midpoint and carries a
* traveling shimmer to read as "in progress"; upcoming legs stay muted. The
* active node pulses once on mount, nodes reveal with the house rise, and each
* step is a hover/focus tooltip. Reduced motion (or `static`) drops the
* shimmer, pulse and rise but keeps the fill and color states.
*/
export function OrderStatusTracker({
stages = DEFAULT_STAGES,
current = 0,
orientation = "horizontal",
static: isStatic = false,
className,
...props
}: OrderStatusTrackerProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const vertical = orientation === "vertical";
if (stages.length === 0) {
return (
<div
data-slot="order-status-tracker"
className={cn(
"flex w-full items-center justify-center rounded-xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground",
className,
)}
>
No fulfillment stages to show.
</div>
);
}
const n = stages.length;
// Fraction of the active leg that reads as "done". The remainder shimmers.
const activeFill = 0.55;
// Overall fill in leg units (0..n-1), computed from a single scale.
const filledLegs = armed ? Math.min(current + activeFill, n - 1) : 0;
const styleBlock = (
<style href="paragon-order-status-tracker" precedence="paragon">{`
@keyframes pg-ost-shimmer-x { to { transform: translateX(300%); } }
@keyframes pg-ost-shimmer-y { to { transform: translateY(300%); } }
@keyframes pg-ost-rise {
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@keyframes pg-ost-pulse {
0% { box-shadow: 0 0 0 0 var(--color-primary); }
70% { box-shadow: 0 0 0 7px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
@media (prefers-reduced-motion: reduce) {
[data-ost-shimmer], [data-ost-node], [data-ost-item] {
animation: none !important;
}
}
`}</style>
);
const Node = ({ stage, i }: { stage: OrderStage; i: number }) => {
const complete = i < current;
const active = i === current;
const Icon = stage.icon ?? ShoppingBag;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-ost-node={active ? "active" : undefined}
aria-current={active ? "step" : undefined}
aria-label={`${stage.label}${
complete
? " — complete"
: active
? " — in progress"
: " — upcoming"
}`}
className={cn(
"pressable relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full outline-none transition-[background-color,color] duration-(--duration-base) ease-out",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
complete || active
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground",
)}
style={
active && animate
? {
animation: `pg-ost-pulse 1.6s var(--ease-out) 1`,
animationDelay: `${n * 70}ms`,
}
: undefined
}
>
{complete ? (
<Check aria-hidden className="size-4" strokeWidth={2.5} />
) : (
<Icon aria-hidden className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side={vertical ? "right" : "top"}>
<span className="font-medium">{stage.label}</span>
{stage.caption && (
<span className="ml-1.5 tabular-nums text-primary-foreground/70">
{stage.caption}
</span>
)}
</TooltipContent>
</Tooltip>
);
};
// ── Vertical: nodes in a column, computed-scale fill on each connector ──
if (vertical) {
return (
<TooltipProvider>
{styleBlock}
<div
ref={ref}
data-slot="order-status-tracker"
className={cn("flex w-full flex-col", className)}
{...props}
>
<ol className="flex flex-col">
{stages.map((stage, i) => {
const isLast = i === n - 1;
const legFill = Math.max(0, Math.min(1, filledLegs - i));
const legActive = i === current && !isLast && armed && animate;
return (
<li
key={stage.id}
data-ost-item
className="flex min-w-0 gap-3"
style={
animate
? {
animation: `pg-ost-rise var(--duration-base) var(--ease-out) both`,
animationDelay: `${i * 70}ms`,
}
: undefined
}
>
<div className="flex flex-col items-center">
<Node stage={stage} i={i} />
{!isLast && (
<span
aria-hidden
className="relative my-1 w-0.5 flex-1 overflow-hidden rounded-full bg-border"
style={{ minHeight: NODE }}
>
<span
className="absolute inset-0 bg-primary transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{
transform: `scaleY(${legFill})`,
transformOrigin: "top",
}}
/>
{legActive && (
<span
data-ost-shimmer
className="pointer-events-none absolute inset-x-0 top-0 h-1/2"
style={{
background:
"linear-gradient(180deg, transparent, color-mix(in oklch, var(--color-primary), white 60%), transparent)",
animation: `pg-ost-shimmer-y 1.4s var(--ease-in-out) infinite`,
transform: "translateY(-100%)",
}}
/>
)}
</span>
)}
</div>
<div className="min-w-0 pb-5 pt-1">
<p
className={cn(
"truncate text-xs font-medium leading-tight",
i <= current
? "text-foreground"
: "text-muted-foreground",
)}
>
{stage.label}
</p>
{stage.caption && (
<p className="mt-0.5 truncate text-[11px] tabular-nums text-muted-foreground">
{stage.caption}
</p>
)}
</div>
</li>
);
})}
</ol>
</div>
</TooltipProvider>
);
}
// ── Horizontal: computed-scale track + CSS-grid node/label rows aligned ──
// Each of the n columns is 1fr; a node is centered in its column, so node
// centers sit at (i + 0.5)/n of the track width. Connector segments span
// consecutive node centers, derived from that same fractional scale — so
// every segment starts and ends exactly on a node center. Fills are scaleX
// transforms only (compositor-safe); nothing animates width/left.
const centerPct = (i: number) => ((i + 0.5) / n) * 100;
return (
<TooltipProvider>
{styleBlock}
<div
ref={ref}
data-slot="order-status-tracker"
className={cn("w-full", className)}
{...props}
>
{/* Connector track: HTML segments positioned on the shared node scale */}
<div className="relative w-full" style={{ height: NODE }} aria-hidden>
{stages.slice(0, -1).map((stage, i) => {
const left = centerPct(i);
const width = centerPct(i + 1) - left;
const legFill = Math.max(0, Math.min(1, filledLegs - i));
const legActive = i === current && armed && animate;
return (
<span
key={stage.id}
className="absolute overflow-hidden rounded-full bg-border"
style={{
left: `${left}%`,
width: `${width}%`,
top: NODE / 2 - 1,
height: 2,
}}
>
<span
className="absolute inset-0 origin-left bg-primary transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{ transform: `scaleX(${legFill})` }}
/>
{legActive && (
<span
data-ost-shimmer
className="absolute inset-y-0 w-1/3"
style={{
background:
"linear-gradient(90deg, transparent, color-mix(in oklch, var(--color-primary), white 60%), transparent)",
animation: `pg-ost-shimmer-x 1.4s var(--ease-in-out) infinite`,
transform: "translateX(-100%)",
}}
/>
)}
</span>
);
})}
{/* Nodes on the same 1fr grid, centered in each column */}
<ol
className="absolute inset-0 grid"
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
>
{stages.map((stage, i) => (
<li
key={stage.id}
data-ost-item
className="flex items-center justify-center"
style={
animate
? {
animation: `pg-ost-rise var(--duration-base) var(--ease-out) both`,
animationDelay: `${i * 70}ms`,
}
: undefined
}
>
<Node stage={stage} i={i} />
</li>
))}
</ol>
</div>
{/* Label row on the identical 1fr grid — centered under each node */}
<div
className="mt-2 grid"
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
>
{stages.map((stage, i) => (
<div key={stage.id} className="min-w-0 px-1 text-center">
<p
className={cn(
"truncate text-xs font-medium leading-tight",
i <= current ? "text-foreground" : "text-muted-foreground",
)}
>
{stage.label}
</p>
{stage.caption && (
<p className="mt-0.5 truncate text-[11px] tabular-nums text-muted-foreground">
{stage.caption}
</p>
)}
</div>
))}
</div>
</div>
</TooltipProvider>
);
}