# Progress Timeline

> Horizontal progress bar with a current-position marker and start, state-aware center, and end captions — derives its state from three dates.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/progress-timeline).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/progress-timeline
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/progress-timeline-light-cd42d9bf4bb70b4a.png
- Registry item: https://ui.ilinxa.com/r/progress-timeline.json
- Categories: data, events
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/progress-timeline.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `progress`

## Files

### `src/registry/components/data/progress-timeline/progress-timeline.tsx`

```tsx
"use client";

import { memo, useId, useMemo, type ReactNode } from "react";
import { Timer } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import {
  DEFAULT_PROGRESS_TIMELINE_LABELS,
  type ProgressTimelineProps,
  type TimelineLabelText,
  type TimelineState,
} from "./types";
import { deriveTimelineState } from "./lib/timeline-state";

function resolveLabel(
  text: TimelineLabelText | undefined,
  state: TimelineState,
): ReactNode {
  if (text == null) return null;
  return typeof text === "function" ? text(state) : text;
}

function ProgressTimelineInner({
  start,
  end,
  now,
  value,
  statusOverride,
  heading,
  headingAs = "h3",
  headingIcon: HeadingIconProp,
  framed = true,
  marker = "dot",
  labels: labelsProp,
  renderCenterLabel,
  className,
  headingClassName,
  barClassName,
  markerClassName,
  captionsClassName,
}: ProgressTimelineProps) {
  const headingId = useId();
  const HeadingTag = headingAs;
  const HeadingIcon =
    HeadingIconProp === undefined ? Timer : HeadingIconProp;

  const labels = useMemo<typeof DEFAULT_PROGRESS_TIMELINE_LABELS>(
    () => ({ ...DEFAULT_PROGRESS_TIMELINE_LABELS, ...labelsProp }),
    [labelsProp],
  );

  const state = useMemo(
    () => deriveTimelineState(start, end, now),
    [start, end, now],
  );

  const effectivePercent = value ?? state.percent;
  const effectiveStatus = statusOverride ?? state.status;

  const centerLabel: ReactNode = renderCenterLabel
    ? renderCenterLabel(state)
    : effectiveStatus === "before"
      ? resolveLabel(labels.beforeText, state)
      : effectiveStatus === "after"
        ? resolveLabel(labels.afterText, state)
        : resolveLabel(labels.activeText, state);

  return (
    <section
      aria-labelledby={heading ? headingId : undefined}
      className={cn(
        framed && "bg-card rounded-2xl p-6 border border-border/50",
        className,
      )}
    >
      {heading && (
        <HeadingTag
          id={headingId}
          className={cn(
            "text-lg font-semibold text-foreground mb-4 flex items-center gap-2",
            headingClassName,
          )}
        >
          {HeadingIcon ? (
            <HeadingIcon
              aria-hidden="true"
              className="w-5 h-5 text-primary"
            />
          ) : null}
          {heading}
        </HeadingTag>
      )}

      <div className="space-y-4">
        <div className={cn("relative", barClassName)}>
          <Progress
            value={effectivePercent}
            className={cn(
              "h-4 rounded-full",
              effectiveStatus === "before" &&
                "**:data-[slot=progress-indicator]:bg-muted-foreground/30",
              effectiveStatus === "after" &&
                "**:data-[slot=progress-indicator]:bg-muted-foreground/40",
            )}
            aria-label={labels.ariaLabel}
          />
          {marker === "dot" && (
            <div
              aria-hidden="true"
              className="absolute top-0 h-4 flex items-center justify-center pointer-events-none"
              style={{
                left: `${effectivePercent}%`,
                transform: "translateX(-50%)",
              }}
            >
              <div
                className={cn(
                  "w-6 h-6 rounded-full border-4 border-background shadow-lg",
                  effectiveStatus === "active"
                    ? "bg-primary"
                    : effectiveStatus === "after"
                      ? "bg-foreground"
                      : "bg-muted-foreground",
                  markerClassName,
                )}
              />
            </div>
          )}
        </div>

        <div
          className={cn(
            "flex justify-between text-sm",
            captionsClassName,
          )}
        >
          <span className="text-muted-foreground">{labels.startLabel}</span>
          <span className="text-foreground font-medium">{centerLabel}</span>
          <span className="text-muted-foreground">{labels.endLabel}</span>
        </div>
      </div>
    </section>
  );
}

const ProgressTimeline = memo(ProgressTimelineInner);
ProgressTimeline.displayName = "ProgressTimeline";

export { ProgressTimeline };
export default ProgressTimeline;
```

### `src/registry/components/data/progress-timeline/index.ts`

```ts
export { ProgressTimeline, default } from "./progress-timeline";

export type {
  ProgressTimelineProps,
  ProgressTimelineLabels,
  TimelineLabelText,
  TimelineState,
  TimelineStatus,
} from "./types";

export { DEFAULT_PROGRESS_TIMELINE_LABELS } from "./types";

export { deriveTimelineState } from "./lib/timeline-state";
```

### `src/registry/components/data/progress-timeline/types.ts`

