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.
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,
}
pnpm dlx shadcn add @01orbital/timelinenpx shadcn add @01orbital/timelineyarn shadcn add @01orbital/timelinebunx --bun shadcn add @01orbital/timelineFree 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.
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>
)
}App Timeline is built from 7 parts. In the example they nest like this:
Timeline
└─ TimelineItem
├─ TimelineIndicator
└─ TimelineContent
├─ TimelineLabel
├─ TimelineTitle
└─ TimelineDescriptionTimeline: 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.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.
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.
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.
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.
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.
The entry's title, in medium weight. It renders a div, not a heading.
It accepts the HTML attributes of the element it renders.
Supporting text for an entry, small and muted, under the title.
It accepts the HTML attributes of the element it renders.
npm: @base-ui/react, cn. The CLI installs them for you.
No other registry pieces.
Announcement
News pill link built on the secondary Badge, with a headline that truncates first and a Learn more action set off by a short vertical Separator. Renders an anchor through Base UI useRender.
Eyebrow
Short label set above a heading. Default is 12px uppercase Geist Mono with wide tracking in muted ink; sans is a 14px semibold line in sentence case. Pulls in the Geist Mono font item.