Bloom Logo

Timeline

A chronological sequence of events supporting customizable variants, status colors, sizes, line shapes, and card containers.

importimport { Timeline, TimelineItem } from "@/components/ui/timeline/timeline";
$npx @bloomui-react/cli add timeline
timeline.tsx
"use client";

import * as React from "react";
import { designRadius } from "@/lib/design-system";
import { cn } from "@/lib/utils";

export type TimelineColor =
  | "default"
  | "primary"
  | "secondary"
  | "accent"
  | "success"
  | "warning"
  | "danger";

export type TimelineVariant = "default" | "bordered" | "flat" | "contained";
export type TimelineSize = "sm" | "md" | "lg";
export type TimelineRadius =
  | "none"
  | "xs"
  | "sm"
  | "md"
  | "lg"
  | "xl"
  | "2xl"
  | "3xl"
  | "full";

export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
  children: React.ReactNode;
  mode?: "left" | "alternate";
  variant?: TimelineVariant;
  color?: TimelineColor;
  size?: TimelineSize;
  radius?: TimelineRadius;
}

export interface TimelineItemProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  title: React.ReactNode;
  description?: React.ReactNode;
  details?: React.ReactNode;
  time?: string;
  icon?: React.ReactNode;
  status?: TimelineColor | "completed" | "active" | "pending" | "error";
  isLast?: boolean;
  position?: "left" | "right";
  variant?: TimelineVariant;
  color?: TimelineColor;
  size?: TimelineSize;
  radius?: TimelineRadius;
}

interface TimelineContextType {
  variant: TimelineVariant;
  color: TimelineColor;
  size: TimelineSize;
  radius: TimelineRadius;
}

const TimelineContext = React.createContext<TimelineContextType>({
  variant: "default",
  color: "primary",
  size: "md",
  radius: "full",
});

const dotSizes: Record<TimelineSize, string> = {
  sm: "size-6 text-[10px]",
  md: "size-8 text-xs",
  lg: "size-10 text-sm",
};

const innerDotSizes: Record<TimelineSize, string> = {
  sm: "size-1.5",
  md: "size-2.5",
  lg: "size-3.5",
};

const lineWidths: Record<TimelineSize, string> = {
  sm: "w-0.5",
  md: "w-0.5",
  lg: "w-[3px]",
};

const lineOffsets: Record<TimelineSize, string> = {
  sm: "left-[11px] top-[24px]",
  md: "left-[15px] top-[32px]",
  lg: "left-[19px] top-[40px]",
};

const lineOffsetsRight: Record<TimelineSize, string> = {
  sm: "right-[11px] top-[24px]",
  md: "right-[15px] top-[32px]",
  lg: "right-[19px] top-[40px]",
};

const contentPaddings: Record<TimelineSize, string> = {
  sm: "pb-6",
  md: "pb-8",
  lg: "pb-10",
};

const textSizes: Record<
  TimelineSize,
  { title: string; desc: string; time: string }
> = {
  sm: {
    title: "text-xs font-semibold",
    desc: "text-[11px] mt-0.5",
    time: "text-[10px]",
  },
  md: { title: "text-sm font-bold", desc: "text-xs mt-1", time: "text-xs" },
  lg: { title: "text-base font-bold", desc: "text-sm mt-1.5", time: "text-sm" },
};

const colorMap: Record<
  TimelineColor,
  {
    text: string;
    border: string;
    bg: string;
    dotBg: string;
    flatBg: string;
    cardBorder: string;
    cardBg: string;
  }
