
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for React ->Timeline 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 |
$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.jsonnpx shadcn-vue@latest add @uipkge/timelineInstalls to:app/components/ui/timeline/| 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 |
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>
}<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
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')
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'
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