A vertical timeline of payouts with future dates ghosted back and the next payout node pulsing once on mount.
npx shadcn@latest add @paragon/payout-schedule"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>
);
}