UIPackage

Timeline

React 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 Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/timeline.json

Or with the named registry: npx shadcn@latest add @uipkge-react/timeline

Examples

Props

Name Type / Values Default Required
variant
'dot''icon''avatar'
dot optional
status
'default''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.

TimelineContextValue
interface TimelineContextValue {
  direction: TimelineDirection
  align: TimelineAlign
  side: TimelineSide
  density: TimelineDensity
  count: number
  register: (id: string) => void
  unregister: (id: string) => void
  indexOf: (id: string) => number
}
TimelineItemContextValue
interface TimelineItemContextValue {
  index: number
  isFirst: boolean
  isLast: boolean
  side: TimelineSide
  status: TimelineStatus
  direction: TimelineDirection
  density: TimelineDensity
}

Dependencies

Files (4)

  • components/ui/timeline/timeline.tsx 13.6 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()
    
    /* ------------------------------------------------------------------ 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.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-last={isLast || undefined}
            className={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' &&
                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-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' &&
                cn(
                  '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,
                ),
              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
    }
    
    function TimelineMedia({
      className,
      variant = 'dot',
      status,
      hideConnector,
      coloredConnector,
      children,
      ...props
    }: TimelineMediaProps) {
      const item = React.useContext(TimelineItemContext)
    
      const direction = item?.direction ?? 'vertical'
      const isFirst = item?.isFirst ?? true
      const isLast = item?.isLast ?? true
      const effectiveStatus: TimelineStatus = status ?? item?.status ?? 'default'
      const showConnector = !hideConnector && !(isFirst && isLast)
    
      // Marker half-size in rem, used to crop the line so it visually emerges
      // from the marker center on the first item.
      const markerHalfRem = {
        dot: '0.375rem', // size-3 = 12px / 2
        icon: '1rem', // size-8 = 32px / 2
        avatar: '1.125rem', // size-9 = 36px / 2
      }[(variant ?? 'dot') as 'dot' | 'icon' | 'avatar']
    
      const connectorBgClass = !coloredConnector
        ? 'bg-border'
        : {
            default: 'bg-primary',
            success: 'bg-success',
            warning: 'bg-warning',
            error: 'bg-destructive',
            info: 'bg-info',
            muted: 'bg-muted-foreground/40',
          }[effectiveStatus]
    
      return (
        <div
          data-uipkge=""
          data-slot="timeline-media"
          className={cn(
            'relative shrink-0 self-stretch',
            direction === 'vertical' ? 'flex w-9 flex-col items-center' : 'flex h-9 flex-row items-center',
            className,
          )}
          style={{ '--timeline-marker-half': markerHalfRem } as React.CSSProperties}
          {...props}
        >
          {/* 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. */}
          {showConnector && (
            <div
              data-uipkge=""
              data-slot="timeline-media-connector"
              aria-hidden="true"
              className={
                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"
            className={cn(timelineMediaVariants({ variant, status: effectiveStatus }), 'relative z-10')}
          >
            {children}
          </div>
        </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 isFirst = item?.isFirst ?? true
      const isLast = item?.isLast ?? true
      const showConnector = !hideConnector && !(isFirst && isLast)
    
      return (
        // Legacy compact separator: 16px marker, customizable inner dot via `dot`
        // prop. 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"
          className={cn(
            'relative shrink-0 self-stretch',
            direction === 'vertical' ? 'flex w-4 flex-col items-center' : 'flex h-4 flex-row items-center',
            className,
          )}
          style={{ '--timeline-marker-half': '0.5rem' } as React.CSSProperties}
          {...props}
        >
          {showConnector && (
            <div
              aria-hidden="true"
              className={
                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 className="bg-primary ring-background relative z-10 flex size-4 items-center justify-center rounded-full ring-4">
            {dot ?? <div className="bg-primary-foreground size-2 rounded-full" />}
          </div>
        </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} />
      )
    }
    
    /* ------------------------------------------------------------- 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,
      TimelineTitle,
      TimelineDescription,
      TimelineDate,
    }
  • components/ui/timeline/context.ts 1 kB
    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' | '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.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'
  • components/ui/timeline/index.ts 0.7 kB
    export {
      Timeline,
      TimelineItem,
      TimelineMedia,
      TimelineSeparator,
      TimelineContent,
      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://react.uipkge.dev/r/react/timeline.json