Skip to content
ilinxa/pro-ui

Progress Timeline

alphav0.2.0

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

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

Use for any time-bound progress display — registration windows, sprints, sales countdowns, course completion windows, fundraising deadlines. Public helper `deriveTimelineState` exported alongside so consumers can derive state without rendering (header counters, calendar coloring, deterministic tests). Migration origin: kasder events/[id]/page.tsx Time Bar block.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/progress-timeline

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/progress-timeline-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Zaman Çizelgesi

Kayıt Başlangıcı29 gün kaldıEtkinlik Günü

Demo source

demo.tsxtsx
"use client"; import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { ProgressTimeline } from "./progress-timeline";import {  dummyNow,  dummyTimelineActive,  dummyTimelineAfter,  dummyTimelineBefore,} from "./dummy-data";import type { TimelineState } from "./types"; const trLabels = {  startLabel: "Kayıt Başlangıcı",  endLabel: "Etkinlik Günü",  beforeText: (state: TimelineState) =>    `${state.daysToStart} gün sonra başlıyor`,  activeText: (state: TimelineState) => `${state.daysToEnd} gün kaldı`,  afterText: "Etkinlik Sona Erdi",}; export default function ProgressTimelineDemo() {  return (    <Tabs defaultValue="default" className="w-full">      <SwipeTabsList>        <TabsTrigger value="default">Default (TR)</TabsTrigger>        <TabsTrigger value="states">3 states</TabsTrigger>        <TabsTrigger value="bare">Bare</TabsTrigger>        <TabsTrigger value="custom">Custom render</TabsTrigger>        <TabsTrigger value="value">Value escape hatch</TabsTrigger>      </SwipeTabsList>       {/* 1. Default — Turkish, mirrors the kasder source verbatim */}      <TabsContent value="default" className="mt-6">        <div className="max-w-3xl mx-auto">          <ProgressTimeline            heading="Zaman Çizelgesi"            start={dummyTimelineActive.start}            end={dummyTimelineActive.end}            now={dummyNow}            labels={trLabels}          />        </div>      </TabsContent>       {/* 2. 3 states — before / active / after stacked */}      <TabsContent value="states" className="mt-6">        <div className="max-w-3xl mx-auto space-y-4">          <ProgressTimeline            heading="Before — registration not open yet"            start={dummyTimelineBefore.start}            end={dummyTimelineBefore.end}            now={dummyNow}          />          <ProgressTimeline            heading="Active — currently in progress"            start={dummyTimelineActive.start}            end={dummyTimelineActive.end}            now={dummyNow}          />          <ProgressTimeline            heading="After — has ended"            start={dummyTimelineAfter.start}            end={dummyTimelineAfter.end}            now={dummyNow}          />        </div>      </TabsContent>       {/* 3. Bare — no card chrome, no marker, no heading */}      <TabsContent value="bare" className="mt-6">        <div className="max-w-3xl mx-auto space-y-2">          <p className="text-sm text-muted-foreground">            Embedded use — <code>framed=&#123;false&#125;</code> +{" "}            <code>marker=&quot;none&quot;</code>. Drop into a banner / dashboard            tile.          </p>          <ProgressTimeline            start={dummyTimelineActive.start}            end={dummyTimelineActive.end}            now={dummyNow}            framed={false}            marker="none"          />        </div>      </TabsContent>       {/* 4. Custom render — full takeover of center label */}      <TabsContent value="custom" className="mt-6">        <div className="max-w-3xl mx-auto space-y-2">          <p className="text-sm text-muted-foreground">            <code>renderCenterLabel(state)</code> — full control. Here:            percent + days-left composed.          </p>          <ProgressTimeline            heading="Project Sprint"            start={dummyTimelineActive.start}            end={dummyTimelineActive.end}            now={dummyNow}            renderCenterLabel={(state) => (              <span className="inline-flex items-baseline gap-2">                <span className="text-base font-bold text-primary">                  {Math.round(state.percent)}%                </span>                <span className="text-xs text-muted-foreground">                  {state.daysToEnd} days left                </span>              </span>            )}          />        </div>      </TabsContent>       {/* 5. Value escape hatch — non-time-based progress */}      <TabsContent value="value" className="mt-6">        <div className="max-w-3xl mx-auto space-y-2">          <p className="text-sm text-muted-foreground">            <code>value</code> overrides start/end-derived percent — useful for            non-time-based progress (course completion, fundraising, etc.).            State (and therefore center label) still derives from dates so the            countdown stays meaningful.          </p>          <ProgressTimeline            heading="Course Progress"            start={dummyTimelineActive.start}            end={dummyTimelineActive.end}            now={dummyNow}            value={42}            labels={{              startLabel: "Module 1",              endLabel: "Module 12",              activeText: () => "42% complete (5 of 12 modules)",            }}          />        </div>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for ProgressTimeline when you need to communicate progress through a time-bound window — registration windows, sprints, sales countdowns, course completion windows, fundraising deadlines. The component renders a horizontal progress bar with a marker dot at the current percentage + 3 captions (start / dynamic state-aware center / end), auto-deriving a 3-state machine (before / active / after) from start + end + now.

Minimal example

import { ProgressTimeline } from "@/components/progress-timeline";

<ProgressTimeline
  start="2026-04-01"
  end="2026-06-30"
  heading="Registration Window"
/>;

Public helper kernel — derive state without rendering

The kernel is a pure function exported alongside the component. Use it for header counters, calendar coloring, status filter logic, deterministic tests — without rendering the bar:

import {
  ProgressTimeline,
  deriveTimelineState,
  type TimelineState,
} from "@/components/progress-timeline";

// Header counter — how many windows are currently open?
const activeCount = events.filter(
  (e) => deriveTimelineState(e.regStart, e.regEnd).status === "active",
).length;

// Calendar day-cell coloring
function isWithinWindow(start: string, end: string, day: Date) {
  return deriveTimelineState(start, end, day).status === "active";
}

Localizing labels

Pass a labels object. Each text label accepts a string OR a function (state: TimelineState) => ReactNode for dynamic content driven by the derived state:

<ProgressTimeline
  start={event.registrationOpens}
  end={event.date}
  labels={{
    startLabel: "Kayıt Başlangıcı",
    endLabel: "Etkinlik Günü",
    beforeText: (state) => `${state.daysToStart} gün sonra başlıyor`,
    activeText: (state) => `${state.daysToEnd} gün kaldı`,
    afterText: "Etkinlik Sona Erdi",
  }}
/>

renderCenterLabel — full takeover

For full control of the center caption (e.g. compose percent + days), use renderCenterLabel — receives the derived TimelineState:

<ProgressTimeline
  start={start}
  end={end}
  renderCenterLabel={(state) => (
    <span>
      <strong>{Math.round(state.percent)}%</strong> · {state.daysToEnd} days left
    </span>
  )}
/>

value escape hatch — non-time progress

For non-time-based progress (course completion %, fundraising %, etc.), pass value (0-100) — overrides the time-derived bar fill. The state machine still derives from start/end so the captions stay meaningful:

<ProgressTimeline
  start={course.startDate}
  end={course.endDate}
  value={courseCompletion}
  labels={{
    startLabel: "Module 1",
    endLabel: "Module 12",
    activeText: () => `${courseCompletion}% complete`,
  }}
/>

Live-clock host — minute-accurate state flips

function LiveTimeline({ event }) {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 60_000);
    return () => clearInterval(id);
  }, []);
  return (
    <ProgressTimeline start={event.start} end={event.end} now={now} />
  );
}

