Timeline
timelineuiVertical or horizontal sequence of events with connectors and node markers. Statuses (pending, current, completed, failed) tint the connector. Use for activity feeds, audit logs, and progress tracking.
When to use which
Decision GuideTimeline showcases chronological events and milestones past-to-present. Stepper guides users through a linear, multi-step workflow requiring active user progression.
| Component | Role | Use when |
|---|---|---|
| Timelinecurrent Chronological feeds, delivery tracking logs, audit trails, and product roadmap milestones. | Activity & Milestone Stream | |
| Stepper Multi-step wizards, checkout sequences, onboarding flows, and form completion tracking. | Workflow Progression |
- Activity logs, tracking events, & milestones → Timeline
- Multi-step wizard or form progression → Stepper (view)
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/timeline.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/timeline.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/timeline.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/timeline.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/timelineInstalls to:app/components/ui/timeline/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'dot''icon''avatar''outline' | dot | optional |
status | 'default''current''success''warning''error''info''muted' | default | optional |
class | HTMLAttributes['class'] | — | optional |
direction | TimelineDirection | 'vertical' | optional |
align | TimelineAlign | 'start' | optional |
side | TimelineSide | — | optional |
density | TimelineDensity | 'default' | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TimelineContextinterface TimelineContext {
direction: Ref<TimelineDirection>
align: Ref<TimelineAlign>
side: Ref<TimelineSide>
density: Ref<TimelineDensity>
itemIds: Ref<symbol[]>
register: (id: symbol) => void
unregister: (id: symbol) => void
}TimelineItemContextinterface TimelineItemContext {
index: Ref<number>
isFirst: Ref<boolean>
isLast: Ref<boolean>
side: Ref<TimelineSide>
status: Ref<TimelineStatus>
direction: Ref<TimelineDirection>
density: Ref<TimelineDensity>
}npm dependencies
Used by
Files installed (12)
app/components/ui/timeline/Timeline.vue1.3 kB
<script setup lang="ts"> import { provide, ref, toRef } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { TIMELINE_CONTEXT, type TimelineAlign, type TimelineDensity, type TimelineDirection, type TimelineSide, } from './context' const props = withDefaults( defineProps<{ class?: HTMLAttributes['class'] direction?: TimelineDirection align?: TimelineAlign side?: TimelineSide density?: TimelineDensity }>(), { direction: 'vertical', align: 'start', density: 'default', }, ) const itemIds = ref<symbol[]>([]) provide(TIMELINE_CONTEXT, { direction: toRef(props, 'direction'), align: toRef(props, 'align'), side: toRef(() => props.side ?? (props.direction === 'horizontal' ? 'top' : 'left')), density: toRef(props, 'density'), itemIds, register: (id) => { if (!itemIds.value.includes(id)) itemIds.value.push(id) }, unregister: (id) => { itemIds.value = itemIds.value.filter((i) => i !== id) }, }) </script> <template> <div data-uipkge data-slot="timeline" :data-direction="direction" :data-align="align" :class="cn('relative', direction === 'vertical' ? 'flex flex-col' : 'flex flex-row', props.class)" v-bind="$attrs" > <slot /> </div> </template>app/components/ui/timeline/TimelineItem.vue5.2 kB
<script setup lang="ts"> import { computed, inject, onBeforeUnmount, provide, toRef } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { TIMELINE_CONTEXT, TIMELINE_ITEM_CONTEXT, type TimelineSide, type TimelineStatus } from './context' const props = withDefaults( defineProps<{ class?: HTMLAttributes['class'] side?: TimelineSide status?: TimelineStatus }>(), { status: 'default', }, ) const ctx = inject(TIMELINE_CONTEXT, null) const id = Symbol('TimelineItem') if (ctx) { ctx.register(id) onBeforeUnmount(() => ctx.unregister(id)) } const index = computed(() => (ctx ? ctx.itemIds.value.indexOf(id) : 0)) const isFirst = computed(() => index.value === 0) const isLast = computed(() => (ctx ? index.value === ctx.itemIds.value.length - 1 : false)) const effectiveSide = computed<TimelineSide>(() => { if (props.side) return props.side if (!ctx) return 'left' if (ctx.align.value === 'center') { if (ctx.direction.value === 'vertical') { return index.value % 2 === 0 ? 'left' : 'right' } return index.value % 2 === 0 ? 'top' : 'bottom' } return ctx.side.value }) const direction = computed(() => ctx?.direction.value ?? 'vertical') const density = computed(() => ctx?.density.value ?? 'default') const isCenter = computed(() => ctx?.align.value === 'center') const verticalSpacing = computed(() => { if (isLast.value) return '' return { compact: '[&>[data-slot=timeline-content]]:pb-3', default: '[&>[data-slot=timeline-content]]:pb-6', comfortable: '[&>[data-slot=timeline-content]]:pb-10', }[density.value] }) const horizontalSpacing = computed(() => { if (isLast.value) return '' return { compact: '[&>[data-slot=timeline-content]]:pr-3', default: '[&>[data-slot=timeline-content]]:pr-6', comfortable: '[&>[data-slot=timeline-content]]:pr-10', }[density.value] }) provide(TIMELINE_ITEM_CONTEXT, { index, isFirst, isLast, side: effectiveSide, status: toRef(props, 'status'), direction, density, }) </script> <template> <div data-uipkge data-slot="timeline-item" :data-side="effectiveSide" :data-status="status" :data-last="isLast || undefined" :style="{ '--timeline-stagger': `${Math.min(index, 12) * 55}ms` }" :class=" cn( 'timeline-item-enter relative', status === 'current' && 'timeline-item-current', !isCenter && direction === 'vertical' && [ 'flex items-start gap-4', effectiveSide === 'right' && 'flex-row-reverse text-right', verticalSpacing, ], !isCenter && direction === 'horizontal' && [ 'flex flex-col items-start gap-2', effectiveSide === 'bottom' && 'flex-col-reverse', horizontalSpacing, ], isCenter && direction === 'vertical' && [ 'grid grid-cols-[1fr_auto_1fr] items-start gap-x-4', '[&>[data-slot=timeline-media]]:col-start-2 [&>[data-slot=timeline-media]]:row-start-1', '[&>[data-slot=timeline-separator]]:col-start-2 [&>[data-slot=timeline-separator]]:row-start-1', '[&>[data-slot=timeline-content]]:row-start-1', effectiveSide === 'left' && '[&>[data-slot=timeline-content]]:col-start-1 [&>[data-slot=timeline-content]]:text-right', effectiveSide === 'right' && '[&>[data-slot=timeline-content]]:col-start-3', verticalSpacing, ], isCenter && direction === 'horizontal' && [ 'grid grid-rows-[1fr_auto_1fr] items-start gap-y-2', '[&>[data-slot=timeline-media]]:col-start-1 [&>[data-slot=timeline-media]]:row-start-2', '[&>[data-slot=timeline-separator]]:col-start-1 [&>[data-slot=timeline-separator]]:row-start-2', '[&>[data-slot=timeline-content]]:col-start-1', effectiveSide === 'top' && '[&>[data-slot=timeline-content]]:row-start-1 [&>[data-slot=timeline-content]]:self-end', effectiveSide === 'bottom' && '[&>[data-slot=timeline-content]]:row-start-3', horizontalSpacing, ], props.class, ) " v-bind="$attrs" > <slot :index="index" :is-last="isLast" :side="effectiveSide" :status="status" /> </div> </template> <style> @keyframes timeline-item-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } @keyframes timeline-current-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 45%, transparent); } 50% { box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 0%, transparent); } } .timeline-item-enter { animation: timeline-item-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both; animation-delay: var(--timeline-stagger, 0ms); } .timeline-item-current [data-slot='timeline-media-marker'], .timeline-item-current [data-slot='timeline-separator-marker'] { animation: timeline-current-pulse 1.8s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { .timeline-item-enter, .timeline-item-current [data-slot='timeline-media-marker'], .timeline-item-current [data-slot='timeline-separator-marker'] { animation: none !important; } } </style>app/components/ui/timeline/TimelineMedia.vue2.8 kB
<script setup lang="ts"> import { computed, inject } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { TIMELINE_ITEM_CONTEXT, type TimelineStatus } from './context' import { timelineMediaVariants, type TimelineMediaVariant } from './timeline.variants' const props = withDefaults( defineProps<{ class?: HTMLAttributes['class'] variant?: TimelineMediaVariant status?: TimelineStatus /** Manually hide the connector line. */ hideConnector?: boolean /** * Color the connector line using the item's status * (success → green, error → red, etc.) instead of the neutral border. */ coloredConnector?: boolean /** Line style for the connector. */ lineStyle?: 'solid' | 'dashed' | 'dotted' }>(), { variant: 'dot', lineStyle: 'solid', }, ) const item = inject(TIMELINE_ITEM_CONTEXT, null) const direction = computed(() => item?.direction.value ?? 'vertical') const isLast = computed(() => item?.isLast.value ?? true) const effectiveStatus = computed<TimelineStatus>(() => props.status ?? item?.status.value ?? 'default') const showConnector = computed(() => !props.hideConnector && !isLast.value) const connectorBgClass = computed(() => { if (props.lineStyle === 'dashed') { return direction.value === 'vertical' ? 'border-l-2 border-dashed border-border bg-transparent w-0' : 'border-t-2 border-dashed border-border bg-transparent h-0' } if (props.lineStyle === 'dotted') { return direction.value === 'vertical' ? 'border-l-2 border-dotted border-border bg-transparent w-0' : 'border-t-2 border-dotted border-border bg-transparent h-0' } if (!props.coloredConnector) return 'bg-border' return { default: 'bg-primary', current: 'bg-primary', success: 'bg-success', warning: 'bg-warning', error: 'bg-destructive', info: 'bg-info', muted: 'bg-muted-foreground/40', }[effectiveStatus.value] }) </script> <template> <div data-uipkge data-slot="timeline-media" :data-variant="variant" :class=" cn( 'relative flex shrink-0 items-center', direction === 'vertical' ? 'flex-col self-stretch' : 'flex-row items-center self-stretch', props.class, ) " > <!-- Marker --> <div data-uipkge data-slot="timeline-media-marker" :class=" cn( timelineMediaVariants({ variant, status: effectiveStatus }), direction === 'vertical' && variant === 'dot' && 'mt-1', ) " > <slot /> </div> <!-- Connector line --> <div v-if="showConnector" data-uipkge data-slot="timeline-media-connector" aria-hidden="true" :class="cn(direction === 'vertical' ? 'my-1 w-px flex-1' : 'mx-1 h-px flex-1', connectorBgClass)" /> </div> </template>app/components/ui/timeline/TimelineSeparator.vue1.4 kB
<script setup lang="ts"> import { computed, inject } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { TIMELINE_ITEM_CONTEXT } from './context' const props = defineProps<{ class?: HTMLAttributes['class'] hideConnector?: boolean }>() const item = inject(TIMELINE_ITEM_CONTEXT, null) const direction = computed(() => item?.direction.value ?? 'vertical') const isLast = computed(() => item?.isLast.value ?? true) const showConnector = computed(() => !props.hideConnector && !isLast.value) </script> <template> <div data-uipkge data-slot="timeline-separator" :class=" cn( 'relative flex shrink-0 items-center', direction === 'vertical' ? 'w-4 flex-col self-stretch' : 'h-4 flex-row items-center self-stretch', props.class, ) " v-bind="$attrs" > <div data-slot="timeline-separator-marker" class="bg-primary ring-background relative z-10 flex size-4 items-center justify-center rounded-full shadow-2xs ring-4" > <slot name="dot"> <div class="bg-primary-foreground size-1.5 rounded-full" /> </slot> </div> <div v-if="showConnector" aria-hidden="true" data-slot="timeline-media-connector" :class="cn('bg-border', direction === 'vertical' ? 'my-1.5 w-0.5 flex-1' : 'mx-1.5 h-0.5 flex-1')" /> </div> </template>app/components/ui/timeline/TimelineContent.vue0.3 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] }>() </script> <template> <div data-uipkge data-slot="timeline-content" :class="cn('flex-1 space-y-1', props.class)" v-bind="$attrs"> <slot /> </div> </template>app/components/ui/timeline/TimelineHeader.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] }>() </script> <template> <div data-uipkge data-slot="timeline-header" :class="cn('flex flex-wrap items-center justify-between gap-2', props.class)" > <slot /> </div> </template>app/components/ui/timeline/TimelineTitle.vue0.5 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' class?: HTMLAttributes['class'] }>() </script> <template> <component :is="props.as ?? 'h3'" data-uipkge data-slot="timeline-title" :class="cn('text-sm leading-none font-semibold tracking-tight', props.class)" v-bind="$attrs" > <slot /> </component> </template>app/components/ui/timeline/TimelineDescription.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] }>() </script> <template> <p data-uipkge data-slot="timeline-description" :class="cn('text-muted-foreground text-sm', props.class)" v-bind="$attrs" > <slot /> </p> </template>app/components/ui/timeline/TimelineDate.vue0.3 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] }>() </script> <template> <time data-uipkge data-slot="timeline-date" :class="cn('text-muted-foreground text-xs', props.class)" v-bind="$attrs"> <slot /> </time> </template>app/components/ui/timeline/context.ts1 kB
import type { InjectionKey, Ref } from 'vue' export type TimelineDirection = 'vertical' | 'horizontal' export type TimelineAlign = 'start' | 'center' export type TimelineSide = 'left' | 'right' | 'top' | 'bottom' export type TimelineStatus = 'default' | 'current' | 'success' | 'warning' | 'error' | 'info' | 'muted' export type TimelineDensity = 'compact' | 'default' | 'comfortable' export interface TimelineContext { direction: Ref<TimelineDirection> align: Ref<TimelineAlign> side: Ref<TimelineSide> density: Ref<TimelineDensity> itemIds: Ref<symbol[]> register: (id: symbol) => void unregister: (id: symbol) => void } export const TIMELINE_CONTEXT: InjectionKey<TimelineContext> = Symbol('TimelineContext') export interface TimelineItemContext { index: Ref<number> isFirst: Ref<boolean> isLast: Ref<boolean> side: Ref<TimelineSide> status: Ref<TimelineStatus> direction: Ref<TimelineDirection> density: Ref<TimelineDensity> } export const TIMELINE_ITEM_CONTEXT: InjectionKey<TimelineItemContext> = Symbol('TimelineItemContext')app/components/ui/timeline/timeline.variants.ts4.2 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming Vue SFCs can import without creating a circular * dependency through the index. See card.variants.ts for the canonical * example + the SSR symptom that motivated the split. */ export const timelineMediaVariants = cva( 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4 ring-background transition-colors [&>svg]:shrink-0', { variants: { variant: { dot: 'size-2.5', icon: 'size-8 border border-border bg-card text-muted-foreground shadow-2xs [&>svg]:size-4', avatar: 'size-8 ring-2 ring-border bg-muted overflow-hidden [&>img]:size-full [&>img]:object-cover [&>[data-slot=avatar]]:size-full', outline: 'size-8 border border-border bg-background text-foreground [&>svg]:size-4', }, status: { default: '', current: '', success: '', warning: '', error: '', info: '', muted: '', }, }, compoundVariants: [ // DOT { variant: 'dot', status: 'default', class: 'bg-primary text-primary-foreground', }, { variant: 'dot', status: 'current', class: 'bg-primary text-primary-foreground ring-4 ring-primary/25', }, { variant: 'dot', status: 'success', class: 'bg-success text-success-foreground', }, { variant: 'dot', status: 'warning', class: 'bg-warning text-warning-foreground', }, { variant: 'dot', status: 'error', class: 'bg-destructive text-destructive-foreground', }, { variant: 'dot', status: 'info', class: 'bg-info text-info-foreground', }, { variant: 'dot', status: 'muted', class: 'bg-muted-foreground/30 text-muted-foreground', }, // ICON { variant: 'icon', status: 'default', class: 'border-border bg-card text-muted-foreground', }, { variant: 'icon', status: 'current', class: 'border-primary/50 bg-primary/10 text-primary ring-4 ring-primary/15', }, { variant: 'icon', status: 'success', class: 'border-success/40 bg-success/10 text-success ring-4 ring-success/10', }, { variant: 'icon', status: 'warning', class: 'border-warning/40 bg-warning/10 text-warning ring-4 ring-warning/10', }, { variant: 'icon', status: 'error', class: 'border-destructive/40 bg-destructive/10 text-destructive ring-4 ring-destructive/10', }, { variant: 'icon', status: 'info', class: 'border-info/40 bg-info/10 text-info ring-4 ring-info/10', }, { variant: 'icon', status: 'muted', class: 'border-border/60 bg-muted/40 text-muted-foreground/60', }, // OUTLINE { variant: 'outline', status: 'default', class: 'border-border text-foreground bg-background', }, { variant: 'outline', status: 'current', class: 'border-primary text-primary bg-background ring-4 ring-primary/25', }, { variant: 'outline', status: 'success', class: 'border-success text-success bg-background', }, { variant: 'outline', status: 'warning', class: 'border-warning text-warning bg-background', }, { variant: 'outline', status: 'error', class: 'border-destructive text-destructive bg-background', }, { variant: 'outline', status: 'info', class: 'border-info text-info bg-background', }, { variant: 'outline', status: 'muted', class: 'border-border text-muted-foreground bg-background', }, ], defaultVariants: { variant: 'dot', status: 'default', }, }, ) export type TimelineMediaVariantsProps = VariantProps<typeof timelineMediaVariants> export type TimelineMediaVariant = 'dot' | 'icon' | 'avatar' | 'outline'app/components/ui/timeline/index.ts0.9 kB
export { default as Timeline } from './Timeline.vue' export { default as TimelineItem } from './TimelineItem.vue' export { default as TimelineMedia } from './TimelineMedia.vue' export { default as TimelineSeparator } from './TimelineSeparator.vue' export { default as TimelineContent } from './TimelineContent.vue' export { default as TimelineHeader } from './TimelineHeader.vue' export { default as TimelineTitle } from './TimelineTitle.vue' export { default as TimelineDescription } from './TimelineDescription.vue' export { default as TimelineDate } from './TimelineDate.vue' export type { TimelineDirection, TimelineAlign, TimelineSide, TimelineStatus, TimelineDensity } from './context' // Re-export variant API from the sibling file (kept separate to avoid the // Component.vue <-> index.ts circular import that broke dev SSR for Card). export { timelineMediaVariants, type TimelineMediaVariantsProps, type TimelineMediaVariant } from './timeline.variants'
Raw manifest:https://uipkge.dev/r/vue/timeline.json