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@latest add https://uipkge.dev/r/react/timeline.json$npx shadcn@latest add https://uipkge.dev/r/react/timeline.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/timeline.json$bunx shadcn@latest add https://uipkge.dev/r/react/timeline.jsonNamed registry:
npx shadcn@latest add @uipkge-react/timelineInstalls to: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 | — | optional |
align | TimelineAlign | — | optional |
side | TimelineSide | — | optional |
density | TimelineDensity | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TimelineContextValueinterface TimelineContextValue {
direction: TimelineDirection
align: TimelineAlign
side: TimelineSide
density: TimelineDensity
count: number
register: (id: string) => void
unregister: (id: string) => void
indexOf: (id: string) => number
}TimelineItemContextValueinterface TimelineItemContextValue {
index: number
isFirst: boolean
isLast: boolean
side: TimelineSide
status: TimelineStatus
direction: TimelineDirection
density: TimelineDensity
}npm dependencies
Used by
Files installed (4)
components/ui/timeline/timeline.tsx14.7 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { TimelineContext, TimelineItemContext, type TimelineAlign, type TimelineDensity, type TimelineDirection, type TimelineItemContextValue, type TimelineSide, type TimelineStatus, } from './context' import { timelineMediaVariants, type TimelineMediaVariant } from './timeline.variants' // React's own useId -- SSR-safe and avoids a module-level counter that would // hydrate-mismatch. (React 19's useRef requires an initial arg.) const useId = () => React.useId() const TIMELINE_STYLE_ID = 'timeline-motion-styles' const TIMELINE_STYLE_CONTENT = ` @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); } } [data-slot='timeline-item'].timeline-item-enter { animation: timeline-item-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both; animation-delay: var(--timeline-stagger, 0ms); } [data-slot='timeline-item'].timeline-item-current [data-slot='timeline-media-marker'], [data-slot='timeline-item'].timeline-item-current [data-slot='timeline-separator-marker'] { animation: timeline-current-pulse 1.8s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { [data-slot='timeline-item'].timeline-item-enter, [data-slot='timeline-item'].timeline-item-current [data-slot='timeline-media-marker'], [data-slot='timeline-item'].timeline-item-current [data-slot='timeline-separator-marker'] { animation: none !important; } } ` function ensureTimelineStyles() { if (typeof document === 'undefined') return let el = document.getElementById(TIMELINE_STYLE_ID) as HTMLStyleElement | null if (!el) { el = document.createElement('style') el.id = TIMELINE_STYLE_ID document.head.appendChild(el) } if (el.textContent !== TIMELINE_STYLE_CONTENT) el.textContent = TIMELINE_STYLE_CONTENT } /* ------------------------------------------------------------------ Timeline */ export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> { direction?: TimelineDirection align?: TimelineAlign side?: TimelineSide density?: TimelineDensity } function Timeline({ className, direction = 'vertical', align = 'start', side, density = 'default', children, ...props }: TimelineProps) { const [ids, setIds] = React.useState<string[]>([]) const register = React.useCallback((id: string) => { setIds((prev) => (prev.includes(id) ? prev : [...prev, id])) }, []) const unregister = React.useCallback((id: string) => { setIds((prev) => prev.filter((i) => i !== id)) }, []) const indexOf = React.useCallback((id: string) => ids.indexOf(id), [ids]) const resolvedSide: TimelineSide = side ?? (direction === 'horizontal' ? 'top' : 'left') const ctx = React.useMemo( () => ({ direction, align, side: resolvedSide, density, count: ids.length, register, unregister, indexOf, }), [direction, align, resolvedSide, density, ids.length, register, unregister, indexOf], ) return ( <TimelineContext.Provider value={ctx}> <div data-uipkge="" data-slot="timeline" data-direction={direction} data-align={align} className={cn('relative', direction === 'vertical' ? 'flex flex-col' : 'flex flex-row', className)} {...props} > {children} </div> </TimelineContext.Provider> ) } /* -------------------------------------------------------------- TimelineItem */ export interface TimelineItemRenderProps { index: number isLast: boolean side: TimelineSide status: TimelineStatus } export interface TimelineItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> { side?: TimelineSide status?: TimelineStatus children?: React.ReactNode | ((props: TimelineItemRenderProps) => React.ReactNode) } function TimelineItem({ className, side, status = 'default', children, ...props }: TimelineItemProps) { const ctx = React.useContext(TimelineContext) const id = useId() React.useLayoutEffect(() => { ensureTimelineStyles() }, []) React.useEffect(() => { if (!ctx) return ctx.register(id) return () => ctx.unregister(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, ctx?.register, ctx?.unregister]) const index = ctx ? ctx.indexOf(id) : 0 const isFirst = index === 0 const isLast = ctx ? index === ctx.count - 1 : false const effectiveSide: TimelineSide = (() => { 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 = ctx?.direction ?? 'vertical' const density = ctx?.density ?? 'default' const isCenter = ctx?.align === 'center' const verticalSpacing = isLast ? '' : { compact: '[&>[data-slot=timeline-content]]:pb-2', default: '[&>[data-slot=timeline-content]]:pb-6', comfortable: '[&>[data-slot=timeline-content]]:pb-10', }[density] const horizontalSpacing = isLast ? '' : { compact: '[&>[data-slot=timeline-content]]:pr-3', default: '[&>[data-slot=timeline-content]]:pr-6', comfortable: '[&>[data-slot=timeline-content]]:pr-10', }[density] const itemCtx: TimelineItemContextValue = { index, isFirst, isLast, side: effectiveSide, status, direction, density, } const resolvedChildren = typeof children === 'function' ? children({ index, isLast, side: effectiveSide, status }) : children return ( <TimelineItemContext.Provider value={itemCtx}> <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`, } as React.CSSProperties } className={cn( 'timeline-item-enter relative', status === 'current' && 'timeline-item-current', // 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' && cn('flex gap-4', effectiveSide === 'right' && 'flex-row-reverse text-right', verticalSpacing), !isCenter && direction === 'horizontal' && cn('flex flex-col gap-2', effectiveSide === 'bottom' && 'flex-col-reverse', horizontalSpacing), // center alternating: 3-col / 3-row grid isCenter && direction === 'vertical' && cn( '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' && cn( '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, )} {...props} > {resolvedChildren} </div> </TimelineItemContext.Provider> ) } /* ------------------------------------------------------------- TimelineMedia */ export interface TimelineMediaProps extends React.HTMLAttributes<HTMLDivElement> { 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 /** Line style for the connector. */ lineStyle?: 'solid' | 'dashed' | 'dotted' } function TimelineMedia({ className, variant = 'dot', status, hideConnector, coloredConnector, lineStyle = 'solid', children, ...props }: TimelineMediaProps) { const item = React.useContext(TimelineItemContext) const direction = item?.direction ?? 'vertical' const isLast = item?.isLast ?? true const effectiveStatus: TimelineStatus = status ?? item?.status ?? 'default' const showConnector = !hideConnector && !isLast const connectorBgClass = React.useMemo(() => { 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] }, [lineStyle, direction, coloredConnector, effectiveStatus]) return ( <div data-uipkge="" data-slot="timeline-media" data-variant={variant} className={cn( 'relative flex shrink-0 items-center', direction === 'vertical' ? 'flex-col self-stretch' : 'flex-row items-center self-stretch', className, )} {...props} > {/* Marker */} <div data-uipkge="" data-slot="timeline-media-marker" className={cn( timelineMediaVariants({ variant, status: effectiveStatus }), direction === 'vertical' && variant === 'dot' && 'mt-1', )} > {children} </div> {/* Connector line */} {showConnector && ( <div data-uipkge="" data-slot="timeline-media-connector" aria-hidden="true" className={cn(direction === 'vertical' ? 'my-1 w-px flex-1' : 'mx-1 h-px flex-1', connectorBgClass)} /> )} </div> ) } /* --------------------------------------------------------- TimelineSeparator */ export interface TimelineSeparatorProps extends React.HTMLAttributes<HTMLDivElement> { /** Manually hide the auto-generated connector line. */ hideConnector?: boolean /** Override the inner dot (React equivalent of the Vue `#dot` slot). */ dot?: React.ReactNode } function TimelineSeparator({ className, hideConnector, dot, ...props }: TimelineSeparatorProps) { const item = React.useContext(TimelineItemContext) const direction = item?.direction ?? 'vertical' const isLast = item?.isLast ?? true const showConnector = !hideConnector && !isLast return ( <div data-uipkge="" data-slot="timeline-separator" className={cn( 'relative flex shrink-0 items-center', direction === 'vertical' ? 'w-4 flex-col self-stretch' : 'h-4 flex-row items-center self-stretch', className, )} {...props} > <div data-slot="timeline-separator-marker" className="bg-primary ring-background relative z-10 flex size-4 items-center justify-center rounded-full shadow-2xs ring-4" > {dot ?? <div className="bg-primary-foreground size-1.5 rounded-full" />} </div> {showConnector && ( <div aria-hidden="true" data-slot="timeline-media-connector" className={cn('bg-border', direction === 'vertical' ? 'my-1.5 w-0.5 flex-1' : 'mx-1.5 h-0.5 flex-1')} /> )} </div> ) } /* ----------------------------------------------------------- TimelineContent */ function TimelineContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { return <div data-uipkge="" data-slot="timeline-content" className={cn('flex-1 space-y-1', className)} {...props} /> } /* ------------------------------------------------------------ TimelineHeader */ function TimelineHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { return ( <div data-uipkge="" data-slot="timeline-header" className={cn('flex flex-wrap items-center justify-between gap-2', className)} {...props} /> ) } /* ------------------------------------------------------------- TimelineTitle */ export interface TimelineTitleProps extends React.HTMLAttributes<HTMLElement> { as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' } function TimelineTitle({ as, className, ...props }: TimelineTitleProps) { const Comp = (as ?? 'h3') as React.ElementType return ( <Comp data-uipkge="" data-slot="timeline-title" className={cn('text-sm leading-none font-semibold tracking-tight', className)} {...props} /> ) } /* ------------------------------------------------------- TimelineDescription */ function TimelineDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) { return ( <p data-uipkge="" data-slot="timeline-description" className={cn('text-muted-foreground text-sm', className)} {...props} /> ) } /* -------------------------------------------------------------- TimelineDate */ function TimelineDate({ className, ...props }: React.TimeHTMLAttributes<HTMLTimeElement>) { return ( <time data-uipkge="" data-slot="timeline-date" className={cn('text-muted-foreground text-xs', className)} {...props} /> ) } export { Timeline, TimelineItem, TimelineMedia, TimelineSeparator, TimelineContent, TimelineHeader, TimelineTitle, TimelineDescription, TimelineDate, }components/ui/timeline/context.ts1 kB
'use client' import * as React from 'react' 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 TimelineContextValue { direction: TimelineDirection align: TimelineAlign side: TimelineSide density: TimelineDensity count: number register: (id: string) => void unregister: (id: string) => void indexOf: (id: string) => number } export const TimelineContext = React.createContext<TimelineContextValue | null>(null) export interface TimelineItemContextValue { index: number isFirst: boolean isLast: boolean side: TimelineSide status: TimelineStatus direction: TimelineDirection density: TimelineDensity } export const TimelineItemContext = React.createContext<TimelineItemContextValue | null>(null)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'components/ui/timeline/index.ts0.7 kB
export { Timeline, TimelineItem, TimelineMedia, TimelineSeparator, TimelineContent, TimelineHeader, TimelineTitle, TimelineDescription, TimelineDate, type TimelineProps, type TimelineItemProps, type TimelineItemRenderProps, type TimelineMediaProps, type TimelineSeparatorProps, type TimelineTitleProps, } from './timeline' export type { TimelineDirection, TimelineAlign, TimelineSide, TimelineStatus, TimelineDensity } from './context' // Re-export variant API from the sibling file (kept separate to avoid the // component <-> index circular import that broke dev SSR for Card). export { timelineMediaVariants, type TimelineMediaVariantsProps, type TimelineMediaVariant } from './timeline.variants'
Raw manifest:https://uipkge.dev/r/react/timeline.json