Fintech & Payments

Payout Schedule

A vertical timeline of payouts with future dates ghosted back and the next payout node pulsing once on mount.

Install

npx shadcn@latest add @paragon/payout-schedule

payout-schedule.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface Payout {
  id: string;
  /** Pre-formatted date, e.g. "Jul 15". */
  date: string;
  /** Amount in major units. */
  amount: number;
  /** paid = settled, next = the upcoming one, scheduled = future. */
  status: "paid" | "next" | "scheduled";
  /** Optional destination line. */
  destination?: string;
}

export interface PayoutScheduleProps extends React.ComponentProps<"div"> {
  payouts: Payout[];
  currency?: string;
  locale?: string;
}

/**
 * A vertical timeline of payouts. Paid entries are solid; scheduled (future)
 * entries are ghosted back so the eye lands on what's settled and what's next.
 * The single `next` payout gets one gentle pulse ring on its node when it
 * mounts — a one-shot keyframe, not a loop, because a permanent pulse would
 * nag. Reduced motion renders it as a static filled node.
 */
export function PayoutSchedule({
  payouts,
  currency = "USD",
  locale = "en-US",
  className,
  ...props
}: PayoutScheduleProps) {
  const fmt = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }),
    [locale, currency],
  );

  return (
    <div
      className={cn("w-full max-w-md", className)}
      {...props}
    >
      <style href="paragon-payout-schedule" precedence="paragon">{`
        @keyframes paragon-payout-pulse {
          0% { transform: scale(1); opacity: 0.55; }
          70% { transform: scale(2.4); opacity: 0; }
          100% { transform: scale(2.4); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-payout-pulse { animation: none !important; opacity: 0 !important; }
        }
      `}</style>

      <ol className="relative">
        {payouts.map((p, i) => {
          const last = i === payouts.length - 1;
          const ghosted = p.status === "scheduled";
          const isNext = p.status === "next";
          return (
            <li key={p.id} className="relative flex gap-3.5 pb-5 last:pb-0">
              {/* Connector line */}
              {!last && (
                <span
                  aria-hidden
                  className={cn(
                    "absolute top-4 left-[7px] -bottom-1 w-px",
                    ghosted ? "bg-border/60" : "bg-border",
                  )}
                />
              )}
              {/* Node */}
              <span className="relative mt-1 flex size-4 shrink-0 items-center justify-center">
                {isNext && (
                  <span
                    aria-hidden
                    className="paragon-payout-pulse absolute inset-0 rounded-full bg-primary"
                    style={{ animation: "paragon-payout-pulse 1.4s var(--ease-out) both" }}
                  />
                )}
                <span
                  className={cn(
                    "size-2.5 rounded-full ring-4 ring-background",
                    p.status === "paid" && "bg-success",
                    isNext && "bg-primary",
                    ghosted && "border border-border bg-muted",
                  )}
                />
              </span>

              {/* Content */}
              <div
                className={cn(
                  "flex min-w-0 flex-1 items-start justify-between gap-3",
                  ghosted && "opacity-55",
                )}
              >
                <div className="min-w-0">
                  <div className="flex items-center gap-2">
                    <span className="text-sm font-medium tabular-nums">
                      {p.date}
                    </span>
                    {p.status === "paid" && (
                      <span className="text-[12px] text-success">Paid</span>
                    )}
                    {isNext && (
                      <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary dark:bg-primary/15">
                        Next payout
                      </span>
                    )}
                  </div>
                  {p.destination && (
                    <span className="block text-[13px] text-muted-foreground">
                      {p.destination}
                    </span>
                  )}
                </div>
                <span
                  className={cn(
                    "shrink-0 text-sm tabular-nums",
                    isNext ? "font-semibold" : "font-medium",
                  )}
                >
                  {fmt.format(p.amount)}
                </span>
              </div>
            </li>
          );
        })}
      </ol>
    </div>
  );
}