Building a Responsive React Timeline (Vertical & Horizontal)

Build a flexible React timeline component that switches vertical/horizontal with accessibility, theming, and performance best practices.

ASOasis
10 min read
Building a Responsive React Timeline (Vertical & Horizontal)

Image used for representation purposes only.

Overview

Timelines are a familiar UI pattern for visualizing events along an axis. In product UIs they appear as delivery progress, audit histories, roadmaps, or learning paths. In this article you’ll build a single React Timeline component that:

  • Renders vertically or horizontally
  • Automatically switches orientation based on available width
  • Is accessible (keyboard and screen reader friendly)
  • Is themeable with CSS variables and dark-mode aware
  • Supports light animations and large lists (with pointers for virtualization)

You’ll get a production-ready baseline plus tips for extending it.

Component goals and API design

Before coding, clarify the API. We’ll keep it small and predictable.

  • items: TimelineItem[]
  • orientation: ‘vertical’ | ‘horizontal’ | ‘auto’ (default ‘auto’)
  • reverse?: boolean (render most-recent-first)
  • lineColor?: string (CSS color string)
  • alternate?: boolean (optional, for advanced vertical layouts — we’ll keep a simple vertical baseline first)
  • ariaLabel?: string (for assistive tech)
  • renderItem?: custom renderer override

TypeScript types:

export type Orientation = 'vertical' | 'horizontal' | 'auto';

export type TimelineItem = {
  id: string;
  title: string;
  subtitle?: string;
  description?: React.ReactNode;
  date?: string | Date;
  icon?: React.ReactNode;
  status?: 'default' | 'success' | 'warning' | 'error' | 'info';
  dotColor?: string;
};

export type TimelineProps = {
  items: TimelineItem[];
  orientation?: Orientation;
  reverse?: boolean;
  lineColor?: string;
  className?: string;
  ariaLabel?: string;
  renderItem?: (item: TimelineItem, index: number) => React.ReactNode;
  thresholdPx?: number; // min width to flip to horizontal when orientation='auto'
};

Layout strategy

We’ll use a shared “axis” defined by CSS custom properties.

  • Vertical: a single vertical line to the left; each item’s card sits to the right with a dot on the line.
  • Horizontal: a horizontal line across the top; each item’s card sits below with a dot on the line.

This keeps markup simple and robust, and it adapts well to responsive containers.

Styles with CSS variables

Create timeline.css with sensible defaults, dark-mode, and reduced-motion support.

/* timeline.css */
.tl {
  --tl-line: #e5e7eb;          /* axis color */
  --tl-connector: #e5e7eb;     /* small line from axis to card */
  --tl-dot: #3b82f6;           /* default dot color */
  --tl-line-w: 2px;            /* axis thickness */
  --tl-dot-size: 12px;         /* dot diameter */
  --tl-gap: 1rem;              /* gap between items */
  --tl-card-bg: #ffffff;       /* card background */
  --tl-card-fg: #111827;       /* card text */
}

/* Vertical layout */
.tl--vertical {
  position: relative;
  padding-left: calc(var(--tl-axis-x, 16px) + var(--tl-dot-size) + 0.75rem);
  display: grid;
  row-gap: var(--tl-gap);
}
.tl--vertical::before {
  content: '';
  position: absolute;
  left: var(--tl-axis-x, 16px);
  top: 0;
  bottom: 0;
  width: var(--tl-line-w);
  background: var(--tl-line);
}

.tl__item { position: relative; }

.tl--vertical .tl__dot {
  position: absolute;
  left: calc(var(--tl-axis-x, 16px) - var(--tl-dot-size) / 2);
  top: 0.9rem; /* aligns dot with card header */
  width: var(--tl-dot-size);
  height: var(--tl-dot-size);
  background: var(--tl-dot);
  border-radius: 999px;
  border: 2px solid #fff;
  box-shadow: 0 0 0 2px var(--tl-line);
}
.tl--vertical .tl__connector {
  position: absolute;
  left: var(--tl-axis-x, 16px);
  top: calc(0.9rem + var(--tl-dot-size) / 2 - 1px);
  width: 0.75rem;
  height: 2px;
  background: var(--tl-connector);
}

