Progress Timeline
alphav0.2.0Horizontal progress bar with a current-position marker and start, state-aware center, and end captions — derives its state from three dates.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/progress-timelineAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/progress-timeline-fixturesCLI 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
Demo source
"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={false}</code> +{" "} <code>marker="none"</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+endare required even whenvalueis 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%.
headingAsdefaults toh3(timelines are typically nested under a pageh2section). Bump viaheadingAs="h2"when standalone.headingIcondefaults toTimerfrom lucide-react. PassheadingIcon={null}to omit, or pass anyComponentTypeto 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