> = {
  default: {
    text: "text-zinc-650 dark:text-zinc-400",
    border: "border-zinc-300 dark:border-zinc-700",
    bg: "bg-zinc-100 dark:bg-zinc-800",
    dotBg: "bg-zinc-450 dark:bg-zinc-500",
    flatBg: "bg-zinc-100/50 dark:bg-zinc-800/40",
    cardBorder: "border-zinc-200 dark:border-zinc-850",
    cardBg: "bg-zinc-50/50 dark:bg-zinc-900/40",
  },
  primary: {
    text: "text-sky-500",
    border: "border-sky-500",
    bg: "bg-sky-500",
    dotBg: "bg-sky-500",
    flatBg: "bg-sky-500/10 dark:bg-sky-400/10",
    cardBorder: "border-sky-200 dark:border-sky-950/40",
    cardBg: "bg-sky-50/30 dark:bg-sky-950/10",
  },
  secondary: {
    text: "text-violet-500",
    border: "border-violet-500",
    bg: "bg-violet-500",
    dotBg: "bg-violet-500",
    flatBg: "bg-violet-500/10 dark:bg-violet-400/10",
    cardBorder: "border-violet-200 dark:border-violet-950/40",
    cardBg: "bg-violet-50/30 dark:bg-violet-950/10",
  },
  accent: {
    text: "text-pink-500",
    border: "border-pink-500",
    bg: "bg-pink-500",
    dotBg: "bg-pink-500",
    flatBg: "bg-pink-500/10 dark:bg-pink-400/10",
    cardBorder: "border-pink-200 dark:border-pink-950/40",
    cardBg: "bg-pink-50/30 dark:bg-pink-950/10",
  },
  success: {
    text: "text-emerald-500",
    border: "border-emerald-500",
    bg: "bg-emerald-500",
    dotBg: "bg-emerald-500",
    flatBg: "bg-emerald-500/10 dark:bg-emerald-400/10",
    cardBorder: "border-emerald-200 dark:border-emerald-950/40",
    cardBg: "bg-emerald-50/30 dark:bg-emerald-950/10",
  },
  warning: {
    text: "text-amber-500",
    border: "border-amber-500",
    bg: "bg-amber-500",
    dotBg: "bg-amber-500",
    flatBg: "bg-amber-500/10 dark:bg-amber-400/10",
    cardBorder: "border-amber-200 dark:border-amber-950/40",
    cardBg: "bg-amber-50/30 dark:bg-amber-950/10",
  },
  danger: {
    text: "text-rose-500",
    border: "border-rose-500",
    bg: "bg-rose-500",
    dotBg: "bg-rose-500",
    flatBg: "bg-rose-500/10 dark:bg-rose-400/10",
    cardBorder: "border-rose-200 dark:border-rose-950/40",
    cardBg: "bg-rose-50/30 dark:bg-rose-950/10",
  },
};

function resolveStatusColor(
  status: TimelineItemProps["status"],
): TimelineColor {
  if (!status) return "primary";
  if (status === "completed" || status === "success") return "success";
  if (status === "active") return "primary";
  if (status === "error" || status === "danger") return "danger";
  if (status === "warning") return "warning";
  if (status === "pending" || status === "default") return "default";
  return status as TimelineColor;
}

export const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(
  (
    {
      children,
      mode = "left",
      variant = "default",
      color = "primary",
      size = "md",
      radius = "full",
      className,
      ...props
    },
    ref,
  ) => {
    const items = React.Children.toArray(children);
    return (
      <TimelineContext.Provider value={{ variant, color, size, radius }}>
        <div
          ref={ref}
          className={cn("relative space-y-0 w-full select-none", className)}
          {...props}
        >
          {items.map((child, index) => {
            if (React.isValidElement<TimelineItemProps>(child)) {
              const position =
                mode === "alternate"
                  ? index % 2 === 0
                    ? "left"
                    : "right"
                  : "left";
              return React.cloneElement(child, {
                isLast: index === items.length - 1,
                position: child.props.position || position,
              });
            }
            return child;
          })}
        </div>
      </TimelineContext.Provider>
    );
  },
);

Timeline.displayName = "Timeline";