/* Horizontal layout */
.tl--horizontal {
  position: relative;
  padding-top: calc(var(--tl-axis-y, 24px) + var(--tl-dot-size) + 0.5rem);
  display: flex;
  gap: var(--tl-gap);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-gutter: stable both-edges;
}
.tl--horizontal::before {
  content: '';
  position: absolute;
  top: var(--tl-axis-y, 24px);
  left: 0;
  right: 0;
  height: var(--tl-line-w);
  background: var(--tl-line);
}
.tl--horizontal .tl__item { min-width: 14rem; }
.tl--horizontal .tl__dot {
  position: absolute;
  top: calc(var(--tl-axis-y, 24px) - var(--tl-dot-size) / 2);
  left: 0.5rem;
  width: var(--tl-dot-size);
  height: var(--tl-dot-size);
  background: var(--tl-dot);
  border-radius: 999px;
  border: 2px solid #fff;
  box-shadow: 0 0 0 2px var(--tl-line);
}

/* Card */
.tl__card {
  background: var(--tl-card-bg);
  color: var(--tl-card-fg);
  border-radius: 0.5rem;
  border: 1px solid #e5e7eb;
  padding: 0.75rem 1rem;
  box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.tl__title { margin: 0; font-weight: 600; font-size: 0.975rem; }
.tl__subtitle { margin: 0.125rem 0 0.25rem; color: #6b7280; font-size: 0.85rem; }
.tl__meta { color: #6b7280; font-size: 0.8rem; }

/* Status colors */
.tl__item--success { --tl-dot: #10b981; }
.tl__item--warning { --tl-dot: #f59e0b; }
.tl__item--error   { --tl-dot: #ef4444; }
.tl__item--info    { --tl-dot: #3b82f6; }

/* Simple entrance animation */
@keyframes tl-fade-slide { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.tl__card[data-animate='in'] { animation: tl-fade-slide 300ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .tl__card[data-animate='in'] { animation: none; }
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
  .tl { --tl-line: #334155; --tl-connector: #334155; --tl-card-bg: #0b1220; --tl-card-fg: #e5e7eb; }
  .tl__card { border-color: #1f2937; box-shadow: 0 1px 2px rgba(0,0,0,.5); }
}

Notes:

  • The axis position is controlled by –tl-axis-x for vertical, and –tl-axis-y for horizontal, so design can tweak spacing per theme.
  • Status classes modify only the dot color.

The React component

This is a concise, composable implementation with auto-orientation via ResizeObserver and keyboard navigation.

// Timeline.tsx
import React from 'react';

export type Orientation = 'vertical' | 'horizontal' | 'auto';

export type TimelineItem = {
  id: string;
  title: string;
  subtitle?: string;
  description?: React.ReactNode;
  date?: string | Date;
  icon?: React.ReactNode;
  status?: 'default' | 'success' | 'warning' | 'error' | 'info';
  dotColor?: string;
};

export type TimelineProps = {
  items: TimelineItem[];
  orientation?: Orientation;
  reverse?: boolean;
  lineColor?: string;
  className?: string;
  ariaLabel?: string;
  renderItem?: (item: TimelineItem, index: number) => React.ReactNode;
  thresholdPx?: number;
};

function useAutoOrientation(
  orientation: Orientation,
  thresholdPx: number | undefined,
  ref: React.RefObject<HTMLElement>
) {
  const [auto, setAuto] = React.useState<'vertical' | 'horizontal'>('vertical');

  React.useEffect(() => {
    if (orientation !== 'auto') return;
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver((entries) => {
      for (const e of entries) {
        const w = e.contentRect.width;
        setAuto(w >= (thresholdPx ?? 640) ? 'horizontal' : 'vertical');
      }
    });
    ro.observe(node);
    return () => ro.disconnect();
  }, [orientation, thresholdPx, ref]);

  return orientation === 'auto' ? auto : orientation;
}

function useInView<T extends Element>(options?: IntersectionObserverInit) {
  const ref = React.useRef<T | null>(null);
  const [inView, set] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver((ents) => {
      ents.forEach((ent) => set(ent.isIntersecting));
    }, options ?? { rootMargin: '0px 0px -20% 0px', threshold: 0.1 });
    io.observe(el);
    return () => io.disconnect();
  }, [options]);
  return { ref, inView } as const;
}

export const Timeline: React.FC<TimelineProps> = ({
  items,
  orientation = 'auto',
  reverse,
  lineColor,
  className,
  ariaLabel,
  renderItem,
  thresholdPx = 640,
}) => {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const finalOrientation = useAutoOrientation(orientation, thresholdPx, containerRef);
  const ordered = React.useMemo(() => (reverse ? [...items].reverse() : items), [items, reverse]);

  // keyboard navigation (arrow keys move focus between items)
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
    const nodes = containerRef.current?.querySelectorAll<HTMLDivElement>('.tl__item[tabindex="0"]');
    if (!nodes || nodes.length === 0) return;
    const active = document.activeElement as HTMLElement | null;
    const idx = Array.from(nodes).findIndex((n) => n === active);

    let next = idx;
    if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = nodes.length - 1;
    else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(nodes.length - 1, (idx < 0 ? 0 : idx + 1));
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, (idx < 0 ? 0 : idx - 1));

    nodes[next]?.focus();
    e.preventDefault();
  };

  const style: React.CSSProperties = lineColor ? { ['--tl-line' as any]: lineColor } : undefined;

  return (
    <div
      ref={containerRef}
      className={['tl', finalOrientation === 'vertical' ? 'tl--vertical' : 'tl--horizontal', className].filter(Boolean).join(' ')}
      role='list'
      aria-label={ariaLabel ?? 'Timeline'}
      onKeyDown={onKeyDown}
      style={style}
    >
      {ordered.map((item, i) => (
        <TimelineNode key={item.id} item={item} index={i} orientation={finalOrientation} renderItem={renderItem} />
      ))}
    </div>
  );
};

const TimelineNode: React.FC<{
  item: TimelineItem;
  index: number;
  orientation: 'vertical' | 'horizontal';
  renderItem?: (item: TimelineItem, index: number) => React.ReactNode;
}> = ({ item, index, orientation, renderItem }) => {
  const { ref, inView } = useInView<HTMLDivElement>();
  const cls = ['tl__item', item.status ? `tl__item--${item.status}` : ''].filter(Boolean).join(' ');

  return (
    <div ref={ref} className={cls} role='listitem' tabIndex={0} aria-label={`${item.title}${item.date ? `, ${String(item.date)}` : ''}`}>
      <span
        className='tl__dot'
        aria-hidden='true'
        style={item.dotColor ? ({ ['--tl-dot' as any]: item.dotColor } as React.CSSProperties) : undefined}
      />
      {orientation === 'vertical' && <span className='tl__connector' aria-hidden='true' />}
      {renderItem ? (
        renderItem(item, index)
      ) : (
        <article className='tl__card' data-animate={inView ? 'in' : undefined}>
          <header style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
            {item.icon && <span aria-hidden='true'>{item.icon}</span>}
            <div>
              <h3 className='tl__title'>{item.title}</h3>
              {item.subtitle && <p className='tl__subtitle'>{item.subtitle}</p>}
            </div>
          </header>
          {item.description && <div className='tl__desc'>{item.description}</div>}
          {item.date && <div className='tl__meta'>{typeof item.date === 'string' ? item.date : item.date.toLocaleString()}</div>}
        </article>
      )}
    </div>
  );
};

Notes:

  • ResizeObserver runs only on the client within useEffect, so it’s safe for SSR frameworks like Next.js; server output defaults to vertical until hydrated.
  • The axis color can be overridden via the lineColor prop, which sets the CSS variable –tl-line.

Usage example

Wire it up with sample data.

// App.tsx
import React from 'react';
import { Timeline, TimelineItem } from './Timeline';
import './timeline.css';

const items: TimelineItem[] = [
  { id: '1', title: 'Project created', subtitle: 'Repository initialized', date: '2026-05-01', status: 'info' },
  { id: '2', title: 'Design review', subtitle: 'UI/UX sign-off', date: '2026-05-10', status: 'success' },
  { id: '3', title: 'Beta release', subtitle: 'Internal dogfood', date: '2026-06-04', status: 'warning' },
  { id: '4', title: 'Public launch', subtitle: 'v1.0', date: '2026-07-12', status: 'success' },
];

export default function App() {
  return (
    <main style={{ padding: 24 }}>
      <h1>Roadmap</h1>
      <Timeline items={items} orientation='auto' ariaLabel='Product roadmap' thresholdPx={720} />

      <h2 style={{ marginTop: 32 }}>Horizontal (forced)</h2>
      <Timeline items={items} orientation='horizontal' lineColor='#94a3b8' />

      <h2 style={{ marginTop: 32 }}>Vertical (forced)</h2>
      <Timeline items={items} orientation='vertical' reverse />
    </main>
  );
}

Accessibility checklist

  • Semantics: role=‘list’ for the container and role=‘listitem’ for nodes.
  • Labels: aria-label on the container; each node’s aria-label includes title and, if present, the date.
  • Keyboard: ArrowLeft/Right (or Up/Down) to move focus; Home/End jump to extremes.
  • Color contrast: dots and lines are decorations; information should also exist in text (e.g., status text or icon with aria-hidden and descriptive text nearby).
  • Reduced motion: respect prefers-reduced-motion and disable animations.

Making it themeable

Because styles use CSS custom properties, you can theme per component or globally.

/* Light brand */
.light .tl {
  --tl-line: #e2e8f0;
  --tl-connector: #cbd5e1;
  --tl-dot: #2563eb;
  --tl-card-bg: #ffffff;
  --tl-card-fg: #0f172a;
}

/* Dark brand */
.dark .tl {
  --tl-line: #475569;
  --tl-connector: #475569;
  --tl-dot: #60a5fa;
  --tl-card-bg: #0b1220;
  --tl-card-fg: #e2e8f0;
}

Override per item with dotColor or per-status via the status classes.

Performance tips

  • Avoid heavy render work in renderItem; preformat data outside the component.
  • For very large lists (1000+ items), render only a window:
    • Vertical: use react-window or react-virtual.
    • Horizontal: compute visible range from container.scrollLeft and clientWidth.

Minimal horizontal windowing example:

// Pseudocode: window indexes based on scroll position
const [range, setRange] = React.useState({ start: 0, end: 20 });
const ref = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
  const el = ref.current!;
  const onScroll = () => {
    const itemW = 240; // min card width incl. gap
    const start = Math.floor(el.scrollLeft / itemW) - 3; // overscan
    const end = Math.ceil((el.scrollLeft + el.clientWidth) / itemW) + 3;
    setRange({ start: Math.max(0, start), end });
  };
  el.addEventListener('scroll', onScroll, { passive: true });
  onScroll();
  return () => el.removeEventListener('scroll', onScroll);
}, []);

// Render only items[range.start..range.end]

Testing the component

Use React Testing Library to assert orientation classes, ARIA, and keyboard behavior.

// Timeline.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Timeline } from './Timeline';

it('renders list items with accessible labels', () => {
  render(<Timeline items={[{ id: '1', title: 'Created' }]} orientation='vertical' />);
  const list = screen.getByRole('list', { name: /timeline/i });
  expect(list).toBeInTheDocument();
  const item = screen.getByRole('listitem', { name: /created/i });
  expect(item).toBeInTheDocument();
});

it('supports keyboard navigation', () => {
  render(
    <Timeline
      items={Array.from({ length: 3 }, (_, i) => ({ id: String(i), title: `Item ${i}` }))}
      orientation='horizontal'
    />
  );
  const nodes = screen.getAllByRole('listitem');
  nodes[0].focus();
  fireEvent.keyDown(nodes[0].parentElement!, { key: 'ArrowRight' });
  expect(document.activeElement).toBe(nodes[1]);
});

Optional enhancements

  • Alternating vertical layout: place cards left and right of the axis by switching container padding and connector direction per nth-child. Start with an API flag alternate and add CSS selectors.
  • Milestones and ranges: support segments on the axis (e.g., start/end markers) by drawing additional positioned elements.
  • Tooltips: show extra details on dot hover/focus; ensure they are focusable and accessible.
  • RTL support: for horizontal timelines in right-to-left locales, reverse scroll direction and swap ArrowLeft/Right hints; you can set dir=‘rtl’ on the container and adjust connector positions via :dir(rtl) CSS.

Conclusion

You now have a flexible, accessible React Timeline that renders vertically or horizontally and can switch automatically based on width. The CSS-variables approach keeps theming straightforward, and the component API remains small yet extensible. From here you can add alternating layouts, virtualization for massive datasets, or advanced visuals like ranged events and milestone clusters—all on the same foundation.

Related Posts