Editorial

App Timeline

Vertical timeline as an ordered list: a ring-and-dot indicator or custom mark per item, a 1px rail joining them, title, description and small label. CSS variables tune dot size and alignment.

Free, MIT licenseOpen in Browse
import type { ComponentProps } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "cn"

/**
 * A vertical timeline, as an ordered list. Holds `TimelineItem` entries,
 * each with a mark and a line down to the next.
 *
 * Three CSS variables, set on it through `className` or `style`, tune the
 * marks. `--timeline-lh` is the line height of the first line in
 * `TimelineContent`, so the mark lines up with it: the default 1rem suits a
 * leading `TimelineLabel`; set 1.5rem when a title or body text comes first.
 * `--timeline-dot` is the mark's diameter (1rem). `--timeline-nudge` (1px)
 * lowers the mark slightly, because a mark centered on a line of text looks
 * high.
 */
function Timeline({ className, ...props }: ComponentProps<"ol">) {
  return (
    <ol
      role="list"
      data-slot="timeline"
      className={cn(
        "relative [--timeline-dot:1rem] [--timeline-lh:1rem] [--timeline-nudge:1px]",
        "[--timeline-shift:calc((var(--timeline-lh)-var(--timeline-dot))/2+var(--timeline-nudge))]",
        className
      )}
      {...props}
    />
  )
}

/**
 * One entry, an `li` by default. Put a `TimelineIndicator` and a
 * `TimelineContent` inside it. It draws the line from under its mark to the
 * next entry's mark; the last entry has none. A `Timeline` nested inside
 * `TimelineContent` keeps its own lines.
 */
function TimelineItem({
  className,
  children,
  render,
  ...props
}: useRender.ComponentProps<"li">) {
  return useRender({
    defaultTagName: "li",
    render,
    state: { slot: "timeline-item" },
    props: mergeProps<"li">(
      {
        className: cn(
          "group/timeline-item relative flex items-start gap-3",
          className
        ),
        children: (
          <>
            <div
              aria-hidden
              data-slot="timeline-line"
              className={cn(
                // Runs from under this mark to the top of the next, never through it. The
                // last-item check looks at the direct parent only, so a Timeline nested in
                // TimelineContent keeps its own rails.
                "pointer-events-none absolute top-[calc(var(--timeline-shift)+var(--timeline-dot))]",
                "left-[calc(var(--timeline-dot)/2)] w-px -translate-x-1/2",
                "h-[calc(100%-var(--timeline-dot))]",
                // A 1px rail can't get thinner, so it is lighter than the type.
                "bg-muted-foreground/45 [[data-slot=timeline-item]:last-child>&]:hidden"
              )}
            />
            {children}
          </>
        ),
      },
      props
    ),
  })
}

/**
 * The mark beside an entry. With no children it shows a small dot in a ring.
 * Pass an element, such as an icon, to show your own mark instead; the ring
 * and fill are then removed. Bare text and `false` are not supported.
 */
function TimelineIndicator({
  className,
  children,
  ...props
}: ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-indicator"
      className={cn(
        "relative z-10 mt-(--timeline-shift) flex shrink-0",
        "size-(--timeline-dot) items-center justify-center rounded-full",
        // No children: the default dot in its ring. An element child: your mark,
        // no ring. false, [] and bare text are not supported forms.
        "border-[1.5px] border-foreground bg-background",
        "has-[*:not([data-slot=timeline-dot])]:border-0",
        "has-[*:not([data-slot=timeline-dot])]:bg-transparent",
        className
      )}
      {...props}
    >
      {children ?? (
        <div
          data-slot="timeline-dot"
          className="size-1.5 rounded-full bg-foreground"
        />
      )}
    </div>
  )
}

/**
 * The text of an entry, beside its mark. It leaves 32px of space before the
 * next entry, and none after the last.
 */
function TimelineContent({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-content"
      className={cn(
        "min-w-0 flex-1 pb-8 [[data-slot=timeline-item]:last-child>&]:pb-0",
        className
      )}
      {...props}
    />
  )
}

/** The entry's title, in medium weight. It renders a `div`, not a heading. */
function TimelineTitle({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-title"
      className={cn("font-medium", className)}
      {...props}
    />
  )
}