export const TimelineItem = React.forwardRef<HTMLDivElement, TimelineItemProps>(
  (
    {
      title,
      description,
      details,
      time,
      icon,
      status,
      isLast = false,
      position = "left",
      variant,
      color,
      size,
      radius,
      className,
      ...props
    },
    ref,
  ) => {
    const context = React.useContext(TimelineContext);
    const [isExpanded, setIsExpanded] = React.useState(false);

    const activeVariant = variant || context.variant;
    const activeColor = color || context.color;
    const activeSize = size || context.size;
    const activeRadius = radius || context.radius;

    const resolvedColor = status ? resolveStatusColor(status) : activeColor;
    const palette = colorMap[resolvedColor];

    const isRight = position === "right";
    const radiusClass = designRadius[activeRadius] || "rounded-full";

    const dotVariants: Record<TimelineVariant, string> = {
      default: cn(
        "border-2 bg-white dark:bg-zinc-950",
        palette.border,
        palette.text,
      ),
      bordered: cn("border-2 bg-transparent", palette.border, palette.text),
      flat: cn("border-transparent", palette.flatBg, palette.text),
      contained: cn(
        "border-2 bg-white dark:bg-zinc-950",
        palette.border,
        palette.text,
      ),
    };

    const containerStyle = cn(
      "relative flex gap-4",
      contentPaddings[activeSize],
      isRight ? "flex-row-reverse text-right" : "text-left",
      isLast && "pb-0",
      className,
    );

    const lineStyle = cn(
      "absolute bottom-0 bg-zinc-200 dark:bg-zinc-800",
      lineWidths[activeSize],
      isRight ? lineOffsetsRight[activeSize] : lineOffsets[activeSize],
    );

    const textStyle = textSizes[activeSize];
    const Component = props.id ? "section" : "div";

    return (
      <Component ref={ref} className={containerStyle} {...props}>
        {!isLast && <div className={lineStyle} />}

        <div
          className={cn(
            "relative z-10 flex items-center justify-center shrink-0 shadow-xs transition-all duration-300",
            dotSizes[activeSize],
            radiusClass,
            dotVariants[activeVariant],
          )}
        >
          {icon ? (
            <span className="flex items-center justify-center">{icon}</span>
          ) : (
            <span
              className={cn(
                "transition-all duration-300",
                innerDotSizes[activeSize],
                radiusClass,
                palette.dotBg,
                status === "active" && "animate-pulse",
              )}
            />
          )}
        </div>

        <div
          className={cn(
            "flex-1 pt-0.5 min-w-0 transition-all duration-300",
            activeVariant === "contained" &&
              cn(
                "p-4 border rounded-2xl shadow-xs bg-white dark:bg-zinc-900/90",
                palette.cardBorder,
                palette.cardBg,
              ),
          )}
        >
          <div
            className={cn(
              "flex items-baseline gap-2",
              isRight && "justify-end",
            )}
          >
            {isRight ? (
              <>
                {time && (
                  <span className="font-mono text-zinc-400 shrink-0 text-xs">
                    {time}
                  </span>
                )}
                <h4
                  className={cn(
                    "text-zinc-900 dark:text-zinc-100",
                    textStyle.title,
                  )}
                >
                  {title}
                </h4>
              </>
            ) : (
              <>
                <h4
                  className={cn(
                    "text-zinc-900 dark:text-zinc-100",
                    textStyle.title,
                  )}
                >
                  {title}
                </h4>
                {time && (
                  <span className="font-mono text-zinc-400 shrink-0 text-xs">
                    {time}
                  </span>
                )}
              </>
            )}
          </div>

          {description && (
            <div
              className={cn(
                "text-zinc-500 dark:text-zinc-400 leading-relaxed",
                textStyle.desc,
              )}
            >
              {description}
            </div>
          )}

          {details && (
            <div className="mt-2">
              <button
                type="button"
                onClick={() => setIsExpanded(!isExpanded)}
                className={cn(
                  "text-xs font-semibold hover:opacity-80 transition-opacity inline-flex items-center gap-1 cursor-pointer",
                  palette.text,
                )}
              >
                {isExpanded ? "Hide Details" : "Show Details"}
              </button>
              {isExpanded && (
                <div
                  className={cn(
                    "mt-2 p-3 border text-xs text-zinc-700 dark:text-zinc-300 animate-in fade-in-0 duration-200",
                    activeRadius === "full"
                      ? "rounded-sm"
                      : designRadius[activeRadius] || "rounded-sm",
                    activeVariant === "contained"
                      ? "bg-zinc-50/50 dark:bg-zinc-950/40 border-zinc-200/50 dark:border-zinc-800/50"
                      : "bg-zinc-50/60 dark:bg-zinc-900/60 border-zinc-200 dark:border-zinc-800",
                  )}
                >
                  {details}
                </div>
              )}
            </div>
          )}
        </div>
      </Component>
    );
  },
);

