Timeline
Events on a single thread, so a payment, an order or a dispute can be read from start to finish.
Payment created
Payment created from the API.
Checkout page opened
Payer selected Airtel Money KE.
Payment succeeded
Funds confirmed by the provider.
import { Timeline, TimelineItem } from '@/components/timeline'
import { CheckCircleIcon, CreditCardIcon, CursorArrowRaysIcon } from '@heroicons/react/16/solid'
export default function Example() {
return (
<Timeline>
<TimelineItem
icon={CreditCardIcon}
title="Payment created"
time="Aug 13, 2026, 3:02 PM"
dateTime="2026-08-13T15:02:00Z"
description="Payment created from the API."
/>
<TimelineItem
icon={CursorArrowRaysIcon}
title="Checkout page opened"
time="Aug 13, 2026, 3:03 PM"
dateTime="2026-08-13T15:03:00Z"
description="Payer selected Airtel Money KE."
/>
<TimelineItem
icon={CheckCircleIcon}
color="green"
title="Payment succeeded"
time="Aug 13, 2026, 3:05 PM"
dateTime="2026-08-13T15:05:00Z"
description="Funds confirmed by the provider."
/>
</Timeline>
)
}<script setup lang="ts">
import Timeline from '@/components/timeline.vue'
import TimelineItem from '@/components/timeline-item.vue'
import { CheckCircleIcon, CreditCardIcon, CursorArrowRaysIcon } from '@heroicons/vue/16/solid'
</script>
<template>
<Timeline>
<TimelineItem
:icon="CreditCardIcon"
title="Payment created"
time="Aug 13, 2026, 3:02 PM"
date-time="2026-08-13T15:02:00Z"
description="Payment created from the API."
/>
<TimelineItem
:icon="CursorArrowRaysIcon"
title="Checkout page opened"
time="Aug 13, 2026, 3:03 PM"
date-time="2026-08-13T15:03:00Z"
description="Payer selected Airtel Money KE."
/>
<TimelineItem
:icon="CheckCircleIcon"
color="green"
title="Payment succeeded"
time="Aug 13, 2026, 3:05 PM"
date-time="2026-08-13T15:05:00Z"
description="Funds confirmed by the provider."
/>
</Timeline>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
const colors = {
zinc: 'fill-zinc-400 dark:fill-zinc-500',
red: 'fill-red-600 dark:fill-red-400',
orange: 'fill-orange-600 dark:fill-orange-400',
amber: 'fill-amber-600 dark:fill-amber-400',
yellow: 'fill-yellow-600 dark:fill-yellow-400',
lime: 'fill-lime-600 dark:fill-lime-400',
green: 'fill-green-600 dark:fill-green-400',
emerald: 'fill-emerald-600 dark:fill-emerald-400',
teal: 'fill-teal-600 dark:fill-teal-400',
cyan: 'fill-cyan-600 dark:fill-cyan-400',
sky: 'fill-sky-600 dark:fill-sky-400',
blue: 'fill-blue-600 dark:fill-blue-400',
indigo: 'fill-indigo-600 dark:fill-indigo-400',
violet: 'fill-violet-600 dark:fill-violet-400',
purple: 'fill-purple-600 dark:fill-purple-400',
fuchsia: 'fill-fuchsia-600 dark:fill-fuchsia-400',
pink: 'fill-pink-600 dark:fill-pink-400',
rose: 'fill-rose-600 dark:fill-rose-400',
}
export function Timeline({ className, ...props }: React.ComponentPropsWithoutRef<'ol'>) {
return <ol role="list" {...props} className={className} />
}
export function TimelineItem({
icon: Icon,
color = 'zinc',
marker,
title,
time,
dateTime,
description,
action,
className,
children,
...props
}: {
icon?: React.ComponentType<{ className?: string }>
color?: keyof typeof colors
marker?: React.ReactNode
title?: React.ReactNode
time?: React.ReactNode
dateTime?: string
description?: React.ReactNode
action?: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<'li'>, 'title'>) {
return (
<li {...props} className={clsx(className, 'group/item relative flex gap-3 pb-6 last:pb-0')}>
<span
aria-hidden="true"
className="absolute top-7 bottom-0 left-3 w-px -translate-x-1/2 bg-zinc-950/10 group-last/item:hidden dark:bg-white/10"
/>
<span className="relative flex size-6 shrink-0 items-center justify-center rounded-full bg-white ring-1 ring-zinc-950/10 dark:bg-zinc-900 dark:ring-white/10">
{marker ?? (Icon && <Icon className={clsx('size-4 shrink-0', colors[color])} />)}
</span>
<div className="min-w-0 flex-1 pt-0.5">
{(title || time || action) && (
<div className="flex flex-wrap items-baseline justify-between gap-x-4">
{title && (
<p className="min-w-0 text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">{title}</p>
)}
{(time || action) && (
<div className="flex items-center gap-1.5">
{time && (
<time
dateTime={dateTime}
className="text-base/6 whitespace-nowrap text-zinc-500 tabular-nums sm:text-sm/6 dark:text-zinc-400"
>
{time}
</time>
)}
{action}
</div>
)}
</div>
)}
{description && (
<p className="text-base/6 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">{description}</p>
)}
{children}
</div>
</li>
)
}<script setup lang="ts">
</script>
<template>
<ol role="list">
<slot />
</ol>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Timelinerenders a <ol> element | ||
| This component does not expose any component-specific props. | ||
TimelineItemrenders a <li> element | ||
icon | - | A 16px Heroicon drawn in the marker. |
color | zinc | Tint of the icon. Same palette as the badge colors. |
marker | - | Replaces the icon in the marker: a logo, an avatar. |
title | - | First line of the event. Accepts any node, a name followed by a `Code` for example. |
time | - | Rendered opposite the title, in a `<time>` element. |
dateTime | - | Machine-readable value for `time`, an ISO 8601 string. |
description | - | Line of context under the title. |
action | - | Rendered after the time: a toggle, a menu. |
| Prop | Default | Description |
|---|---|---|
Timelinerenders a <ol> element | ||
| This component does not expose any component-specific props. | ||
TimelineItemrenders a <li> element | ||
icon | - | A 16px Heroicon drawn in the marker. |
color | zinc | Tint of the icon. Same palette as the badge colors. |
title | - | First line of the event. The `title` slot overrides it for rich content, a name followed by a `Code` for example. |
time | - | Rendered opposite the title, in a `<time>` element. |
dateTime | - | Machine-readable value for `time`, an ISO 8601 string. |
description | - | Line of context under the title. |
#marker | - | Slot replacing the icon in the marker: a logo, an avatar. |
#action | - | Slot rendered after the time: a toggle, a menu. |
Examples
Basic example
Each item takes an icon, a title, a time and a description. Pass the ISO value in dateTime so the time is machine-readable:
Payment created
Payment created from the API.
Checkout page opened
Payer selected Airtel Money KE.
Payment succeeded
Funds confirmed by the provider.
import { Timeline, TimelineItem } from '@/components/timeline'
import { CheckCircleIcon, CreditCardIcon, CursorArrowRaysIcon } from '@heroicons/react/16/solid'
export default function Example() {
return (
<Timeline>
<TimelineItem
icon={CreditCardIcon}
title="Payment created"
time="Aug 13, 2026, 3:02 PM"
dateTime="2026-08-13T15:02:00Z"
description="Payment created from the API."
/>
<TimelineItem
icon={CursorArrowRaysIcon}
title="Checkout page opened"
time="Aug 13, 2026, 3:03 PM"
dateTime="2026-08-13T15:03:00Z"
description="Payer selected Airtel Money KE."
/>
<TimelineItem
icon={CheckCircleIcon}
color="green"
title="Payment succeeded"
time="Aug 13, 2026, 3:05 PM"
dateTime="2026-08-13T15:05:00Z"
description="Funds confirmed by the provider."
/>
</Timeline>
)
}<script setup lang="ts">
import Timeline from '@/components/timeline.vue'
import TimelineItem from '@/components/timeline-item.vue'
import { CheckCircleIcon, CreditCardIcon, CursorArrowRaysIcon } from '@heroicons/vue/16/solid'
</script>
<template>
<Timeline>
<TimelineItem
:icon="CreditCardIcon"
title="Payment created"
time="Aug 13, 2026, 3:02 PM"
date-time="2026-08-13T15:02:00Z"
description="Payment created from the API."
/>
<TimelineItem
:icon="CursorArrowRaysIcon"
title="Checkout page opened"
time="Aug 13, 2026, 3:03 PM"
date-time="2026-08-13T15:03:00Z"
description="Payer selected Airtel Money KE."
/>
<TimelineItem
:icon="CheckCircleIcon"
color="green"
title="Payment succeeded"
time="Aug 13, 2026, 3:05 PM"
date-time="2026-08-13T15:05:00Z"
description="Funds confirmed by the provider."
/>
</Timeline>
</template>Colors
Use color to tint the icon. Keep it for the outcome of an event, settled, failed or pending, and leave the rest neutral so the tinted ones stand out:
Refund requested
Full amount, requested from the dashboard.
Refund failed
No confirmation received from the operator within 90s.
Refund issued
Funds returned to the payer wallet.
<Timeline>
<TimelineItem icon={ArrowUturnLeftIcon} color="amber" title="Refund requested" time="Sep 2, 2026, 9:14 AM" description="Full amount, requested from the dashboard." />
<TimelineItem icon={XCircleIcon} color="red" title="Refund failed" time="Sep 2, 2026, 9:15 AM" description="No confirmation received from the operator within 90s." />
<TimelineItem icon={CheckCircleIcon} color="green" title="Refund issued" time="Sep 2, 2026, 9:41 AM" description="Funds returned to the payer wallet." />
</Timeline><template>
<Timeline>
<TimelineItem :icon="ArrowUturnLeftIcon" color="amber" title="Refund requested" time="Sep 2, 2026, 9:14 AM" description="Full amount, requested from the dashboard." />
<TimelineItem :icon="XCircleIcon" color="red" title="Refund failed" time="Sep 2, 2026, 9:15 AM" description="No confirmation received from the operator within 90s." />
<TimelineItem :icon="CheckCircleIcon" color="green" title="Refund issued" time="Sep 2, 2026, 9:41 AM" description="Funds returned to the payer wallet." />
</Timeline>
</template>Custom markers
Pass a marker to replace the icon with anything sized to the ring, an avatar for the person behind the event, a logo for a provider:
Dispute opened
Chargeback from the issuing bank, evidence due in 7 days.
Kofi Mensah assigned the dispute to you

Ines Carvalho commented
Evidence uploaded, the payer confirmed the delivery address.
import { Avatar } from '@/components/avatar'
import { Timeline, TimelineItem } from '@/components/timeline'
import { ScaleIcon } from '@heroicons/react/16/solid'
export default function Example() {
return (
<Timeline>
<TimelineItem
icon={ScaleIcon}
title="Dispute opened"
time="Sep 1, 2026"
description="Chargeback from the issuing bank, evidence due in 7 days."
/>
<TimelineItem
marker={<Avatar initials="NB" className="size-6 bg-zinc-900 text-white dark:bg-white dark:text-black" />}
title="Kofi Mensah assigned the dispute to you"
time="Yesterday"
/>
<TimelineItem
marker={<Avatar src="/users/avatar.jpg" className="size-6" />}
title="Ines Carvalho commented"
time="2h ago"
description="Evidence uploaded, the payer confirmed the delivery address."
/>
</Timeline>
)
}<script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import Timeline from '@/components/timeline.vue'
import TimelineItem from '@/components/timeline-item.vue'
import { ScaleIcon } from '@heroicons/vue/16/solid'
</script>
<template>
<Timeline>
<TimelineItem
:icon="ScaleIcon"
title="Dispute opened"
time="Sep 1, 2026"
description="Chargeback from the issuing bank, evidence due in 7 days."
/>
<TimelineItem title="Kofi Mensah assigned the dispute to you" time="Yesterday">
<template #marker>
<Avatar initials="NB" class="size-6 bg-zinc-900 text-white dark:bg-white dark:text-black" />
</template>
</TimelineItem>
<TimelineItem
title="Ines Carvalho commented"
time="2h ago"
description="Evidence uploaded, the payer confirmed the delivery address."
>
<template #marker>
<Avatar src="/users/avatar.jpg" class="size-6" />
</template>
</TimelineItem>
</Timeline>
</template>Without icons
Leave out the icon and the marker stays a plain ring, for a list where every entry is of the same kind, a delivery status or a changelog:
Order placed
Paid with Airtel Money.
Packed
Two parcels, 3.2 kg.
Handed to the carrier
Delivered
Signed by the recipient.
<Timeline>
<TimelineItem title="Order placed" time="Aug 13" description="Paid with Airtel Money." />
<TimelineItem title="Packed" time="Aug 14" description="Two parcels, 3.2 kg." />
<TimelineItem title="Handed to the carrier" time="Aug 14" />
<TimelineItem title="Delivered" time="Aug 16" description="Signed by the recipient." />
</Timeline><template>
<Timeline>
<TimelineItem title="Order placed" time="Aug 13" description="Paid with Airtel Money." />
<TimelineItem title="Packed" time="Aug 14" description="Two parcels, 3.2 kg." />
<TimelineItem title="Handed to the carrier" time="Aug 14" />
<TimelineItem title="Delivered" time="Aug 16" description="Signed by the recipient." />
</Timeline>
</template>Expandable details
The action slot sits after the time. Put a toggle there and render the details as children: badges, the account used, then a grid of facts that collapses to two columns on small screens:
The action slot sits after the time. Put a toggle there and render the details in the default slot: badges, the account used, then a grid of facts that collapses to two columns on small screens:
Payment created
Payment created from the API.
Checkout page opened
Payer selected Airtel Money KE.
Airtel Money
ke.airtelSucceeded+254 •• •• •• 25 Mobile money
- Duration
- 1.5s
- External ID
- EXT-49414L4SATP
- IP address
- 41.75.75.75
- Reference
- trx_W49414L4SATP
- Risk score
- 3/100 · Low risk
Payment succeeded
Funds confirmed by the provider.
'use client'
import { Avatar } from '@/components/avatar'
import { Badge } from '@/components/badge'
import { TouchTarget } from '@/components/button'
import { Code } from '@/components/text'
import { TimelineItem } from '@/components/timeline'
import { ChevronDownIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import { useId, useState } from 'react'
export function ProviderCall() {
let [open, setOpen] = useState(true)
let panelId = useId()
return (
<TimelineItem
marker={<Avatar initials="AM" className="size-6 bg-red-600 text-white" />}
title={<>Airtel Money <Code>ke.airtel</Code></>}
time="Aug 13, 2026, 3:04 PM"
dateTime="2026-08-13T15:04:00Z"
action={
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
aria-label="Toggle details"
onClick={() => setOpen(!open)}
className="relative -my-1 cursor-pointer rounded-md p-1 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-blue-500 dark:hover:bg-white/10"
>
<TouchTarget>
<ChevronDownIcon className={clsx('size-4 fill-zinc-400 transition-transform', open && 'rotate-180')} />
</TouchTarget>
</button>
}
>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge color="green">Succeeded</Badge>
</div>
<dl id={panelId} hidden={!open} className="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<div>
<dt className="text-sm/6 text-zinc-500 sm:text-xs/6">Duration</dt>
<dd className="text-base/6 text-zinc-950 tabular-nums sm:text-sm/6">1.5s</dd>
</div>
{/* … */}
</dl>
</TimelineItem>
)
}<script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import Badge from '@/components/badge.vue'
import Code from '@/components/code.vue'
import TimelineItem from '@/components/timeline-item.vue'
import TouchTarget from '@/components/touch-target.vue'
import { ChevronDownIcon } from '@heroicons/vue/16/solid'
import { ref, useId } from 'vue'
const open = ref(true)
const panelId = useId()
</script>
<template>
<TimelineItem time="Aug 13, 2026, 3:04 PM" date-time="2026-08-13T15:04:00Z">
<template #marker>
<Avatar initials="AM" class="size-6 bg-red-600 text-white" />
</template>
<template #title>Airtel Money <Code>ke.airtel</Code></template>
<template #action>
<button
type="button"
:aria-expanded="open"
:aria-controls="panelId"
aria-label="Toggle details"
class="relative -my-1 cursor-pointer rounded-md p-1 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-blue-500 dark:hover:bg-white/10"
@click="open = !open"
>
<TouchTarget>
<ChevronDownIcon class="size-4 fill-zinc-400 transition-transform" :class="open && 'rotate-180'" />
</TouchTarget>
</button>
</template>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Badge color="green">Succeeded</Badge>
</div>
<dl v-show="open" :id="panelId" class="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<div>
<dt class="text-sm/6 text-zinc-500 sm:text-xs/6">Duration</dt>
<dd class="text-base/6 text-zinc-950 tabular-nums sm:text-sm/6">1.5s</dd>
</div>
</dl>
</TimelineItem>
</template>