defuss-shadcn / Data Display / timeline
TimelineATM
A vertical timeline for displaying sequential events. Built on <ol> with CSS-generated connector lines.
On this page (9)
§Basic
Vertical timeline with title, description, and time.
§Activity Feed
Timeline composed with .avatar and .badge for an activity feed pattern.
§Horizontal
data-orientation="horizontal" lays the items out as a row of equal columns - dot on top, content below, the connector running from dot to dot. The outline dot marks what is still planned.
§Horizontal, centered
data-align="center" centers each dot over its column and the text under it - a release line.
§Horizontal, alternating
data-alternate puts every second item's content above the line, so dense histories get twice the room. All dots stay on one line - the rows are a subgrid shared by every item.
§Scrolling
Columns never get narrower than 10rem: when the row is full it scrolls sideways and snaps to each item. The container here is 32rem wide.
§Icons and status
Put an icon inside the dot (a lucide <i data-lucide>, an svg or an img) and it becomes a 1.5rem badge - the connector re-centers on it. data-variant="active", "destructive" and "outline" color it by status. Works horizontally too.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§CSS view file
Connector line
@layer components { .timeline { list-style: none; margin: 0; padding: 0; position: relative; } .timeline-item { display: flex; gap: 1rem; padding-bottom: 1.5rem; position: relative; /* Connector line */ &::before { content: ''; position: absolute; left: 0.3125rem; top: 1rem; bottom: 0; width: 1px; background-color: var(--border); } &:last-child::before { display: none; } &:last-child { padding-bottom: 0; } } .timeline-dot { width: 0.625rem; height: 0.625rem; border-radius: 9999px; background-color: var(--border); flex-shrink: 0; margin-top: 0.375rem; position: relative; z-index: 1; &[data-variant="active"] { background-color: var(--primary); } &[data-variant="destructive"] { background-color: var(--destructive); } /* a hollow ring: planned / upcoming */ &[data-variant="outline"] { background-color: var(--background); box-shadow: inset 0 0 0 2px var(--primary); } /* an icon inside the dot (svg / img / lucide <i data-lucide>) turns it into a 1.5rem badge; the connector re-centers on it (below) */ &:has(> :is(svg, img)) { display: grid; place-items: center; width: 1.5rem; height: 1.5rem; margin-top: -0.125rem; background-color: var(--muted); color: var(--foreground); & > :is(svg, img) { width: 0.875rem; height: 0.875rem; } &[data-variant="active"] { background-color: var(--primary); color: var(--primary-foreground); } &[data-variant="destructive"] { background-color: var(--destructive); color: var(--destructive-foreground); } &[data-variant="outline"] { background-color: var(--background); color: var(--foreground); box-shadow: inset 0 0 0 1px var(--border); } } } /* vertical connector under an icon dot: centered on the 1.5rem badge */ .timeline-item:has(> .timeline-dot > :is(svg, img))::before { left: calc(0.75rem - 0.5px); top: 1.75rem; bottom: 0.5rem; } .timeline-content { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; flex: 1; } .timeline-title { margin: 0; font-size: 0.875rem; font-weight: 500; color: var(--foreground); line-height: 1.4; } .timeline-description { margin: 0; font-size: 0.8125rem; color: var(--muted-foreground); line-height: 1.5; } .timeline-time { font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.125rem; } /* -- Density ---------------------------------------------------- data-density on the .timeline root scales the item rhythm (connector gap + trailing space). comfortable == the unsized default. The padding-bottom rule is gated :not(:last-child) so the base `:last-child { padding-bottom: 0 }` (same specificity, earlier source) keeps winning for the final item. */ .timeline:where([data-density="compact"]) { & .timeline-item { gap: 0.75rem; } & .timeline-item:not(:last-child) { padding-bottom: 1.125rem; } } .timeline:where([data-density="comfortable"]) { & .timeline-item { gap: 1rem; } & .timeline-item:not(:last-child) { padding-bottom: 1.5rem; } } .timeline:where([data-density="spacious"]) { & .timeline-item { gap: 1.25rem; } & .timeline-item:not(:last-child) { padding-bottom: 1.875rem; } } /* -- Horizontal --------------------------------------------------- data-orientation="horizontal": one equal column per item (at least 10rem - more items scroll sideways, snapping to each), the dot on top, the content below. The rows are a SUBGRID across all items, so every dot sits on one line even when contents differ in height. The connector is the item's ::before as a GRID ITEM in the dot's row: it starts 0.375rem after the dot and runs through the item's trailing padding to 0.375rem before the next dot. data-align="center" centers dot and text in the column; data-alternate puts every second item's content ABOVE the line. The item gap (density-scaled) spaces dot and content. Declared after the density rules, which it overrides where they meet (padding). */ .timeline[data-orientation="horizontal"] { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(10rem, 1fr); grid-template-rows: auto auto; overflow-x: auto; overscroll-behavior-inline: contain; scroll-snap-type: x proximity; scrollbar-width: thin; & > .timeline-item { display: grid; grid-row: span 2; grid-template-rows: subgrid; grid-template-columns: minmax(0, 1fr); column-gap: 0; padding: 0; padding-inline-end: 1.5rem; scroll-snap-align: start; & > .timeline-dot { grid-row: 1; grid-column: 1; margin: 0; justify-self: start; align-self: center; } & > .timeline-content { grid-row: 2; grid-column: 1; } &::before { position: static; grid-row: 1; grid-column: 1; align-self: center; width: auto; height: 1px; margin-inline: 1rem -1.125rem; } &:has(> .timeline-dot > :is(svg, img))::before { margin-inline: 1.875rem -1.125rem; } &:last-child { padding-inline-end: 0; } } &[data-align="center"] > .timeline-item { text-align: center; & > .timeline-dot { justify-self: center; } & > .timeline-content { align-items: center; } /* percentages resolve against the column: from just past this dot's center to just before the next one's */ &::before { margin-inline: calc(50% + 0.6875rem) calc(-50% - 0.8125rem); } &:has(> .timeline-dot > :is(svg, img))::before { margin-inline: calc(50% + 1.125rem) calc(-50% - 0.375rem); } } &[data-alternate] { grid-template-rows: auto auto auto; & > .timeline-item { grid-row: span 3; & > .timeline-dot, &::before { grid-row: 2; } & > .timeline-content { grid-row: 3; } &:nth-child(even) > .timeline-content { grid-row: 1; align-self: end; } } } } /* -- Accessibility ------------------------------------------ forced colors drop background colors: keep the dots and the connector visible in system colors */ @media (forced-colors: active) { .timeline-dot { forced-color-adjust: none; background-color: CanvasText; color: Canvas; } .timeline-dot[data-variant="active"] { background-color: Highlight; color: HighlightText; } .timeline-dot[data-variant="outline"] { background-color: Canvas; box-shadow: inset 0 0 0 2px CanvasText; color: CanvasText; } .timeline-item::before { forced-color-adjust: none; background-color: CanvasText; } }}Comments, ideas or improvements? Edit this page's source on GitHub