Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

Timeline

A vertical or horizontal run of events, each one completed, current or still to come. In a Comitor app it is the activity on a CRM contact, the employment history on an HR profile, the progress of an order. The component draws the line and the dots and works out each item’s state; the words and the dates are yours.

Import
import { Timeline, TimelineItem, TimelineDot, … } from '@comitor/ui' — the main entry, with no optional peer.
Client
The file starts with "use client", but no part needs a function prop, so a Server Component can render a whole timeline. Written in a client component, the states and lines are in the server HTML; written straight in a Server Component, they arrive at hydration — see Known limitations.
Origin
Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own primitives and tokens. See the changelog.

Anatomy

Nine parts, most of them plain layout. The three that do work are Timeline, which holds the orientation and the activeIndex; TimelineItem, which registers its position and derives its state from it; and TimelineConnector, which asks the next item for its state to decide its colour — and whether it is drawn at all.

tsx
import {
  Timeline, TimelineItem, TimelineDot, TimelineConnector, TimelineContent,
  TimelineHeader, TimelineTitle, TimelineTime, TimelineDescription,
} from '@comitor/ui'

<Timeline activeIndex={2}>             {/* role="list" — vertical unless you say otherwise */}
  <TimelineItem>                       {/* role="listitem" — completed, active or pending */}
    <TimelineDot />                    {/* an empty ring, or whatever you put in it */}
    <TimelineConnector />              {/* the line to the next item — none after the last */}
    <TimelineContent>
      <TimelineHeader>
        <TimelineTitle>Shipped</TimelineTitle>
        <TimelineTime dateTime="2026-08-28">Aug 28</TimelineTime>
      </TimelineHeader>
      <TimelineDescription>Handed to the carrier in Ho Chi Minh City.</TimelineDescription>
    </TimelineContent>
  </TimelineItem>
</Timeline>

Activity on a contact

A log, newest first. There is no activeIndex — in a record of what happened, nothing is current — so every ring keeps the neutral pending style and every line the decorative border colour. The dots are grown to 1.75rem through --timeline-dot-size to hold an icon; the connector reads the same variable, so it stays centred under them. The times are RelativeTime, merged into TimelineTime with asChild.

Alice Nguyen · VP Engineering, Aurora Logistics

Sent the revised proposal
40 seats, annual billing, SSO included. Opened twice since.

Mai Tran

Discovery call
32 minutes. Security review needs SAML and a signed DPA before procurement.

Duc Pham

Demo for the platform team
Four attendees. Follow-up questions on audit-log retention.

Mai Tran

Stage moved to Proposal
From Qualified, once the budget was confirmed.

Mai Tran

Contact created from the website form
Asked for a demo of the logistics plan.

Linh Vo

Where it stands: activeIndex

activeIndex is the zero-based position of the current item, counted over the rendered TimelineItems in document order. Everything else follows from it.

  • Items before it are completed, the one at it is active — with aria-current="step" — and items after it are pending. The state is written to data-status on the item, its dot and its content.
  • A connector is drawn in primary-ink when the item after it has been reached: the line leading into a completed or active step. The last item has none.
  • Left out, every item is pending. activeIndex={items.length} marks every item completed and none current — the order below, once delivered.
  • By default a completed dot and the active dot are the same ring. This demo tells them apart — a filled check, a centre dot — and works that out from its own index: the parts give their state to CSS, through data-status, but not to your code.
Completed: Ordered
Completed: Packed
Shipped
Not yet: Out for delivery
Not yet: Delivered
activeIndex={2}

Ordered: completed · Packed: completed · Shipped: active · Out for delivery: pending · Delivered: pending

Alternate layout

variant="alternate" hangs every second item on the other side of a centre line, with the dots and the line on it — a career read at a glance. Each item takes half the width, so give the timeline room. Here the current role is the active item and the defaults are left alone: completed and active rings look the same, and every line leading into a reached role is drawn.

Lê Thị Mai · Product Design

Joined as Junior Product Designer
Design team, Ho Chi Minh City office.
Moved to the Payments squad
Checkout and invoicing flows.
Promoted to Product Designer
After the H2 2023 review cycle.
Relocated to the Hanoi office
Same squad, new city.
Design lead, onboarding redesign
Current role.

