Kosmesis
Components

Timeline

A vertical list of dated events with a connecting rail. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add timeline
pnpm dlx kosmesis add timeline
yarn dlx kosmesis add timeline
bunx kosmesis add timeline

Copy and paste the following code into your project.

timeline.tsx
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/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

timeline.tsx
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

PropTypeDefault
status"complete" | "current" | "upcoming""complete"
animatedbooleanfalse
animateLinebooleanfalse
classstring

TimelineTitle, TimelineTime, and TimelineDescription accept class and children only.

On this page