GitHubX

Timeline

PreviousNext

A vertical or horizontal thread for displaying sequential data with composable items, markers, and content.

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/right for vertical, top/bottom for 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's variant prop

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

PropTypeDefault
direction"vertical" | "horizontal""vertical"
side"left" | "right" | "top" | "bottom"-
align"start" | "center""start"

TimelineItem

PropTypeDefault
side"left" | "right" | "top" | "bottom"inherit

TimelineMedia

PropTypeDefault
variant"dot" | "icon""dot"

Notes

  • align="start" (the default) produces a thread on one side of each item. Mixing per-item side values in start mode will shift the thread between items — use align="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.