/** Supporting text for an entry, small and muted, under the title. */
function TimelineDescription({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-description"
      className={cn("mt-1 text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

/**
 * A small muted line for an entry, such as a date. Its line height matches
 * the default `--timeline-lh`, so a label that comes first lines up with the
 * mark.
 */
function TimelineLabel({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-label"
      className={cn("text-xs/4 text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Timeline,
  TimelineItem,
  TimelineIndicator,
  TimelineContent,
  TimelineTitle,
  TimelineDescription,
  TimelineLabel,
}

Installation

pnpm dlx shadcn add @01orbital/timeline
npx shadcn add @01orbital/timeline
yarn shadcn add @01orbital/timeline
bunx --bun shadcn add @01orbital/timeline

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/timeline.tsx, as source you own and edit.

Usage

import {
  Timeline,
  TimelineContent,
  TimelineDescription,
  TimelineIndicator,
  TimelineItem,
  TimelineLabel,
  TimelineTitle,
} from "@/components/editorial/timeline"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  label: "Step",
} as const

const STEPS = [
  { title: "Explore", body: "Choose a direction." },
  { title: "Build", body: "Compose the pieces that fit." },
  { title: "Refine", body: "Make the details yours." },
] as const

export default function Demo() {
  return (
    <Timeline className="w-full max-w-lg">
      {STEPS.map(({ title, body }, i) => (
        <TimelineItem key={title}>
          <TimelineIndicator />
          <TimelineContent>
            <TimelineLabel>
              {COPY.label} {i + 1}
            </TimelineLabel>
            <TimelineTitle>{title}</TimelineTitle>
            <TimelineDescription>{body}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  )
}

Anatomy

App Timeline is built from 7 parts. In the example they nest like this:

Timeline
└─ TimelineItem
   ├─ TimelineIndicator
   └─ TimelineContent
      ├─ TimelineLabel
      ├─ TimelineTitle
      └─ TimelineDescription
  • Timeline: A vertical timeline, as an ordered list. Holds TimelineItem entries, each with a mark and a line down to the next.
  • TimelineItem: One entry, an li by default. Put a TimelineIndicator and a TimelineContent inside it. It draws the line from under its mark to the next entry's mark; the last entry has none. A Timeline nested inside TimelineContent keeps its own lines.
  • TimelineIndicator: The mark beside an entry. With no children it shows a small dot in a ring. Pass an element, such as an icon, to show your own mark instead; the ring and fill are then removed. Bare text and false are not supported.
  • TimelineContent: The text of an entry, beside its mark. It leaves 32px of space before the next entry, and none after the last.
  • TimelineLabel: A small muted line for an entry, such as a date. Its line height matches the default --timeline-lh, so a label that comes first lines up with the mark.
  • TimelineTitle: The entry's title, in medium weight. It renders a div, not a heading.
  • TimelineDescription: Supporting text for an entry, small and muted, under the title.

API reference

Timeline

A vertical timeline, as an ordered list. Holds TimelineItem entries, each with a mark and a line down to the next.

Three CSS variables, set on it through className or style, tune the marks. --timeline-lh is the line height of the first line in TimelineContent, so the mark lines up with it: the default 1rem suits a leading TimelineLabel; set 1.5rem when a title or body text comes first. --timeline-dot is the mark's diameter (1rem). --timeline-nudge (1px) lowers the mark slightly, because a mark centered on a line of text looks high.

It accepts the HTML attributes of the element it renders.

TimelineItem

One entry, an li by default. Put a TimelineIndicator and a TimelineContent inside it. It draws the line from under its mark to the next entry's mark; the last entry has none. A Timeline nested inside TimelineContent keeps its own lines.

It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

TimelineIndicator

The mark beside an entry. With no children it shows a small dot in a ring. Pass an element, such as an icon, to show your own mark instead; the ring and fill are then removed. Bare text and false are not supported.

It accepts the HTML attributes of the element it renders.

TimelineContent

The text of an entry, beside its mark. It leaves 32px of space before the next entry, and none after the last.

It accepts the HTML attributes of the element it renders.

TimelineLabel

A small muted line for an entry, such as a date. Its line height matches the default --timeline-lh, so a label that comes first lines up with the mark.

It accepts the HTML attributes of the element it renders.

TimelineTitle

The entry's title, in medium weight. It renders a div, not a heading.

It accepts the HTML attributes of the element it renders.

TimelineDescription

Supporting text for an entry, small and muted, under the title.

It accepts the HTML attributes of the element it renders.

Dependencies

npm: @base-ui/react, cn. The CLI installs them for you.

No other registry pieces.