Timeline

Timeline arranges cards, date labels, and compact events along a vertical line. The parent owns order, grouping, dates, and loading.

import Timeline, { TimelineItem, TimelineNote, TimelineSeparator } from "@haloid/azucar/ui/timeline";
import EventCard, { EventCardItem } from "@haloid/azucar/ui/event-card";

Entries

Mix full cards and compact notes in one sequence. Open a row to reveal its details.

<div className="rounded-2xl bg-bg-2 p-4 sm:p-8">
<Timeline
  ariaLabel="Activity history"
  className="mx-auto max-w-120"
  footer={
    <>
      <span aria-hidden="true" className="flex size-12 items-center justify-center rounded-full bg-bg-3">
        <SFFlame size={24} />
      </span>
      <Text variant="secondary" className="max-w-44 text-center">This is where the journey begins</Text>
    </>
  }
>
  <TimelineSeparator dateTime="2026-01-29">Today</TimelineSeparator>
  <TimelineItem>
    <EventCard
      summary={<Heading level={2} muted="lasted for 12m">Session</Heading>}
      footer={{
        leading: <><SFArrowForward size={16} aria-hidden="true" /> Direct</>,
        trailing: <><SFDisplay size={16} aria-hidden="true" /><span className="sr-only">Desktop</span><time dateTime="2026-01-29T14:13:00Z">02:13 PM</time></>,
      }}
    >
      <EventCardItem icon={<SFEye size={16} />} label="Step A" value="2s" />
      <EventCardItem icon={<SFEye size={16} />} label="Step B" value="22s" />
    </EventCard>
  </TimelineItem>
  <TimelineItem>
    <EventCard
      summary={<Heading level={2} muted="lasted for 26s">Session</Heading>}
      footer={{
        leading: <><SFArrowForward size={16} aria-hidden="true" /> Direct</>,
        trailing: <><SFDisplay size={16} aria-hidden="true" /><span className="sr-only">Desktop</span><time dateTime="2026-01-29T12:25:00Z">12:25 PM</time></>,
      }}
    >
      <EventCardItem
        icon={<SFCheckmarkCircle size={16} />}
        label="Completed"
        value="+714"
        tone="good"
        defaultOpen
      >
        <div className="flex flex-col gap-3">
          <div className="flex flex-wrap items-center justify-between gap-3">
            <Text inline>Item A</Text>
            <span className="ml-auto rounded-full bg-bg-1 px-2 text-fg-2">1</span>
            <Text inline>500.00</Text>
          </div>
          <Text variant="secondary">Step C finished on Jan 29, 2026 at 12:25 PM with a total of 500.</Text>
        </div>
      </EventCardItem>
      <EventCardItem icon={<SFEye size={16} />} label="Step D" value="968ms" />
    </EventCard>
  </TimelineItem>
  <TimelineNote icon={<SFArrowUturnBackward size={16} />} value="-36" tone="bad">
    reversed at{" "}<time dateTime="2026-01-29T11:23:00Z">11:23 AM</time>
  </TimelineNote>
  <TimelineItem>
    <EventCard
      summary={<Heading level={2} muted="lasted for 10m">Session</Heading>}
      footer={{
        leading: <><SFArrowForward size={16} aria-hidden="true" /> Direct</>,
        trailing: <><SFDisplay size={16} aria-hidden="true" /><span className="sr-only">Desktop</span><time dateTime="2026-01-29T11:12:00Z">11:12 AM</time></>,
      }}
    >
      <EventCardItem icon={<SFCheckmarkCircle size={16} />} label="Updated" value="+71" tone="good">
        <Text variant="secondary">Step E added 71 to the total.</Text>
      </EventCardItem>
      <EventCardItem icon={<SFEye size={16} />} label="Step F" value="5s" />
    </EventCard>
  </TimelineItem>
</Timeline>
</div>

TimelineItem places content on the line. EventCard supplies its own shell.

TimelineSeparator and TimelineNote are direct entries. Give mapped entries stable React keys. Arrays and fragments preserve the layout.

Sizing

The parent sets width. Cards fill it, notes wrap, and the timeline grows with its content.

The connecting line follows each entry's height. The parent owns scrolling and pagination.

Loading history

Use footer for a loading status or a pagination control. Show a beginning marker after the earliest history loads.

This example prepends an entry. Existing keyed entries keep their state.

function HistoryExample() {
const [loaded, setLoaded] = useState(false);
return (
  <Timeline
    ariaLabel="Oldest-first history"
    className="mx-auto max-w-100"
    footer={loaded
      ? "All history loaded"
      : <Button onClick={() => setLoaded(true)}>Load earlier</Button>}
  >
    {loaded && <TimelineNote key="earlier">Step A started</TimelineNote>}
    <TimelineItem key="current">
      <EventCard>
        <EventCardItem label="Step B" value="Complete" defaultOpen>
          <Text>These details keep their expansion state when earlier history loads.</Text>
        </EventCardItem>
      </EventCard>
    </TimelineItem>
  </Timeline>
);
}
render(<HistoryExample />);

Empty children draw no entries or line. Render empty and error states in the parent, or supply a footer.

Dates and accessibility

Set ariaLabel to name the ordered list. The line and event icons are decorative.

Give date separators a machine-readable dateTime. Use <time> for timestamps inside cards and notes.

The parent formats displayed dates and selects a timezone. It also decides when a label reads “Today”.

Styling

Separators use a neutral pill. Notes use a white pill with a quiet border. Their value accepts Azucar's five tones.

Notes and metadata use Text secondary styling at 14px. Card titles and rows follow the EventCard typography.

Text colors use darker semantic steps to stay readable. Keep signs or words that explain each value's meaning.

API

// Timeline: { children; ariaLabel: string; footer?; className? }
// TimelineItem: { children; className? }
// TimelineSeparator: { children; dateTime?: string; className? }
// TimelineNote: { children; icon?; value?; tone?: BadgeTone; className? }

Types are exported from @haloid/azucar/ui/timeline/types. Each part accepts className on its outer element.

Client components can use Timeline.Item, Timeline.Separator, and Timeline.Note. Named imports also work in Server Components.

Use in north

North maps its data to entries and supplies stable keys. It owns sorting, filtering, fetching, pagination, and timestamp formatting.