TimelineItem.displayName = "TimelineItem";

Default

A standard sequence of chronological timeline events.

Design System Created

2 days ago
Initial tokens structure has been established.

Components Developed

1 day ago
Core interactive UI components implemented.

Released v1.0.0

Just now
Production package published.

Variants

Choose from default solid dots, bordered outlines, flat soft highlights, or contained item card blocks.

variant: default | bordered | flat | contained
variant="default"

Initial Review

Documents and plans signed off.

Development Started

Repository branch established.
variant="bordered"

Initial Review

Documents and plans signed off.

Development Started

Repository branch established.
variant="flat"

Review Pending

Awaiting design team approval.

Deploy Complete

Codebase verified in sandbox.
variant="contained"

Sprint Planning

Monday
Scoped target release objectives.

Code Frozen

Wednesday
Refactoring branch integrated.

Colors

Timeline indicator dots, lines, and contained cards adapt to standard color themes.

color: default | primary | secondary | accent | success | warning | danger

Default Accent

Standard theme line and dot accents.

Primary Accent

Theme brand accent color.

Secondary Accent

Theme branding secondary color.

Accent Accent

Theme accent/highlight brand color.

Success Accent

Milestone completed successfully.

Warning Accent

Pending actions needed.

Danger Accent

Critical anomalies logged.

Sizes

Choose from small, medium, or large timeline scales controlling dot sizing and typography.

size: sm | md | lg

Small scale timeline item

Useful for compact sidebars.

Medium scale timeline item

Standard layout size.

Large scale timeline item

Great for prominent history feeds.

Radius

Modify rounding settings of indicator nodes, inner checkmarks, and contained cards.

radius: none | md | full

Squared Box

Radius none applied to card corners and indicators.

Medium Rounded Box

Modern rounded corners applied.

Super Rounded Box

Extra round borders layout.

Alternating Layout

Stagger milestones alternatively from left to right for symmetric chronological streams.

mode: alternate | left

Event One

09:00 AM
Staggered on the left side.
10:30 AM

Event Two

Staggered on the right side.

Event Three

11:45 AM
Back to the left side again.

Custom Node Icons

Provide custom SVG icons or emojis to replace standard round dot indicators.

icon: ReactNode

Bug Reported

Database locking anomaly flagged.

Security Patched

Firewall firewall rule set updated.

Expandable Details

Allow users to reveal detailed sub-sections by collapsing/expanding content in-place.

details: ReactNode

Server Deployment

Web application uploaded to cloud infrastructure.

Props — Timeline

Properties to configure the Timeline container component.

PropTypeDefaultDescription
mode'left' | 'alternate''left'Layout alignment of milestones.
variant'default' | 'bordered' | 'flat' | 'contained''default'The default visual layout variant for all items.
color'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger''primary'Theme color accent for the timeline.
size'sm' | 'md' | 'lg''md'Sizing of line, text, and dots.
radius'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full''full'Rounding borders of cards and nodes.

Props — TimelineItem

Properties to configure individual TimelineItem components.

PropTypeDefaultDescription
titleReactNoderequiredThe main title header of the milestone.
descriptionReactNodeundefinedChronological milestone text description.
timestringundefinedTimestamp displayed next to the item.
iconReactNodeundefinedCustom icon graphic replacing standard circle node.
detailsReactNodeundefinedCollapsible details block content.
statusstring'default'Milestone-level status color override.