PropTech

Lease Timeline

A lease term timeline plotting move-in, renewal, and expiry milestones with a progress fill for the current position.

Install

npx shadcn@latest add @paragon/lease-timeline

lease-timeline.tsx

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