Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
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.0from 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.
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
Mai Tran
Duc Pham
Mai Tran
Mai Tran
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 isactive— witharia-current="step"— and items after it arepending. The state is written todata-statuson the item, its dot and its content. - A connector is drawn in
primary-inkwhen 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.
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
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-orientationon the list. ARIA 1.2 does not allow it onrole="list", and axe reported it (aria-allowed-attr). The orientation stays indata-orientation. - Colour by role. Reached dots and lines use
primary-ink, the token for brand colour standing on the page, instead of the fill tokenprimary. Pending dots usemuted-foreground— 5.23:1 light, 5.70:1 dark, the same as StatusPill’s neutral outline — instead of the decorativeborderat 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.
Mai Tran
Duc Pham
Mai Tran
Mai Tran
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 — 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
| Prop | Kiểu | Mặc định | Mô 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). |
activeIndex | number | — | 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. |
asChild | boolean | false | Merge onto your own element — an ol, say — instead of rendering a div with role list. |
className | string | — | 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
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
TimelineItem id | string | useId() | Both the DOM id and the key the item registers its position under — unique on the page if you pass one. |
TimelineItem aria-current | AriaAttributes['aria-current'] | 'step' on the active item | Your own value wins, since the item spreads your props after its own. |
TimelineDot children | ReactNode | — | 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 className | string | — | 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 forceMount | boolean | false | The last item gets no line. forceMount draws one anyway — for a list that goes on past a Load more button. |
TimelineConnector className | string | — | bg-primary-ink when the item after it has been reached (data-completed is set), bg-border otherwise. aria-hidden, always. |
asChild | boolean | false | On 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.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
TimelineContent | div · flex-1 | — | The text beside the dot. data-status; in the alternate layout it aligns towards the centre line. |
TimelineHeader | div · flex flex-col gap-1 | — | Title and time, stacked. flex-row puts them on one line, as the activity demo does. |
TimelineTitle | div · leading-none font-semibold | — | Not a heading. asChild with an h3 when titles should appear in a screen reader’s list of headings. |
TimelineDescription | div · text-sm text-muted-foreground | — | Secondary text. |
TimelineTime | time · 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
TimelineItemwritten directly in theTimeline, through.map, fragments, conditions and plain HTML elements. Anything else inside the timeline — a component of your own, aSuspense, 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 carriesaria-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 aTimelineItemcan still be your own. A client-only wrapper added before3.1.0to 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
activeIndexfor every item after it. TimelineTimedoes not format. Upstream’s part never did; it is a styled<time>. See Language.
Accessibility
- The root is
role="list"and each itemrole="listitem", so a screen reader announces “list, 5 items” and lets the reader step through them. Connectors arearia-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 anddata-statusonly. Put the state in words: visible text, or ansr-onlyprefix as in the order demo. - Do not lean on the ring colour either.
primary-inkfollows 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. TimelineTimeis a real<time>. Give it a machine-readabledateTime;RelativeTimesets one for you, and atitlewith the absolute date.TimelineTitleis not a heading. On a long record page where people jump between events, render it as one withasChild.
Related
CRM pattern
A contact’s history of calls, emails and stage changes — the activity feed on a record page.
HR pattern
Employment history and onboarding progress on an employee profile.
Commerce pattern
Order tracking — the horizontal timeline with a current step.
Relative Time
“2 hours ago” that follows the language provider — the natural thing to put inside TimelineTime.
Stepper
When the steps are a form the user walks through, rather than a record of what happened.
Kanban
Where the cards live; a card’s timeline is what you see once it opens.