Skip to content

Timeline

A chronology as an ordered list, with tone-coded markers and machine-readable timestamps.

Datastartertimelinehistoryauditchronology

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { GitCommit, GitPullRequest, Rocket, ShieldAlert } from 'lucide-react'
import { Timeline } from '@/components/ui/timeline'
import { DemoColumn, DemoStage } from './_kit'

export default function TimelineDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Timeline
          entries={[
            {
              id: '1',
              title: 'Deployed to production',
              description: 'Release v1.4.2 — 14 files changed across three packages.',
              timestamp: '2026-03-14',
              displayTime: '14 Mar, 09:41',
              icon: <Rocket className="size-3.5" />,
              tone: 'accent',
            },
            {
              id: '2',
              title: 'Pull request merged',
              description: 'feat(tokens): make density a first-class axis',
              timestamp: '2026-03-13',
              displayTime: '13 Mar, 17:02',
              icon: <GitPullRequest className="size-3.5" />,
              tone: 'success',
            },
            {
              id: '3',
              title: 'Security advisory resolved',
              description: 'Transitive dependency bumped; no user action required.',
              timestamp: '2026-03-11',
              displayTime: '11 Mar, 08:15',
              icon: <ShieldAlert className="size-3.5" />,
              tone: 'warning',
            },
            {
              id: '4',
              title: 'Repository created',
              timestamp: '2026-02-02',
              displayTime: '02 Feb, 11:30',
              icon: <GitCommit className="size-3.5" />,
            },
          ]}
        />
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/timeline.tsx

import { GitCommit, GitPullRequest, Rocket, ShieldAlert } from 'lucide-react'
import { Timeline } from '@/components/ui/timeline'
import { DemoColumn, DemoStage } from './_kit'

export default function TimelineDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Timeline
          entries={[
            {
              id: '1',
              title: 'Deployed to production',
              description: 'Release v1.4.2 — 14 files changed across three packages.',
              timestamp: '2026-03-14',
              displayTime: '14 Mar, 09:41',
              icon: <Rocket className="size-3.5" />,
              tone: 'accent',
            },
            {
              id: '2',
              title: 'Pull request merged',
              description: 'feat(tokens): make density a first-class axis',
              timestamp: '2026-03-13',
              displayTime: '13 Mar, 17:02',
              icon: <GitPullRequest className="size-3.5" />,
              tone: 'success',
            },
            {
              id: '3',
              title: 'Security advisory resolved',
              description: 'Transitive dependency bumped; no user action required.',
              timestamp: '2026-03-11',
              displayTime: '11 Mar, 08:15',
              icon: <ShieldAlert className="size-3.5" />,
              tone: 'warning',
            },
            {
              id: '4',
              title: 'Repository created',
              timestamp: '2026-02-02',
              displayTime: '02 Feb, 11:30',
              icon: <GitCommit className="size-3.5" />,
            },
          ]}
        />
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/timeline.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Timeline / ActivityItem
 *
 * Timeline renders a chronology as an ordered list — the order is the meaning,
 * so it must not be a `<div>` stack. ActivityItem is the denser feed variant
 * used inside dashboards and record sidebars.
 */
export interface TimelineEntry {
  id: string
  title: string
  description?: ReactNode
  /** ISO date string, formatted by the caller for display. */
  timestamp: string
  displayTime: string
  icon?: ReactNode
  tone?: 'default' | 'accent' | 'success' | 'warning' | 'danger'
}

export interface TimelineProps {
  entries: TimelineEntry[]
  className?: string
  /** `alternating` staggers entries left/right on wide screens. */
  layout?: 'stacked' | 'alternating'
}

const toneRing = {
  default: 'border-line bg-surface text-ink-subtle',
  accent: 'border-accent bg-accent-soft text-accent-soft-ink',
  success: 'border-success-line bg-success-soft text-success',
  warning: 'border-warning-line bg-warning-soft text-warning',
  danger: 'border-danger-line bg-danger-soft text-danger',
} as const

export function Timeline({ entries, className, layout = 'stacked' }: TimelineProps) {
  return (
    <ol className={cn('relative flex flex-col', className)}>
      {entries.map((entry, index) => {
        const isLast = index === entries.length - 1
        const alternate = layout === 'alternating' && index % 2 === 1

        return (
          <li key={entry.id} className={cn('relative flex gap-4 pb-6', isLast && 'pb-0')}>
            <div className="flex flex-col items-center">
              <span
                className={cn(
                  'flex size-8 shrink-0 items-center justify-center rounded-full border',
                  toneRing[entry.tone ?? 'default'],
                )}
                aria-hidden="true"
              >
                {entry.icon ?? <span className="size-1.5 rounded-full bg-current" />}
              </span>
              {!isLast ? <span className="mt-1 w-px flex-1 bg-line" aria-hidden="true" /> : null}
            </div>

            <div className={cn('min-w-0 flex-1 pt-0.5', alternate && 'lg:pl-8')}>
              <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
                <p className="text-sm font-semibold text-ink-strong">{entry.title}</p>
                <time dateTime={entry.timestamp} className="font-mono text-2xs text-ink-subtle">
                  {entry.displayTime}
                </time>
              </div>
              {entry.description ? (
                <div className="mt-1 text-sm text-ink-muted">{entry.description}</div>
              ) : null}
            </div>
          </li>
        )
      })}
    </ol>
  )
}

export interface ActivityItemProps {
  actor: ReactNode
  action: string
  target?: string
  timestamp: string
  displayTime: string
  meta?: ReactNode
  className?: string
}

export function ActivityItem({
  actor,
  action,
  target,
  timestamp,
  displayTime,
  meta,
  className,
}: ActivityItemProps) {
  return (
    <div className={cn('flex items-start gap-3 py-2.5', className)}>
      <div className="shrink-0">{actor}</div>
      <div className="min-w-0 flex-1">
        <p className="text-sm leading-snug text-ink">
          {action}
          {target ? <span className="font-medium text-ink-strong"> {target}</span> : null}
        </p>
        <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
          <time dateTime={timestamp} className="font-mono text-2xs text-ink-subtle">
            {displayTime}
          </time>
          {meta}
        </div>
      </div>
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For a sequence where the order is the meaning: release history, audit trail, incident timeline. Rendered as an ordered list precisely because the order is content.

  • Timestamps use `<time datetime>` so the machine-readable date and the human-readable one cannot drift.
  • Tone the marker by outcome, not by recency.
  • For dense in-product feeds use ActivityItem, which is the compact sibling.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Stacked
  • Tone-coded markers
  • With icons
  • With descriptions

Accessibility

Order
An `<ol>`, so assistive tech announces position and count.
Time
Every entry pairs a display string with an ISO `datetime` attribute.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.