Timeline
A vertical list of dated events with a connecting rail. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add timelinepnpm dlx kosmesis add timelineyarn dlx kosmesis add timelinebunx kosmesis add timelineCopy and paste the following code into your project.
import { StatelessComponent } from "@praxisjs/core";import { Component } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { cn } from "@/lib/utils";export interface TimelineProps { class?: string; id?: string; children?: Children;}@Component()export class Timeline extends StatelessComponent<TimelineProps> { render() { const { class: cls, id, children } = this.props; return ( <ol id={id} data-slot="timeline" class={cn("flex flex-col", cls)}> {children} </ol> ); }}export type TimelineItemStatus = "complete" | "current" | "upcoming";export interface TimelineItemProps { status?: TimelineItemStatus; /** Pulses a ring around the dot when `status` is `"current"`. */ animated?: boolean; /** Animates the connecting line as a dashed pattern flowing toward the next step. */ animateLine?: boolean; class?: string; children?: Children;}/** * The line is `absolute`, sized off the `<li>` itself (not a `flex-1` sibling of the dot) so it * reaches through the `pb-8` gap into the next item instead of stopping at the row's own height. * `animateLine` animates `background-position` (not `transform`) so the repeating dash pattern * loops seamlessly regardless of the line's actual height. The `@keyframes` name is fixed rather * than per-instance — duplicate `<style>` tags across items are harmless. */@Component()export class TimelineItem extends StatelessComponent<TimelineItemProps> { render() { const { status = "complete", animated = false, animateLine = false, class: cls, children } = this.props; const showPing = animated && status === "current"; return ( <li data-slot="timeline-item" data-status={status} class={cn("group relative flex gap-4 pb-8 last:pb-0", cls)}> {animateLine && ( <style>{"@keyframes kosmesis-timeline-line-flow { from { background-position: 0 0; } to { background-position: 0 12px; } }"}</style> )} <span aria-hidden class="absolute top-[calc(0.25rem+5px)] bottom-0 left-[5px] w-px -translate-x-1/2 bg-border group-last:hidden" style={ animateLine ? { backgroundImage: "linear-gradient(var(--color-primary) 50%, transparent 50%)", backgroundSize: "1px 12px", backgroundRepeat: "repeat-y", animation: "kosmesis-timeline-line-flow 600ms linear infinite", } : undefined } /> <div class="flex flex-col items-center"> {/* `absolute inset-0` blockifies the nested span; `relative` alone wouldn't, so `size-2.5` would collapse. */} <span class="relative mt-1 size-2.5 shrink-0"> {showPing && <span aria-hidden class="absolute inset-0 animate-ping rounded-full bg-primary/50" />} <span class={cn( "absolute inset-0 z-10 rounded-full bg-border", "in-data-[status=current]:bg-primary in-data-[status=current]:ring-4 in-data-[status=current]:ring-primary/20", "in-data-[status=complete]:bg-primary", )} /> </span> </div> <div class="flex-1 pb-2">{children}</div> </li> ); }}export interface TimelineSlotProps { class?: string; children?: Children;}@Component()export class TimelineTitle extends StatelessComponent<TimelineSlotProps> { render() { const { class: cls, children } = this.props; return <p class={cn("text-sm font-medium text-foreground", cls)}>{children}</p>; }}@Component()export class TimelineTime extends StatelessComponent<TimelineSlotProps> { render() { const { class: cls, children } = this.props; return ( <time class={cn("text-xs text-muted-foreground", cls)}>{children}</time> ); }}@Component()export class TimelineDescription extends StatelessComponent<TimelineSlotProps> { render() { const { class: cls, children } = this.props; return <p class={cn("mt-1 text-sm text-muted-foreground", cls)}>{children}</p>; }}Install the following dependencies:
npm install @praxisjs/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
import { StatelessComponent } from "@praxisjs/core";import { cx, keyframes, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);const ping = keyframes("kosmesis-timeline-ping", { "75%, 100%": { transform: "scale(2)", opacity: "0" },});const lineFlow = keyframes("kosmesis-timeline-line-flow", { from: { backgroundPosition: "0 0" }, to: { backgroundPosition: "0 12px" },});class TimelineStyles extends Stylesheet { $root = this.css({ display: "flex", flexDirection: "column" }); $item = this.css({ position: "relative", display: "flex", gap: "1rem", paddingBottom: "2rem" }).on("&:last-child", { paddingBottom: 0, }); /** `position: absolute`, sized off the `<li>` (not a `flex` sibling of the dot) so it reaches through `$item`'s `paddingBottom` into the next item. */ $line = this.css({ position: "absolute", top: "calc(0.25rem + 5px)", bottom: 0, left: "5px", width: "1px", transform: "translateX(-50%)", backgroundColor: t.border, }).on('[data-slot="timeline-item"]:last-child &', { display: "none" }); /** Animates `backgroundPosition` (not `transform`) so the repeating dash pattern loops seamlessly regardless of the line's height. */ $lineAnimated = this.css({ backgroundImage: `linear-gradient(${t.primary} 50%, transparent 50%)`, backgroundSize: "1px 12px", backgroundRepeat: "repeat-y", animation: `${lineFlow} 600ms linear infinite`, }); $rail = this.css({ display: "flex", flexDirection: "column", alignItems: "center" }); $dotWrap = this.css({ position: "relative", marginTop: "0.25rem", height: "0.625rem", width: "0.625rem", flexShrink: 0 }); $ping = this.css({ position: "absolute", inset: 0, borderRadius: "9999px", backgroundColor: `color-mix(in oklab, ${t.primary} 50%, transparent)`, animation: `${ping} 1s cubic-bezier(0, 0, 0.2, 1) infinite`, }); /** `position: absolute` blockifies this nested span; `relative` alone wouldn't, and the dot would collapse to zero size. */ $dot = this.css({ position: "absolute", inset: 0, zIndex: 10, borderRadius: "9999px", backgroundColor: t.border, }) .on('[data-status="current"] &', { backgroundColor: t.primary, boxShadow: `0 0 0 4px color-mix(in oklab, ${t.primary} 20%, transparent)`, }) .on('[data-status="complete"] &', { backgroundColor: t.primary }); $content = this.css({ flex: "1 1 0%", paddingBottom: "0.5rem" }); $title = this.css({ fontSize: "0.875rem", fontWeight: 500, color: t.foreground }); $time = this.css({ fontSize: "0.75rem", color: t.mutedForeground }); $description = this.css({ marginTop: "0.25rem", fontSize: "0.875rem", color: t.mutedForeground });}export interface TimelineProps { class?: string; id?: string; children?: Children;}@Component()export class Timeline extends StatelessComponent<TimelineProps> { @Styled(TimelineStyles) $s!: TimelineStyles; render() { const { class: cls, id, children } = this.props; return ( <ol id={id} data-slot="timeline" class={cx(this.$s.$root, cls)}> {children} </ol> ); }}export type TimelineItemStatus = "complete" | "current" | "upcoming";export interface TimelineItemProps { status?: TimelineItemStatus; /** Pulses a ring around the dot when `status` is `"current"`. */ animated?: boolean; /** Animates the connecting line as a dashed pattern flowing toward the next step. */ animateLine?: boolean; class?: string; children?: Children;}@Component()export class TimelineItem extends StatelessComponent<TimelineItemProps> { @Styled(TimelineStyles) $s!: TimelineStyles; render() { const { status = "complete", animated = false, animateLine = false, class: cls, children } = this.props; const showPing = animated && status === "current"; return ( <li data-slot="timeline-item" data-status={status} class={cx(this.$s.$item, cls)}> <span class={cx(this.$s.$line, animateLine && this.$s.$lineAnimated)} /> <div class={this.$s.$rail}> <span class={this.$s.$dotWrap}> {showPing && <span aria-hidden class={this.$s.$ping} />} <span class={this.$s.$dot} /> </span> </div> <div class={this.$s.$content}>{children}</div> </li> ); }}export interface TimelineSlotProps { class?: string; children?: Children;}@Component()export class TimelineTitle extends StatelessComponent<TimelineSlotProps> { @Styled(TimelineStyles) $s!: TimelineStyles; render() { const { class: cls, children } = this.props; return <p class={cx(this.$s.$title, cls)}>{children}</p>; }}@Component()export class TimelineTime extends StatelessComponent<TimelineSlotProps> { @Styled(TimelineStyles) $s!: TimelineStyles; render() { const { class: cls, children } = this.props; return ( <time class={cx(this.$s.$time, cls)}>{children}</time> ); }}@Component()export class TimelineDescription extends StatelessComponent<TimelineSlotProps> { @Styled(TimelineStyles) $s!: TimelineStyles; render() { const { class: cls, children } = this.props; return <p class={cx(this.$s.$description, cls)}>{children}</p>; }}Examples
About
Purely presentational — no Morphos equivalent. TimelineItem renders its own dot + connecting
rail; TimelineTitle, TimelineTime, and TimelineDescription are plain typography slots, the
same compound-slot pattern as Card.
The connecting rail is a single absolute span pinned to the dot's own center at the top and to
the <li>'s full bottom edge (padding included) at the bottom — so it always reaches exactly where
the next item's dot begins, regardless of how tall this item's content is.
Set animated on a TimelineItem to pulse an expanding ring around its dot — only visible when
status is "current", a nice touch for a step that's genuinely in progress (e.g. "Shipped" while
waiting on "Delivered"). Set animateLine to turn that one item's connecting line into a dashed
pattern that scrolls continuously downward — a "flowing toward the next, still-pending step"
indicator, not a one-shot entrance animation. Each TimelineItem owns its own animateLine
independently, so only the segment between the current step and the next pending one needs it —
not every line in the list, and not the ones behind you that are already done.
Usage
import { Timeline, TimelineDescription, TimelineItem, TimelineTime, TimelineTitle } from "@/components/ui/timeline";
<Timeline>
<TimelineItem status="complete">
<TimelineTitle>Order placed</TimelineTitle>
<TimelineTime>Jan 3, 2026</TimelineTime>
<TimelineDescription>Your order was confirmed.</TimelineDescription>
</TimelineItem>
<TimelineItem status="current" animated animateLine>
<TimelineTitle>Shipped</TimelineTitle>
<TimelineTime>Jan 4, 2026</TimelineTime>
<TimelineDescription>Package left the warehouse.</TimelineDescription>
</TimelineItem>
</Timeline>Props
TimelineItem
| Prop | Type | Default |
|---|---|---|
status | "complete" | "current" | "upcoming" | "complete" |
animated | boolean | false |
animateLine | boolean | false |
class | string | — |
TimelineTitle, TimelineTime, and TimelineDescription accept class and children only.