A composable timeline built from primitives. Use it to visualize an ordered sequence of events — logs, activity feeds, product journeys, or step-by-step stories — either top-to-bottom or left-to-right.
<script setup lang="ts">
import { Timeline, TimelineContent, TimelineItem, TimelineMedia } from "@/components/timeline";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CircleCheckIcon, CircleDotIcon, GitMergeIcon, GitPullRequestIcon, TagIcon } from "lucide-vue-next";
type User = {
username: string;
avatar: string;
};
function initials(username: string) {
return username.slice(0, 2).toUpperCase();
}
const me: User = { username: "zeno", avatar: "https://github.com/fontanaen.png" };
const shadcn: User = { username: "shadcn", avatar: "https://github.com/shadcn.png" };
type Entry =
| { id: string; kind: "opened"; user: User; timestamp: string }
| { id: string; kind: "comment"; user: User; body: string; timestamp: string }
| { id: string; kind: "labeled"; user: User; label: string; labelClass: string; timestamp: string }
| { id: string; kind: "linked"; user: User; pr: { number: number; title: string }; timestamp: string }
| { id: string; kind: "merged"; user: User; pr: { number: number; branch: string }; timestamp: string }
| { id: string; kind: "closed"; user: User; timestamp: string };
const entries: Entry[] = [
{ id: "opened", kind: "opened", user: shadcn, timestamp: "3 days ago" },
{
id: "comment-1",
kind: "comment",
user: shadcn,
body: "Would love a composable Timeline component — something close to the shadcn-vue style, built on reka-ui primitives, supporting both vertical and horizontal layouts.",
timestamp: "3 days ago",
},
{
id: "labeled",
kind: "labeled",
user: me,
label: "enhancement",
labelClass: "bg-sky-500/15 text-sky-600 ring-sky-500/30 dark:text-sky-400",
timestamp: "2 days ago",
},
{
id: "linked",
kind: "linked",
user: me,
pr: { number: 42, title: "feat(registry): add Timeline" },
timestamp: "yesterday",
},
{
id: "comment-2",
kind: "comment",
user: me,
body: "Opened #42 with the initial primitives. Let me know if the API feels right before I wire up the docs.",
timestamp: "yesterday",
},
{
id: "merged",
kind: "merged",
user: shadcn,
pr: { number: 42, branch: "main" },
timestamp: "2h ago",
},
{ id: "closed", kind: "closed", user: shadcn, timestamp: "2h ago" },
];
</script>
<template>
<Timeline class="w-full max-w-xl">
<TimelineItem
v-for="(entry, index) in entries"
:key="entry.id"
class="animate-in fade-in slide-in-from-left-4 fill-mode-both duration-500"
:style="{ animationDelay: `${index * 90}ms` }"
>
<template v-if="entry.kind === 'comment'">
<TimelineMedia variant="icon">
<Avatar class="size-full">
<AvatarImage :src="entry.user.avatar" :alt="entry.user.username" />
<AvatarFallback>{{ initials(entry.user.username) }}</AvatarFallback>
</Avatar>
</TimelineMedia>
<TimelineContent>
<div class="overflow-hidden rounded-md border bg-card">
<div class="flex items-center justify-between gap-2 border-b bg-muted/40 px-3 py-2 text-sm">
<div>
<span class="font-semibold text-foreground">{{ entry.user.username }}</span>
<span class="text-muted-foreground"> commented</span>
</div>
<span class="text-xs text-muted-foreground">{{ entry.timestamp }}</span>
</div>
<p class="px-3 py-2 text-sm leading-relaxed">{{ entry.body }}</p>
</div>
</TimelineContent>
</template>
<template v-else>
<TimelineMedia variant="icon">
<CircleDotIcon v-if="entry.kind === 'opened'" class="size-4 text-emerald-500" />
<TagIcon v-else-if="entry.kind === 'labeled'" class="size-4" />
<GitPullRequestIcon v-else-if="entry.kind === 'linked'" class="size-4 text-emerald-500" />
<GitMergeIcon v-else-if="entry.kind === 'merged'" class="size-4 text-violet-500" />
<CircleCheckIcon v-else-if="entry.kind === 'closed'" class="size-4 text-violet-500" />
</TimelineMedia>
<TimelineContent>
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 pt-1.5 text-sm text-muted-foreground">
<Avatar class="size-5 text-[10px]">
<AvatarImage :src="entry.user.avatar" :alt="entry.user.username" />
<AvatarFallback>{{ initials(entry.user.username) }}</AvatarFallback>
</Avatar>
<span class="font-semibold text-foreground">{{ entry.user.username }}</span>
<template v-if="entry.kind === 'opened'">
<span>opened this issue</span>
</template>
<template v-if="entry.kind === 'labeled'">
<span>added the</span>
<span
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset',
entry.labelClass,
]"
>{{ entry.label }}</span>
<span>label</span>
</template>
<template v-if="entry.kind === 'linked'">
<span>linked a pull request</span>
<a href="#" class="font-medium text-foreground hover:underline">#{{ entry.pr.number }}</a>
<span class="text-foreground">{{ entry.pr.title }}</span>
</template>
<template v-if="entry.kind === 'merged'">
<span>merged pull request</span>
<a href="#" class="font-medium text-foreground hover:underline">#{{ entry.pr.number }}</a>
<span>into</span>
<code class="rounded bg-muted px-1 py-0.5 text-xs text-foreground">{{ entry.pr.branch }}</code>
</template>
<template v-if="entry.kind === 'closed'">
<span>closed this as completed</span>
</template>
<span aria-hidden="true">·</span>
<span>{{ entry.timestamp }}</span>
</div>
</TimelineContent>
</template>
</TimelineItem>
</Timeline>
</template>
Features
- Two directions — Stack items top-to-bottom (
vertical) or left-to-right (horizontal) - Side control — Place content on either side of the thread (
left/rightfor vertical,top/bottomfor horizontal) - Centered thread — Opt-in alternating layout with
align="center"for zigzag timelines - Dot or icon markers — Switch between a small dot or an icon slot via
TimelineMedia'svariantprop
Installation
Install from the Vuzeno registry with the shadcn-vue CLI:
bunx --bun shadcn-vue@latest add https://vuzeno.com/r/timeline.json
Anatomy
<template>
<Timeline direction="vertical">
<TimelineItem>
<TimelineMedia variant="icon" />
<TimelineContent>
<TimelineHeader>
<TimelineTitle />
<TimelineDescription />
</TimelineHeader>
<!-- Content -->
</TimelineContent>
</TimelineItem>
</Timeline>
</template>
Examples
Horizontal
Switch the orientation with direction="horizontal". The thread runs left-to-right and content stacks above or below each marker.
<script setup lang="ts">
import { Timeline, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMedia, TimelineTitle } from "@/components/timeline";
type TimelineStep = {
id: string;
title: string;
description: string;
};
const steps: TimelineStep[] = [
{ id: "draft", title: "Draft", description: "Idea captured" },
{ id: "design", title: "Design", description: "Specs approved" },
{ id: "build", title: "Build", description: "Implementation" },
{ id: "ship", title: "Ship", description: "Released to users" },
];
</script>
<template>
<Timeline direction="horizontal" class="mx-auto">
<TimelineItem v-for="step in steps" :key="step.id">
<TimelineMedia />
<TimelineContent>
<TimelineHeader>
<TimelineTitle>{{ step.title }}</TimelineTitle>
<TimelineDescription>{{ step.description }}</TimelineDescription>
</TimelineHeader>
</TimelineContent>
</TimelineItem>
</Timeline>
</template>
<script setup lang="ts">
import {
Timeline,
TimelineContent,
TimelineDescription,
TimelineHeader,
TimelineItem,
TimelineMedia,
TimelineTitle,
} from "@vuzeno/registry/ui/timeline";
</script>
<template>
<Timeline direction="horizontal" class="w-full">
<TimelineItem>
<TimelineMedia />
<TimelineContent>
<TimelineHeader>
<TimelineTitle>Draft</TimelineTitle>
<TimelineDescription>Idea captured</TimelineDescription>
</TimelineHeader>
</TimelineContent>
</TimelineItem>
<!-- more items... -->
</Timeline>
</template>
Alternating sides
Use align="center" on Timeline to center the thread, then set each TimelineItem's side to left or right (or top/bottom for horizontal) to build an alternating layout.
<script setup lang="ts">
import { Timeline, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMedia, TimelineTitle } from "@/components/timeline";
type TimelineEvent = {
id: string;
side: "left" | "right";
title: string;
description: string;
};
const events: TimelineEvent[] = [
{ id: "kickoff", side: "left", title: "Project kickoff", description: "Monday · 9:00 AM" },
{ id: "review", side: "right", title: "Design review", description: "Tuesday · 2:30 PM" },
{ id: "planning", side: "left", title: "Sprint planning", description: "Wednesday · 10:00 AM" },
{ id: "release", side: "right", title: "Release", description: "Friday · 5:00 PM" },
];
</script>
<template>
<Timeline align="center" class="w-full max-w-md">
<TimelineItem v-for="event in events" :key="event.id" :side="event.side">
<TimelineMedia />
<TimelineContent>
<TimelineHeader>
<TimelineTitle>{{ event.title }}</TimelineTitle>
<TimelineDescription>{{ event.description }}</TimelineDescription>
</TimelineHeader>
</TimelineContent>
</TimelineItem>
</Timeline>
</template>
<template>
<Timeline align="center" class="w-full max-w-md">
<TimelineItem side="left">
<TimelineMedia />
<TimelineContent>
<TimelineHeader>
<TimelineTitle>Project kickoff</TimelineTitle>
<TimelineDescription>Monday · 9:00 AM</TimelineDescription>
</TimelineHeader>
</TimelineContent>
</TimelineItem>
<TimelineItem side="right">
<TimelineMedia />
<TimelineContent>
<TimelineHeader>
<TimelineTitle>Design review</TimelineTitle>
<TimelineDescription>Tuesday · 2:30 PM</TimelineDescription>
</TimelineHeader>
</TimelineContent>
</TimelineItem>
<!-- more items... -->
</Timeline>
</template>
API Reference
Timeline
| Prop | Type | Default |
|---|---|---|
direction | "vertical" | "horizontal" | "vertical" |
side | "left" | "right" | "top" | "bottom" | - |
align | "start" | "center" | "start" |
TimelineItem
| Prop | Type | Default |
|---|---|---|
side | "left" | "right" | "top" | "bottom" | inherit |
TimelineMedia
| Prop | Type | Default |
|---|---|---|
variant | "dot" | "icon" | "dot" |
Notes
align="start"(the default) produces a thread on one side of each item. Mixing per-itemsidevalues instartmode will shift the thread between items — usealign="center"for alternating layouts.- The connector line is hidden on the last item via a
last:variant, so a single-item timeline shows only the marker.