UIPackage

Timeline

Vue data-display
Edit on GitHub

Vertical 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

$ npx 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