```ts
import type { ComponentType, ReactNode } from "react";
import type {
  TimelineState,
  TimelineStatus,
} from "./lib/timeline-state";

export type { TimelineState, TimelineStatus };

export type TimelineLabelText =
  | string
  | ((state: TimelineState) => ReactNode);

export interface ProgressTimelineLabels {
  /** Default: "Start". Static left-side caption. */
  startLabel?: ReactNode;
  /** Default: "End". Static right-side caption. */
  endLabel?: ReactNode;
  /** Default: state-aware — "{n} days until start". When `now < start`. */
  beforeText?: TimelineLabelText;
  /** Default: state-aware — "{n} days left". When `now ∈ [start, end]`. */
  activeText?: TimelineLabelText;
  /** Default: "Ended". When `now > end`. */
  afterText?: TimelineLabelText;
  /** aria-label on the progress bar. Default: "Timeline progress". */
  ariaLabel?: string;
}

export interface ProgressTimelineProps {
  /** Start of the timeline window. ISO string or Date. Required. */
  start: Date | string;
  /** End of the timeline window. ISO string or Date. Required. */
  end: Date | string;

  /** Inject "now" for deterministic state (tests, live clocks). Default: new Date() at render. */
  now?: Date;

  /** Escape hatch: explicit 0-100 percentage. Overrides start/end-derived percent (state still derives from dates). */
  value?: number;

  /** Override the auto-derived state. Rare. */
  statusOverride?: TimelineStatus;

  // ─── Heading ─────────────────────────────────────────────────────
  /** Optional section heading. */
  heading?: string;
  /** Heading semantic level. Default: 'h3'. */
  headingAs?: "h2" | "h3" | "h4";
  /** Heading icon (default Timer). Pass null to omit. */
  headingIcon?: ComponentType<{
    className?: string;
    "aria-hidden"?: boolean | "true" | "false";
  }> | null;

  // ─── Visual ──────────────────────────────────────────────────────
  /** Wrap in card chrome (`bg-card rounded-2xl p-6 border ...`). Default: true. */
  framed?: boolean;
  /** Marker dot variant. Default: 'dot'. */
  marker?: "dot" | "none";

  /** Localized labels + render-function overrides. */
  labels?: ProgressTimelineLabels;

  /** Custom center-label renderer — full takeover of the dynamic center caption. Receives derived TimelineState. */
  renderCenterLabel?: (state: TimelineState) => ReactNode;

  // ─── Style overrides ─────────────────────────────────────────────
  className?: string;
  headingClassName?: string;
  barClassName?: string;
  markerClassName?: string;
  captionsClassName?: string;
}

/** Default English labels — exported for consumer composition. */
export const DEFAULT_PROGRESS_TIMELINE_LABELS: {
  startLabel: ReactNode;
  endLabel: ReactNode;
  beforeText: TimelineLabelText;
  activeText: TimelineLabelText;
  afterText: TimelineLabelText;
  ariaLabel: string;
} = {
  startLabel: "Start",
  endLabel: "End",
  beforeText: (state) =>
    `${state.daysToStart} day${state.daysToStart === 1 ? "" : "s"} until start`,
  activeText: (state) =>
    `${state.daysToEnd} day${state.daysToEnd === 1 ? "" : "s"} left`,
  afterText: "Ended",
  ariaLabel: "Timeline progress",
};
```

### `src/registry/components/data/progress-timeline/lib/timeline-state.ts`

```ts
export type TimelineStatus = "before" | "active" | "after";

export interface TimelineState {
  /** Derived state — time-window position. */
  status: TimelineStatus;
  /** Percent elapsed (0-100), clamped. */
  percent: number;
  /** Days until start (negative if start is past). */
  daysToStart: number;
  /** Days from start (negative if start is future). */
  daysFromStart: number;
  /** Days to end (negative if end is past). */
  daysToEnd: number;
  /** Total days in the window (always positive; clamped to 1 minimum). */
  totalDays: number;
}

const MS_PER_DAY = 1000 * 60 * 60 * 24;

/**
 * Pure function. Derives timeline state from start + end + an optional `now`.
 * Invalid dates clamp gracefully; out-of-window times return 0% / 100%.
 *
 * @example
 * deriveTimelineState("2026-04-01", "2026-06-30")
 *   // → { status: "active", percent: 67, daysToStart: -61, daysToEnd: 29, ... }
 */
export function deriveTimelineState(
  start: Date | string,
  end: Date | string,
  now: Date = new Date(),
): TimelineState {
  const startDate = new Date(start);
  const endDate = new Date(end);

  // Soft-failure on invalid dates
  const validStart = isNaN(startDate.getTime()) ? now : startDate;
  const validEnd = isNaN(endDate.getTime())
    ? validStart
    : endDate < validStart
      ? validStart
      : endDate;

  const totalMs = Math.max(1, validEnd.getTime() - validStart.getTime());
  const elapsedMs = now.getTime() - validStart.getTime();
  const percent = Math.min(100, Math.max(0, (elapsedMs / totalMs) * 100));

  const daysToStart = Math.ceil(
    (validStart.getTime() - now.getTime()) / MS_PER_DAY,
  );
  const daysFromStart = Math.floor(
    (now.getTime() - validStart.getTime()) / MS_PER_DAY,
  );
  const daysToEnd = Math.ceil(
    (validEnd.getTime() - now.getTime()) / MS_PER_DAY,
  );
  const totalDays = Math.max(1, Math.ceil(totalMs / MS_PER_DAY));

  let status: TimelineStatus;
  if (now < validStart) status = "before";
  else if (now > validEnd) status = "after";
  else status = "active";

  return {
    status,
    percent,
    daysToStart,
    daysFromStart,
    daysToEnd,
    totalDays,
  };
}
```
