Timeline

A list of things that happened, in order, joined by a line.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add timeline
pnpm dlx ermineui add timeline
yarn dlx ermineui add timeline
bunx ermineui add timeline

That copies 3 files into src/components/ui/timeline/. Files you already have are kept.

Use

import { Timeline, TimelineItem } from '@/components/ui/timeline'
<Tour v-model:open="touring" :steps="[
  { target: '#deploy-button', title: 'Deploy from here', description: 'Or just push to main.' },
  { target: '#search', title: 'Find anything', side: 'bottom' },
]" />

<Banner v-model:open="showBanner" variant="info" dismissible>
  Scheduled maintenance on Sunday.
  <template #action><a href="/status">Details</a></template>
</Banner>

<Timeline>
  <TimelineItem title="Promoted to production" time="12 min ago" tone="success">4f2c9a1 by Ines.</TimelineItem>
</Timeline>

<CodeBlock :code="snippet" filename="nuxt.config.ts" line-numbers />
<CopyButton value="dpl_12d5" label="Copy deployment id" />
Press <Kbd>⌘</Kbd> <Kbd>K</Kbd>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Timeline.vuesrc/components/ui/timeline/Timeline.vue
TimelineItem.vuesrc/components/ui/timeline/TimelineItem.vue
index.tssrc/components/ui/timeline/index.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Timeline.vue 106 lines
<template>
  <ol class="er-timeline"><slot /></ol>
</template>

<style>
@layer components {
  .er-timeline {
    display: grid;
    margin: 0;
    padding: 0;
    font-size: 0.875rem;
    list-style: none;
  }

  .er-timeline-item {
    position: relative;
    display: grid;
    grid-template-columns: 1.5rem 1fr;
    gap: 0 0.75rem;
    padding-bottom: 1.25rem;
  }

  .er-timeline-item:last-child {
    padding-bottom: 0;
  }

  /* The line runs from under this marker to the next item's marker. */
  .er-timeline-item:not(:last-child)::before {
    content: '';
    position: absolute;
    top: 1.625rem;
    bottom: 0.125rem;
    left: calc(0.75rem - 0.5px);
    width: 1px;
    background: var(--er-border);
  }

  .er-timeline-marker {
    --er-timeline-color: var(--er-muted-foreground);
    display: grid;
    place-items: center;
    width: 1.5rem;
    height: 1.5rem;
    border-radius: 50%;
    background: color-mix(in oklab, var(--er-timeline-color) 14%, var(--er-background));
    color: var(--er-timeline-color);
  }

  .er-timeline-item[data-tone='success'] .er-timeline-marker {
    --er-timeline-color: var(--er-success);
  }

  .er-timeline-item[data-tone='warning'] .er-timeline-marker {
    --er-timeline-color: var(--er-warning);
  }

  .er-timeline-item[data-tone='destructive'] .er-timeline-marker {
    --er-timeline-color: var(--er-destructive);
  }

  .er-timeline-marker svg {
    width: 0.75rem;
    height: 0.75rem;
  }

  /* Without an icon the marker is a plain dot. */
  .er-timeline-dot {
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background: currentColor;
  }

  .er-timeline-body {
    display: grid;
    gap: 0.125rem;
    min-width: 0;
    padding-top: 0.125rem;
  }

  .er-timeline-heading {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: 0 0.75rem;
  }

  .er-timeline-title {
    font-weight: var(--er-weight-medium);
  }

  .er-timeline-time {
    color: var(--er-muted-foreground);
    font-size: 0.8125rem;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  .er-timeline-content {
    color: var(--er-muted-foreground);
    line-height: 1.5;
  }
}
</style>
TimelineItem.vue 30 lines
<script setup lang="ts">
withDefaults(
  defineProps<{
    title: string
    /** When it happened, already written out the way it should read ("2 hours ago", "Oct 6, 14:20"). */
    time?: string
    /** For machines: the same moment as an ISO date, e.g. "2026-10-06T14:20". */
    datetime?: string
    /** Colours the marker. Say what happened in the title too — colour alone is not enough. */
    tone?: 'default' | 'success' | 'warning' | 'destructive'
  }>(),
  { tone: 'default' },
)
</script>

<template>
  <li class="er-timeline-item" :data-tone="tone">
    <span class="er-timeline-marker" aria-hidden="true">
      <slot name="icon"><span class="er-timeline-dot" /></slot>
    </span>
    <div class="er-timeline-body">
      <div class="er-timeline-heading">
        <span class="er-timeline-title">{{ title }}</span>
        <time v-if="time" class="er-timeline-time" :datetime="datetime">{{ time }}</time>
      </div>
      <div v-if="$slots.default" class="er-timeline-content"><slot /></div>
    </div>
  </li>
</template>
index.ts 4 lines
/** Timeline — a list of things that happened, in order, joined by a line. */
export { default as Timeline } from './Timeline.vue'
export { default as TimelineItem } from './TimelineItem.vue'