Timeline
Vue data-displayVertical 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.
Also available for React ->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.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/timeline
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'dot''icon''avatar' | dot | optional |
status | 'default''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.
TimelineContext 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
} TimelineItemContext interface TimelineItemContext {
index: Ref<number>
isFirst: Ref<boolean>
isLast: Ref<boolean>
side: Ref<TimelineSide>
status: Ref<TimelineStatus>
direction: Ref<TimelineDirection>
density: Ref<TimelineDensity>
} Dependencies
Files (11)
-
app/components/ui/timeline/Timeline.vue 1.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.vue 4.1 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-2', 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-last="isLast || undefined" :class=" cn( 'relative', // start mode (default): simple flex // No item padding — TimelineMedia's continuous line relies on items // butting up edge-to-edge. Use TimelineContent's own padding for // breathing room between rows/cards. !isCenter && direction === 'vertical' && [ 'flex gap-4', effectiveSide === 'right' && 'flex-row-reverse text-right', verticalSpacing, ], !isCenter && direction === 'horizontal' && [ 'flex flex-col gap-2', effectiveSide === 'bottom' && 'flex-col-reverse', horizontalSpacing, ], // center alternating: 3-col / 3-row grid isCenter && direction === 'vertical' && [ 'grid grid-cols-[1fr_auto_1fr] items-start gap-x-4', '[&>[data-slot=timeline-media]]:col-start-2', '[&>[data-slot=timeline-separator]]:col-start-2', 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]]:row-start-2', '[&>[data-slot=timeline-separator]]:row-start-2', 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> -
app/components/ui/timeline/TimelineMedia.vue 3.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, 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 auto-generated connector line. */ hideConnector?: boolean /** * Color the connector line below the marker using the item's status * (success → green, muted → gray, etc) instead of the neutral border. * Opt-in so existing timelines stay visually unchanged. */ coloredConnector?: boolean }>(), { variant: 'dot', }, ) const item = inject(TIMELINE_ITEM_CONTEXT, null) const direction = computed(() => item?.direction.value ?? 'vertical') const isFirst = computed(() => item?.isFirst.value ?? true) const isLast = computed(() => item?.isLast.value ?? true) const effectiveStatus = computed<TimelineStatus>(() => props.status ?? item?.status.value ?? 'default') const showConnector = computed(() => !props.hideConnector && !(isFirst.value && isLast.value)) // Marker half-size in rem, used to crop the line so it visually emerges // from the marker center on the first item. const markerHalfRem = computed( () => ({ dot: '0.375rem', // size-3 = 12px / 2 icon: '1rem', // size-8 = 32px / 2 avatar: '1.125rem', // size-9 = 36px / 2 })[(props.variant ?? 'dot') as 'dot' | 'icon' | 'avatar'], ) const connectorBgClass = computed(() => { if (!props.coloredConnector) return 'bg-border' return { default: '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" :class=" cn( 'relative shrink-0 self-stretch', direction === 'vertical' ? 'flex w-9 flex-col items-center' : 'flex h-9 flex-row items-center', props.class, ) " :style="{ '--timeline-marker-half': markerHalfRem }" > <!-- Single continuous connector line, positioned through the marker. The marker's bg + ring-background acts as a "punch-through" so the line appears to break at each marker without any per-item math. --> <div v-if="showConnector" data-uipkge data-slot="timeline-media-connector" aria-hidden="true" :class=" direction === 'vertical' ? cn('absolute left-1/2 w-px -translate-x-1/2', connectorBgClass) : cn('absolute top-1/2 h-px -translate-y-1/2', connectorBgClass) " :style=" direction === 'vertical' ? { top: isFirst ? 'var(--timeline-marker-half)' : '0', bottom: isLast ? 'calc(100% - var(--timeline-marker-half))' : '0', } : { left: isFirst ? 'var(--timeline-marker-half)' : '0', right: isLast ? 'calc(100% - var(--timeline-marker-half))' : '0', } " /> <!-- Marker — its bg + ring-background hides the line behind it. --> <div data-uipkge data-slot="timeline-media-marker" :class="cn(timelineMediaVariants({ variant, status: effectiveStatus }), 'relative z-10')" > <slot /> </div> </div> </template> -
app/components/ui/timeline/TimelineSeparator.vue 2.1 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'] /** Manually hide the auto-generated connector line. */ hideConnector?: boolean }>() const item = inject(TIMELINE_ITEM_CONTEXT, null) const direction = computed(() => item?.direction.value ?? 'vertical') const isFirst = computed(() => item?.isFirst.value ?? true) const isLast = computed(() => item?.isLast.value ?? true) const showConnector = computed(() => !props.hideConnector && !(isFirst.value && isLast.value)) </script> <template> <!-- Legacy compact separator: 16px marker, customizable inner dot via #dot slot. Uses the same single-absolute-line strategy as TimelineMedia so the connector is pixel-aligned across items. --marker-half = 0.5rem (= size-4 / 2). --> <div data-uipkge data-slot="timeline-separator" :class=" cn( 'relative shrink-0 self-stretch', direction === 'vertical' ? 'flex w-4 flex-col items-center' : 'flex h-4 flex-row items-center', props.class, ) " style="--timeline-marker-half: 0.5rem" v-bind="$attrs" > <div v-if="showConnector" aria-hidden="true" :class=" direction === 'vertical' ? 'bg-border absolute left-1/2 w-px -translate-x-1/2' : 'bg-border absolute top-1/2 h-px -translate-y-1/2' " :style=" direction === 'vertical' ? { top: isFirst ? 'var(--timeline-marker-half)' : '0', bottom: isLast ? 'calc(100% - var(--timeline-marker-half))' : '0', } : { left: isFirst ? 'var(--timeline-marker-half)' : '0', right: isLast ? 'calc(100% - var(--timeline-marker-half))' : '0', } " /> <div class="bg-primary ring-background relative z-10 flex size-4 items-center justify-center rounded-full ring-4"> <slot name="dot"> <div class="bg-primary-foreground size-2 rounded-full" /> </slot> </div> </div> </template> -
app/components/ui/timeline/TimelineContent.vue 0.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/TimelineTitle.vue 0.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.vue 0.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.vue 0.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.ts 1 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' | '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.ts 1.3 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 [&>svg]:shrink-0', { variants: { variant: { dot: 'size-3', icon: 'size-8 [&>svg]:size-4', avatar: 'size-9 ring-2 [&>img]:size-full [&>img]:rounded-full [&>img]:object-cover', }, status: { default: 'bg-primary text-primary-foreground', success: 'bg-success text-success-foreground', warning: 'bg-warning text-warning-foreground', error: 'bg-destructive text-destructive-foreground', info: 'bg-info text-info-foreground', muted: 'bg-muted text-muted-foreground', }, }, defaultVariants: { variant: 'dot', status: 'default', }, }, ) export type TimelineMediaVariantsProps = VariantProps<typeof timelineMediaVariants> export type TimelineMediaVariant = 'dot' | 'icon' | 'avatar' -
app/components/ui/timeline/index.ts 0.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 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