The component has no internal setInterval — consumer drives the cadence (1-minute / 5-minute / 1-hour windows your call).

Notes

  • start + end are required even when value is supplied — captions need anchors and the state machine needs date boundaries.
  • Invalid dates clamp gracefully (no crash); out-of-window times render as 0% / 100%.
  • headingAs defaults to h3 (timelines are typically nested under a page h2 section). Bump via headingAs="h2" when standalone.
  • headingIcon defaults to Timer from lucide-react. Pass headingIcon={null} to omit, or pass anyComponentType to swap.
  • marker="none" hides the dot; useful for dense contexts.
  • The marker dot extends slightly past the bar at 0% / 100% (half-dot width) — by design; the dot represents the position, not the bar fill.

Features

  • Horizontal progress bar with marker dot at current %
  • 3-state state machine (before / active / after) auto-derived
  • Public helper kernel — deriveTimelineState pure function
  • Dynamic center label — string OR (state) => ReactNode
  • Frame toggle (framed/bare) + marker toggle (dot/none)
  • Optional heading with configurable level + icon
  • value escape hatch for non-time-based progress
  • now injection for deterministic / live-clock hosts
  • statusOverride for preview / what-if states
  • i18n via labels object (6 keys)
  • WCAG — Radix Progress role=progressbar + aria-valuenow
  • Status-conditional bar fill + marker color — before (muted gray), active (lime), after (mid-gray); pairs with center-text differentiation
  • Soft-failure on invalid dates

Tags

progress-timelineprogresstimelinecountdownevents

Dependencies

shadcn primitives: progress
npm peer deps: lucide-react@^1.11.0