A horizontal offer→counter→accepted→closing rail that fills to the current stage, which pulses.
npx shadcn@latest add @paragon/offer-status-timeline"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface OfferStage {
/** Stage title, e.g. "Offer submitted". */
label: string;
/** Optional caption under the label, e.g. a date. */
detail?: string;
}
export interface OfferStatusTimelineProps
extends React.ComponentProps<"div"> {
stages: OfferStage[];
/** Index of the current (in-progress) stage. Earlier stages are complete. */
current: number;
/** Suppresses the rail draw and pulse. */
static?: boolean;
}
/**
* A horizontal offer-status rail (offer → counter → accepted → closing).
* Completed stages fill and check; the current stage pulses gently. The
* connecting rail fills up to the current stage with a single draw when the
* component scrolls into view. Reduced motion shows the final state with no
* pulse and no draw.
*/
export function OfferStatusTimeline({
stages,
current,
static: isStatic = false,
className,
...props
}: OfferStatusTimelineProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const clampedCurrent = Math.min(Math.max(current, 0), stages.length - 1);
const total = stages.length;
// Fill fraction of the whole rail: complete stages contribute full spans.
const fillFraction =
total <= 1 ? 0 : clampedCurrent / (total - 1);
return (
<div
ref={ref}
data-slot="offer-status-timeline"
className={cn("w-full max-w-xl", className)}
{...props}
>
<style href="paragon-offer-status-timeline" precedence="paragon">{`
@keyframes offer-stage-pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--color-primary); opacity: 1; }
50% { box-shadow: 0 0 0 4px color-mix(in oklch, var(--color-primary) 20%, transparent); opacity: 0.92; }
}
@media (prefers-reduced-motion: reduce) {
[data-offer-current] { animation: none !important; }
}
`}</style>
<ol className="relative flex items-start justify-between">
{/* Rail track */}
<div
aria-hidden
className="absolute top-3.5 right-3.5 left-3.5 h-0.5 -translate-y-1/2 rounded-full bg-border"
>
<div
className="h-full origin-left rounded-full bg-primary"
style={{
transform: `scaleX(${drawn ? fillFraction : 0})`,
transition: animate
? "transform 700ms var(--ease-in-out)"
: undefined,
}}
/>
</div>
{stages.map((stage, i) => {
const complete = i < clampedCurrent;
const isCurrent = i === clampedCurrent;
return (
<li
key={stage.label}
className="relative z-10 flex min-w-0 flex-1 flex-col items-center text-center first:items-start last:items-end"
>
<span
data-offer-current={isCurrent ? "" : undefined}
aria-hidden
className={cn(
"flex size-7 items-center justify-center rounded-full border-2 bg-background transition-[background-color,border-color,color] duration-200",
complete &&
"border-primary bg-primary text-primary-foreground",
isCurrent && "border-primary text-primary",
!complete && !isCurrent && "border-border text-transparent",
)}
style={
isCurrent && animate
? { animation: "offer-stage-pulse 2s ease-in-out infinite" }
: undefined
}
>
{complete ? (
<Check className="size-4" />
) : (
<span
className={cn(
"size-2 rounded-full",
isCurrent ? "bg-primary" : "bg-border",
)}
/>
)}
</span>
<span
className={cn(
"mt-2 max-w-[8rem] text-xs font-medium",
complete || isCurrent
? "text-foreground"
: "text-muted-foreground",
)}
>
{stage.label}
</span>
{stage.detail && (
<span className="mt-0.5 text-[11px] text-muted-foreground tabular-nums">
{stage.detail}
</span>
)}
<span className="sr-only">
{complete
? "completed"
: isCurrent
? "in progress"
: "upcoming"}
</span>
</li>
);
})}
</ol>
</div>
);
}