What the port guarantees

  • The server render has the states. Upstream learned each item’s index in a layout effect, so on the server and in the first client render every index was −1 — and, compared with activeIndex, −1 painted every dot completed until hydration. The root now reads its items from its children as it renders, until they have registered, so the server HTML already has each state, aria-current, the connectors and the alternate sides. Where it cannot read them (Known limitations), an unknown index means pending — neutral, and right for most items — never completed.
  • No aria-orientation on the list. ARIA 1.2 does not allow it on role="list", and axe reported it (aria-allowed-attr). The orientation stays in data-orientation.
  • Colour by role. Reached dots and lines use primary-ink, the token for brand colour standing on the page, instead of the fill token primary. Pending dots use muted-foreground — 5.23:1 light, 5.70:1 dark, the same as StatusPill’s neutral outline — instead of the decorative border at 1.16:1. Lines not yet reached stay decorative.

Language

Timeline has no strings of its own and no namespace in ComitorLocaleProvider. TimelineTime renders a <time> and prints what you give it. So a time follows the language only if something that follows the language produced it: put RelativeTime inside it, which takes its date-fns locale from the provider, or format with Intl and useComitorLocale().locale (see setting the language). Switch the demo: only the times change. The titles and descriptions are yours to translate, the way the rest of your app does it.

Sent the revised proposal
· Aug 30, 2026
40 seats, annual billing, SSO included. Opened twice since.

Mai Tran

Discovery call
· Aug 29, 2026
32 minutes. Security review needs SAML and a signed DPA before procurement.

Duc Pham

Demo for the platform team
· Aug 26, 2026
Four attendees. Follow-up questions on audit-log retention.

Mai Tran

Stage moved to Proposal
· Aug 25, 2026
From Qualified, once the budget was confirmed.

Mai Tran

Contact created from the website form
· Aug 18, 2026
Asked for a demo of the logistics plan.

Linh Vo

Pass timeZone to every Intl formatter whose output is server-rendered — otherwise the server’s time zone and the reader’s can put the same event on different days, and React reports a hydration mismatch. RelativeTime is safe with a fixed now; its tooltip still uses the local zone, which it tolerates with suppressHydrationWarning. Formatting on the server works just as well:

app/contacts/[id]/page.tsx
// app/contacts/[id]/page.tsx — a Server Component. No part of Timeline needs a
// function prop, so the whole thing can be rendered here. The one cost: written in
// a Server Component, the items stay out of the root's sight until hydration, so
// the connecting lines only appear then (see Known limitations).
import { Timeline, TimelineItem, TimelineDot, TimelineConnector, TimelineContent,
  TimelineTitle, TimelineTime } from '@comitor/ui'

