A lease term timeline plotting move-in, renewal, and expiry milestones with a progress fill for the current position.
npx shadcn@latest add @paragon/lease-timeline"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { KeyRound, RefreshCw, LogOut, CalendarDays } from "lucide-react";
import { cn } from "@/lib/utils";
export type LeaseKind = "movein" | "renewal" | "expiry" | "custom";
export interface LeaseMilestone {
kind: LeaseKind;
label: string;
date: string;
/** Position 0–1 along the term. */
at: number;
}
export interface LeaseTimelineProps extends React.ComponentProps<"div"> {
milestones: LeaseMilestone[];
/** Current progress through the lease term, 0–1. */
progress: number;
/** Suppresses the progress fill animation. */
static?: boolean;
}
const ICONS: Record<LeaseKind, typeof KeyRound> = {
movein: KeyRound,
renewal: RefreshCw,
expiry: LogOut,
custom: CalendarDays,
};
/**
* A lease term timeline: move-in, renewal and expiry milestones plotted along a
* vertical rail, with a progress fill that grows to the current term position
* on mount (transform scaleY, reduced-motion-safe). Past milestones read solid,
* upcoming ones outlined. Deterministic — positions are props.
*/
export function LeaseTimeline({
milestones,
progress,
static: isStatic = false,
className,
...props
}: LeaseTimelineProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [drawn, setDrawn] = React.useState(!animate);
React.useEffect(() => {
if (!animate) return;
const id = requestAnimationFrame(() => setDrawn(true));
return () => cancelAnimationFrame(id);
}, [animate]);
const p = Math.min(Math.max(progress, 0), 1);
const sorted = [...milestones].sort((a, b) => a.at - b.at);
return (
<div
data-slot="lease-timeline"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium">Lease term</p>
<p className="text-xs tabular-nums text-muted-foreground">
{Math.round(p * 100)}% elapsed
</p>
</div>
<div className="relative mt-4 pl-1">
{/* rail */}
<div
aria-hidden
className="absolute bottom-2 left-3 top-2 w-0.5 rounded-full bg-secondary"
/>
{/* progress fill (top→down) */}
<div
aria-hidden
className="absolute left-3 top-2 w-0.5 origin-top rounded-full bg-primary"
style={{
bottom: "0.5rem",
transform: `scaleY(${drawn ? p : 0})`,
transition: animate
? "transform 700ms var(--ease-out)"
: undefined,
}}
/>
<ol className="relative flex flex-col gap-5">
{sorted.map((m, i) => {
const Icon = ICONS[m.kind];
const past = m.at <= p;
return (
<li key={`${m.kind}-${i}`} className="flex items-center gap-3">
<span
aria-hidden
className={cn(
"z-10 flex size-6 shrink-0 items-center justify-center rounded-full ring-4 ring-card transition-colors duration-300",
past
? "bg-primary text-primary-foreground"
: "border border-border bg-background text-muted-foreground",
)}
>
<Icon className="size-3" />
</span>
<div className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
<span
className={cn(
"truncate text-sm",
past ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{m.label}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{m.date}
</span>
</div>
</li>
);
})}
</ol>
</div>
</div>
);
}