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-svelte@latest add https://uipkge.dev/r/svelte/timeline.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/timeline.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/timeline.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/timeline.jsonManifest:
https://uipkge.dev/r/svelte/timeline.jsonInstalls to:src/lib/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 |
direction | TimelineDirection | vertical | optional |
align | TimelineAlign | start | optional |
side | TimelineSide | — | optional |
density | TimelineDensity | default | optional |
ref | HTMLDivElement | null | — | optional |
npm dependencies
Used by
Files installed (12)
src/lib/components/ui/timeline/Timeline.svelte1.2 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { TimelineAlign, TimelineDensity, TimelineDirection, TimelineSide } from './context.svelte' export interface TimelineProps extends HTMLAttributes<HTMLDivElement> { direction?: TimelineDirection align?: TimelineAlign side?: TimelineSide density?: TimelineDensity ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { TimelineContextState, setTimelineContext } from './context.svelte' let { class: className, direction = 'vertical', align = 'start', side, density = 'default', children, ref = $bindable(null), ...restProps }: TimelineProps = $props() const ctx = new TimelineContextState() setTimelineContext(ctx) $effect(() => { ctx.direction = direction ctx.align = align ctx.side = side ?? (direction === 'horizontal' ? 'top' : 'left') ctx.density = density }) </script> <div bind:this={ref} data-uipkge="" data-slot="timeline" data-direction={direction} data-align={align} class={cn('relative', direction === 'vertical' ? 'flex flex-col' : 'flex flex-row', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/timeline/TimelineItem.svelte5.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { TimelineSide, TimelineStatus } from './context.svelte' export interface TimelineItemRenderArgs { index: number isLast: boolean side: TimelineSide status: TimelineStatus } export interface TimelineItemProps extends HTMLAttributes<HTMLDivElement> { side?: TimelineSide status?: TimelineStatus // Optional param keeps this assignable to the base `Snippet<[]>` while // still letting children read the render args. children?: Snippet<[TimelineItemRenderArgs?]> ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { TimelineItemContextState, getTimelineContext, setTimelineItemContext } from './context.svelte' let { class: className, side, status = 'default', children, ref = $bindable(null), ...restProps }: TimelineItemProps = $props() const ctx = getTimelineContext() const id = Symbol('TimelineItem') $effect(() => { ctx?.register(id) return () => ctx?.unregister(id) }) const index = $derived(ctx ? ctx.itemIds.indexOf(id) : 0) const isFirst = $derived(index === 0) const isLast = $derived(ctx ? index === ctx.itemIds.length - 1 : false) const effectiveSide: TimelineSide = $derived.by(() => { if (side) return side if (!ctx) return 'left' if (ctx.align === 'center') { if (ctx.direction === 'vertical') { return index % 2 === 0 ? 'left' : 'right' } return index % 2 === 0 ? 'top' : 'bottom' } return ctx.side }) const direction = $derived(ctx?.direction ?? 'vertical') const density = $derived(ctx?.density ?? 'default') const isCenter = $derived(ctx?.align === 'center') const verticalSpacing = $derived.by(() => { if (isLast) return '' return { compact: '[&>[data-slot=timeline-content]]:pb-3', default: '[&>[data-slot=timeline-content]]:pb-6', comfortable: '[&>[data-slot=timeline-content]]:pb-10', }[density] }) const horizontalSpacing = $derived.by(() => { if (isLast) return '' return { compact: '[&>[data-slot=timeline-content]]:pr-3', default: '[&>[data-slot=timeline-content]]:pr-6', comfortable: '[&>[data-slot=timeline-content]]:pr-10', }[density] }) const itemCtx = new TimelineItemContextState() setTimelineItemContext(itemCtx) $effect(() => { itemCtx.index = index itemCtx.isFirst = isFirst itemCtx.isLast = isLast itemCtx.side = effectiveSide itemCtx.status = status itemCtx.direction = direction itemCtx.density = density }) </script> <div bind:this={ref} 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, ], className, )} {...restProps} > {@render children?.({ index, isLast, side: effectiveSide, status })} </div> <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); } /* Markers live in child components, so the descendant half must be global. */ .timeline-item-current :global([data-slot='timeline-media-marker']), .timeline-item-current :global([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 :global([data-slot='timeline-media-marker']), .timeline-item-current :global([data-slot='timeline-separator-marker']) { animation: none !important; } } </style>src/lib/components/ui/timeline/TimelineMedia.svelte3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { TimelineStatus } from './context.svelte' import type { TimelineMediaVariant } from './timeline.variants' export interface TimelineMediaProps extends HTMLAttributes<HTMLDivElement> { 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' ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getTimelineItemContext } from './context.svelte' import { timelineMediaVariants } from './timeline.variants' let { class: className, variant = 'dot', status, hideConnector = false, coloredConnector = false, lineStyle = 'solid', children, ref = $bindable(null), ...restProps }: TimelineMediaProps = $props() const item = getTimelineItemContext() const direction = $derived(item?.direction ?? 'vertical') const isLast = $derived(item?.isLast ?? true) const effectiveStatus: TimelineStatus = $derived(status ?? item?.status ?? 'default') const showConnector = $derived(!hideConnector && !isLast) const connectorBgClass = $derived.by(() => { if (lineStyle === 'dashed') { return direction === 'vertical' ? 'border-l-2 border-dashed border-border bg-transparent w-0' : 'border-t-2 border-dashed border-border bg-transparent h-0' } if (lineStyle === 'dotted') { return direction === 'vertical' ? 'border-l-2 border-dotted border-border bg-transparent w-0' : 'border-t-2 border-dotted border-border bg-transparent h-0' } if (!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] }) </script> <div bind:this={ref} 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', className, )} {...restProps} > <!-- Marker --> <div data-uipkge="" data-slot="timeline-media-marker" class={cn( timelineMediaVariants({ variant, status: effectiveStatus }), direction === 'vertical' && variant === 'dot' && 'mt-1', )} > {@render children?.()} </div> <!-- Connector line --> {#if showConnector} <div 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> {/if} </div>src/lib/components/ui/timeline/TimelineSeparator.svelte1.6 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface TimelineSeparatorProps extends HTMLAttributes<HTMLDivElement> { hideConnector?: boolean /** Custom marker dot — the Svelte counterpart of the Vue `dot` slot. */ dot?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getTimelineItemContext } from './context.svelte' let { class: className, hideConnector = false, dot, ref = $bindable(null), ...restProps }: TimelineSeparatorProps = $props() const item = getTimelineItemContext() const direction = $derived(item?.direction ?? 'vertical') const isLast = $derived(item?.isLast ?? true) const showConnector = $derived(!hideConnector && !isLast) </script> <div bind:this={ref} 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', className, )} {...restProps} > <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" > {#if dot} {@render dot()} {:else} <div class="bg-primary-foreground size-1.5 rounded-full"></div> {/if} </div> {#if showConnector} <div 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> {/if} </div>src/lib/components/ui/timeline/TimelineContent.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TimelineContentProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: TimelineContentProps = $props() </script> <div bind:this={ref} data-uipkge="" data-slot="timeline-content" class={cn('flex-1 space-y-1', className)} {...restProps}> {@render children?.()} </div>src/lib/components/ui/timeline/TimelineHeader.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TimelineHeaderProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: TimelineHeaderProps = $props() </script> <div bind:this={ref} data-uipkge="" data-slot="timeline-header" class={cn('flex flex-wrap items-center justify-between gap-2', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/timeline/TimelineTitle.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TimelineTitleProps extends HTMLAttributes<HTMLElement> { as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' ref?: HTMLElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { as: tag = 'h3', class: className, children, ref = $bindable(null), ...restProps }: TimelineTitleProps = $props() function setRef(node: HTMLElement) { ref = node } </script> <svelte:element this={tag} use:setRef data-uipkge="" data-slot="timeline-title" class={cn('text-sm leading-none font-semibold tracking-tight', className)} {...restProps} > {@render children?.()} </svelte:element>src/lib/components/ui/timeline/TimelineDescription.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TimelineDescriptionProps extends HTMLAttributes<HTMLParagraphElement> { ref?: HTMLParagraphElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: TimelineDescriptionProps = $props() </script> <p bind:this={ref} data-uipkge="" data-slot="timeline-description" class={cn('text-muted-foreground text-sm', className)} {...restProps} > {@render children?.()} </p>src/lib/components/ui/timeline/TimelineDate.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TimelineDateProps extends HTMLAttributes<HTMLElement> { ref?: HTMLElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: TimelineDateProps = $props() function setRef(node: HTMLElement) { ref = node } </script> <time use:setRef data-uipkge="" data-slot="timeline-date" class={cn('text-muted-foreground text-xs', className)} {...restProps} > {@render children?.()} </time>src/lib/components/ui/timeline/context.svelte.ts1.7 kB
import { getContext, setContext } from 'svelte' 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 class TimelineContextState { direction = $state<TimelineDirection>('vertical') align = $state<TimelineAlign>('start') side = $state<TimelineSide>('left') density = $state<TimelineDensity>('default') itemIds = $state<symbol[]>([]) register(id: symbol) { if (!this.itemIds.includes(id)) this.itemIds.push(id) } unregister(id: symbol) { this.itemIds = this.itemIds.filter((i) => i !== id) } } const TIMELINE_KEY = Symbol('TimelineContext') export function setTimelineContext(ctx: TimelineContextState) { setContext(TIMELINE_KEY, ctx) } export function getTimelineContext(): TimelineContextState | undefined { return getContext<TimelineContextState | undefined>(TIMELINE_KEY) } export class TimelineItemContextState { index = $state(0) isFirst = $state(true) isLast = $state(false) side = $state<TimelineSide>('left') status = $state<TimelineStatus>('default') direction = $state<TimelineDirection>('vertical') density = $state<TimelineDensity>('default') } const TIMELINE_ITEM_KEY = Symbol('TimelineItemContext') export function setTimelineItemContext(ctx: TimelineItemContextState) { setContext(TIMELINE_ITEM_KEY, ctx) } export function getTimelineItemContext(): TimelineItemContextState | undefined { return getContext<TimelineItemContextState | undefined>(TIMELINE_ITEM_KEY) }src/lib/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'src/lib/components/ui/timeline/index.ts1.2 kB
export { default as Timeline, type TimelineProps } from './Timeline.svelte' export { default as TimelineItem, type TimelineItemProps, type TimelineItemRenderArgs } from './TimelineItem.svelte' export { default as TimelineMedia, type TimelineMediaProps } from './TimelineMedia.svelte' export { default as TimelineSeparator, type TimelineSeparatorProps } from './TimelineSeparator.svelte' export { default as TimelineContent, type TimelineContentProps } from './TimelineContent.svelte' export { default as TimelineHeader, type TimelineHeaderProps } from './TimelineHeader.svelte' export { default as TimelineTitle, type TimelineTitleProps } from './TimelineTitle.svelte' export { default as TimelineDescription, type TimelineDescriptionProps } from './TimelineDescription.svelte' export { default as TimelineDate, type TimelineDateProps } from './TimelineDate.svelte' export type { TimelineDirection, TimelineAlign, TimelineSide, TimelineStatus, TimelineDensity, } from './context.svelte' // Re-export variant API from the sibling file (kept separate to avoid the // Component <-> 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/svelte/timeline.json