export default async function ContactPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const activity = await getActivity(id)

  return (
    <Timeline>
      {activity.map((event) => (
        <TimelineItem key={event.id}>
          <TimelineDot />
          <TimelineConnector />
          <TimelineContent>
            <TimelineTitle>{event.title}</TimelineTitle>
            {/* Formatted here, on the server — TimelineTime prints it as given. */}
            <TimelineTime dateTime={event.at}>{formatDay(event.at)}</TimelineTime>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  )
}

Props

Timeline

PropKiểuMặc địnhMô tả
orientation'vertical' | 'horizontal''vertical'Vertical stacks the items with the line down the start edge; horizontal lays them in a row with the line across the top. Written to data-orientation on the root and on every item, dot and connector.
variant'default' | 'alternate''default'alternate puts every second item on the other side of a centre line (vertical) or above and below it (horizontal). The side comes from the item's index — in the server render too, unless the root cannot read its items (see Known limitations).
activeIndexnumber—Zero-based index of the current item among the rendered items, in document order. Before it: completed. At it: active. After it: pending. Left out, every item is pending; items.length marks them all completed.
dir'ltr' | 'rtl'Radix DirectionProvider, else 'ltr'Written to the root and to every item. In the default variant the line and the dots sit at logical start offsets and follow it; the alternate variant places them with physical left and right, and does not.
asChildbooleanfalseMerge onto your own element — an ol, say — instead of rendering a div with role list.
classNamestring—Merged with tailwind-merge, which makes it the place to set --timeline-dot-size (0.875rem) and --timeline-connector-thickness (0.125rem).

TimelineItem, TimelineDot, TimelineConnector

PropKiểuMặc địnhMô tả
TimelineItem idstringuseId()Both the DOM id and the key the item registers its position under — unique on the page if you pass one.
TimelineItem aria-currentAriaAttributes['aria-current']'step' on the active itemYour own value wins, since the item spreads your props after its own.
TimelineDot childrenReactNode—Centred inside the ring — an icon, a check, an initial. Grow the ring with --timeline-dot-size on the root so the connector stays centred under it.
TimelineDot classNamestring—Merged over the status classes: border-primary-ink for completed and active, border-muted-foreground for pending, bg-background for all. data-status is set for styling.
TimelineConnector forceMountbooleanfalseThe last item gets no line. forceMount draws one anyway — for a list that goes on past a Load more button.
TimelineConnector classNamestring—bg-primary-ink when the item after it has been reached (data-completed is set), bg-border otherwise. aria-hidden, always.
asChildbooleanfalseOn every part. TimelineItem asChild on an li pairs with Timeline asChild on an ol.

The text parts

Each renders one element with the classes below, takes asChild, and forwards its props.

PropKiểuMặc địnhMô tả
TimelineContentdiv · flex-1—The text beside the dot. data-status; in the alternate layout it aligns towards the centre line.
TimelineHeaderdiv · flex flex-col gap-1—Title and time, stacked. flex-row puts them on one line, as the activity demo does.
TimelineTitlediv · leading-none font-semibold—Not a heading. asChild with an h3 when titles should appear in a screen reader’s list of headings.
TimelineDescriptiondiv · text-sm text-muted-foreground—Secondary text.
TimelineTimetime · text-xs text-muted-foreground—Prints its children as given — no formatting, no locale. Pass dateTime with a machine-readable value, or asChild around RelativeTime, which sets both.

Known limitations

  • Items the root cannot see wait for hydration. The root reads its items from its children as it renders: each TimelineItem written directly in the Timeline, through .map, fragments, conditions and plain HTML elements. Anything else inside the timeline — a component of your own, a Suspense, a context provider — hides the items, and so does writing the tree straight in a Server Component. Then until the page hydrates every item is pending, none carries aria-current, no connector is drawn, and in the alternate layout every item sits on the first side — no mismatch warning, but a Next app shows that first frame. Write the items out in a client component; what goes inside a TimelineItem can still be your own. A client-only wrapper added before 3.1.0 to hide that frame can go.
  • Completed and active look alike. Both get the same ring, and the line is the only difference. Style by data-status, or from your own index, and say the state in words (next section).
  • The index is the document order. Items register themselves, so a conditionally rendered item — or a wrapper that reorders them visually — shifts activeIndex for every item after it.
  • TimelineTime does not format. Upstream’s part never did; it is a styled <time>. See Language.

Accessibility

  • The root is role="list" and each item role="listitem", so a screen reader announces “list, 5 items” and lets the reader step through them. Connectors are aria-hidden; dots are plain elements with no role.
  • The active item carries aria-current="step". Completed and pending are not announced at all — they exist as colour and data-status only. Put the state in words: visible text, or an sr-only prefix as in the order demo.
  • Do not lean on the ring colour either. primary-ink follows the palette. The default palette maps it to the brand gold itself: 12.67:1 on the dark background, but 1.86:1 on white — below the 3:1 a meaningful graphic needs. The high-contrast palette darkens it to 6.51:1 in light. Icons, fills and words carry the state in every palette.
  • Icons inside a dot are decoration next to a title that says the same thing: mark them aria-hidden.
  • TimelineTime is a real <time>. Give it a machine-readable dateTime; RelativeTime sets one for you, and a title with the absolute date.
  • TimelineTitle is not a heading. On a long record page where people jump between events, render it as one with asChild.

Related