Feedback

Offer Status Timeline

A horizontal offer→counter→accepted→closing rail that fills to the current stage, which pulses.

Install

npx shadcn@latest add @paragon/offer-status-timeline

offer-status-timeline.tsx

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