Theme
On this page (9)
Component Skill — components/timeline/component-skill.md

Native basis

<ol> element with timeline items connected by a line - vertical by default, horizontal with data-orientation="horizontal".

Web Platform APIs

<ol>::beforesubgridscroll-snap:has()

Classes

.timeline.timeline-item.timeline-dot.timeline-content.timeline-title.timeline-description.timeline-time

Variants (data-variant)

activePrimary dot - the current stepoutlineHollow ring - planned / upcomingdestructiveDestructive dot - a failure

Layout (on .timeline)

data-orientation="horizontal"A row of equal columns, dot on top; scrolls sideways when fulldata-align="center"Horizontal: dot and text centered in each columndata-alternateHorizontal: every second item's content above the line

§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