CSS Timelines: 16 Examples, Vertical and Horizontal

Changelogs, resumes, project histories and product roadmaps keep asking for the same thing, which is dated events in order along a line. HTML and CSS draw that without a JavaScript library, starting from an ordered list with a time element on every date.

Below are 16 CSS timeline examples you can try and copy, followed by a guide to vertical and horizontal builds.

By Bogdan Sandu · Updated October 28, 2026

Vertical timelines

The classic layout: one line running down the page with dated entries beside it. Every example is an ordered list with a time element per entry, and the line and markers are pseudo-elements, so the HTML holds nothing but content.

Alternating company history

  • container query
  • nth-child(even)
  • grid columns
  1. Two benches in a rented garage

    Ana and Tom Reyes start building steel touring frames to order, eleven in the first year.

  2. The first shop on Mill Street

    A storefront with a service bay. Repairs pay the rent while the frame waiting list grows to five months.

  3. Cargo bikes join the range

    The Haul, a long-tail cargo frame, sells out its first batch of 60 in three weeks.

  4. A workshop of our own

    Production moves to a 900 square meter workshop with a paint booth and room for 14 builders.

  5. Frame number 10,000

    A green Haul with a child seat leaves the shop, signed by every builder on the floor.

The base layout is one column with the line on the left, which is what phones get. A container query on the wrapper switches to two grid columns with the line in the middle and sends even entries to the right, so the switch follows the space the timeline actually has, not the viewport.

View code
.tl-alt {
  container-type: inline-size;
  width: min(100%, 780px);
  color: #2a2118;
}
.tl-alt__list {
  position: relative;
  display: grid;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-alt__list::before {
  content: "";
  position: absolute;
  top: 8px;
  bottom: 8px;
  left: 11px;
  width: 3px;
  border-radius: 3px;
  background: #dccbb2;
}
.tl-alt__item {
  position: relative;
  margin-left: 38px;
  padding: 16px 18px 18px;
  border: 1px solid #eadcc6;
  border-radius: 14px;
  background: #fffaf2;
}
.tl-alt__item::before {
  content: "";
  position: absolute;
  top: 20px;
  left: -34px;
  box-sizing: border-box;
  width: 18px;
  height: 18px;
  border: 4px solid #a8461f;
  border-radius: 50%;
  background: #fff;
}
.tl-alt__date {
  display: block;
  color: #a8461f;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .02em;
}
.tl-alt__title {
  margin: 4px 0 6px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 19px;
  font-weight: 600;
  line-height: 1.25;
}
.tl-alt__text {
  margin: 0;
  color: #5b4b3a;
  font-size: 14.5px;
  line-height: 1.55;
}
@container (min-width: 560px) {
  .tl-alt__list {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: 60px;
    row-gap: 18px;
    align-items: start;
  }
  .tl-alt__list::before {
    left: calc(50% - 1.5px);
  }
  .tl-alt__item {
    grid-column: 1;
    margin: 0;
    text-align: right;
  }
  .tl-alt__item:nth-child(even) {
    grid-column: 2;
    margin-top: 56px;
    text-align: left;
  }
  .tl-alt__item::before {
    left: auto;
    right: -40px;
  }
  .tl-alt__item:nth-child(even)::before {
    right: auto;
    left: -40px;
  }
}

Centered year markers

  • nested ol
  • centered line
  • pill markers

Paper Lanterns on the road

    1. First headline show 140 tickets, a pub back room in Bristol.
    1. Living room sessions Twelve streamed sets while venues stay shut.
    2. Debut album out Low Tide Radio enters the indie chart at 18.
    1. First European tour 22 dates in 9 countries, all by van.
    1. Summer festival run Six main stages and a sold-out home show.

Years sit on the line as pills and the events for each year stack under them as centered cards. The cards have an opaque background, so the single line drawn on the outer list disappears behind them and only shows in the gaps. Nested ordered lists keep the year and event order for screen readers.

View code
.tl-yr {
  width: min(100%, 460px);
  color: #20233a;
  text-align: center;
}
.tl-yr__heading {
  margin: 0 0 20px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 700;
}
.tl-yr__list,
.tl-yr__events {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-yr__list {
  position: relative;
  display: grid;
  gap: 22px;
}
.tl-yr__list::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(50% - 1px);
  width: 2px;
  background: #20233a;
}
.tl-yr__year {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 12px;
}
.tl-yr__pill {
  padding: 5px 16px;
  border: 2px solid #20233a;
  border-radius: 999px;
  background: #f2c14e;
  font-size: 15px;
  font-weight: 800;
  letter-spacing: .04em;
}
.tl-yr__pill--now {
  background: #20233a;
  color: #f2c14e;
}
.tl-yr__events {
  display: grid;
  gap: 10px;
  width: 100%;
}
.tl-yr__event {
  padding: 12px 16px;
  border: 1px solid #d9dbe8;
  border-radius: 12px;
  background: #fff;
  color: #4a4e68;
  font-size: 14.5px;
  line-height: 1.5;
}
.tl-yr__event strong {
  display: block;
  color: #20233a;
  font-size: 16px;
}

Course syllabus with a dates column

  • grid pseudo-elements
  • aria-current="step"
  • done / current / upcoming

Responsive Layout with CSS, autumn cohort

  1. Flow, boxes and units

    Completed

  2. Flexbox in real components

    Completed

  3. Grid templates and areas

    Completed

  4. Container queries

    This week: live session Thursday, project brief due Sunday

  5. Fluid type and spacing

    Upcoming

  6. Final project review

    Upcoming

Each list item is a three-column grid: date, rail, content. The ::before and ::after pseudo-elements become grid items in the middle column, one for the line segment and one for the marker, so the line always matches the height of its entry. The current week carries aria-current="step", and the CSS styles that attribute instead of a separate class.

View code
.tl-syl {
  width: min(100%, 560px);
  padding: 24px 22px 8px;
  border-radius: 18px;
  background: #fff;
  color: #10243a;
  box-shadow: 0 12px 32px rgba(16, 36, 58, .08);
}
.tl-syl__heading {
  margin: 0 0 18px;
  font-size: 18px;
  font-weight: 700;
}
.tl-syl__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-syl__item {
  display: grid;
  grid-template-columns: 4.6rem 22px minmax(0, 1fr);
  column-gap: 14px;
  padding-bottom: 18px;
}
.tl-syl__item::before {
  content: "";
  grid-column: 2;
  grid-row: 1;
  justify-self: center;
  width: 2px;
  margin: 22px 0 -18px;
  background: #cfd8e3;
}
.tl-syl__item:last-child::before {
  display: none;
}
.tl-syl__item::after {
  content: "";
  grid-column: 2;
  grid-row: 1;
  box-sizing: border-box;
  width: 22px;
  height: 22px;
  margin-top: 1px;
  border: 2px solid #9aa9ba;
  border-radius: 50%;
  background: #fff;
}
.tl-syl__item--done::before {
  background: #0f766e;
}
.tl-syl__item--done::after {
  border: 0;
  background: #0f766e;
  box-shadow: inset 0 0 0 6px #0f766e, inset 0 0 0 11px #fff;
}
.tl-syl__item[aria-current="step"]::after {
  border: 6px solid #0f766e;
  box-shadow: 0 0 0 4px #ccebe7;
}
.tl-syl__when {
  grid-column: 1;
  grid-row: 1;
  color: #4d6178;
  font-size: 13px;
  line-height: 1.35;
  text-align: right;
}
.tl-syl__when span {
  display: block;
  color: #10243a;
  font-weight: 700;
}
.tl-syl__body {
  grid-column: 3;
  grid-row: 1;
}
.tl-syl__title {
  margin: 0;
  font-size: 15.5px;
  font-weight: 700;
  line-height: 1.35;
}
.tl-syl__meta {
  margin: 3px 0 0;
  color: #4d6178;
  font-size: 13.5px;
}
.tl-syl__item[aria-current="step"] .tl-syl__body {
  margin: -6px 0 0;
  padding: 8px 12px 10px;
  border-radius: 10px;
  background: #e7f5f3;
}
.tl-syl__item[aria-current="step"] .tl-syl__meta {
  color: #0b4f4a;
  font-weight: 600;
}
.tl-syl__item:not(.tl-syl__item--done):not([aria-current]) .tl-syl__title {
  color: #4d6178;
}

Resume experience section

  • nested ol
  • monogram markers
  • date ranges

Experience

  1. Quillfern

    Product design, remote

    1. Lead Product Designer

      to present

      Leads a team of four on the invoicing app. Rebuilt the design system on tokens shared with code.

    2. Senior Product Designer

      to

      Owned onboarding. Cut the setup flow from nine screens to four.

  2. Harbourtype Studio

    Agency, Glasgow

    1. UI Designer

      to

      Marketing sites and booking flows for hotels, museums and a ferry line.

A resume is a timeline read newest first. Each employer gets a monogram tile as its marker, and a promotion inside the same company becomes a nested list with smaller dots on a thinner line, so a reader sees career growth at a glance. Both ends of every date range are time elements.

View code
.tl-cv {
  width: min(100%, 560px);
  padding: 26px 24px 10px;
  border: 1px solid #e3e6ec;
  border-radius: 16px;
  background: #fff;
  color: #1c2230;
}
.tl-cv__heading {
  margin: 0 0 18px;
  font-size: 20px;
  font-weight: 700;
}
.tl-cv__list,
.tl-cv__roles {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-cv__job {
  position: relative;
  padding: 0 0 22px 62px;
}
.tl-cv__job:not(:last-child)::before {
  content: "";
  position: absolute;
  top: 50px;
  bottom: 4px;
  left: 21px;
  width: 2px;
  background: #e3e6ec;
}
.tl-cv__logo {
  position: absolute;
  top: 0;
  left: 0;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: #1f6f5c;
  color: #fff;
  font-family: "Fraunces", Georgia, serif;
  font-size: 18px;
  font-weight: 700;
}
.tl-cv__logo--b {
  background: #3b3f9e;
}
.tl-cv__company {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
  line-height: 1.3;
}
.tl-cv__where {
  margin: 2px 0 12px;
  color: #5c6475;
  font-size: 13.5px;
}
.tl-cv__roles {
  position: relative;
}
.tl-cv__role {
  position: relative;
  padding: 0 0 14px 22px;
}
.tl-cv__role::before {
  content: "";
  position: absolute;
  top: 6px;
  left: 0;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #1c2230;
}
.tl-cv__role:not(:last-child)::after {
  content: "";
  position: absolute;
  top: 20px;
  bottom: 2px;
  left: 4px;
  width: 2px;
  background: #c4c9d4;
}
.tl-cv__title {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
}
.tl-cv__dates {
  margin: 2px 0 4px;
  color: #5c6475;
  font-size: 13px;
}
.tl-cv__text {
  margin: 0;
  color: #3d4455;
  font-size: 14px;
  line-height: 1.55;
}

Minimalist text-only changelog

  • hairline rail
  • serif type
  • grid date column

Fieldnote Serif, release notes

  1. 2.1

    • Added Vietnamese and extended Latin to all six weights.
    • Tightened kerning for f-quote and r-comma pairs.
  2. 2.0

    • New variable font with weight and optical size axes.
    • Static files renamed. Old names still ship until 3.0.
  3. 1.4

    • Old-style figures by default in the text cuts.
  4. 1.0

    • First release: four weights with italics.

No cards, no icons: a hairline, a short tick per release and good typography do all the work. The version and date share a narrow first column, and on a narrow container the grid drops to one column with the tick still aligned to the version line.

View code
.tl-log {
  container-type: inline-size;
  width: min(100%, 600px);
  color: #191919;
}
.tl-log__heading {
  margin: 0 0 22px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 400;
  font-style: italic;
}
.tl-log__list {
  margin: 0;
  padding: 0 0 0 22px;
  border-left: 1px solid #191919;
  list-style: none;
}
.tl-log__entry {
  position: relative;
  padding-bottom: 22px;
}
.tl-log__entry::before {
  content: "";
  position: absolute;
  top: .8em;
  left: -22px;
  width: 14px;
  height: 1px;
  background: #191919;
}
.tl-log__entry:last-child {
  padding-bottom: 0;
}
.tl-log__ver {
  margin: 0 0 6px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
  line-height: 1.2;
}
.tl-log__ver time {
  display: block;
  margin-top: 2px;
  color: #5d5d5d;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12.5px;
  font-weight: 400;
}
.tl-log__changes {
  margin: 0;
  padding: 0;
  list-style: none;
  color: #3a3a3a;
  font-size: 14.5px;
  line-height: 1.6;
}
.tl-log__changes li + li {
  margin-top: 4px;
}
@container (min-width: 480px) {
  .tl-log__entry {
    display: grid;
    grid-template-columns: 8.5rem minmax(0, 1fr);
    column-gap: 20px;
  }
  .tl-log__ver {
    margin: 0;
  }
  .tl-log__changes {
    padding-top: 4px;
  }
}

Feeds, itineraries and schedules

Timelines that live inside products: an activity feed, a trip plan with photos, a conference day and a journal with sticky month headers. Same list markup, very different jobs.

Activity feed with icon markers

  • mask-image icons
  • datetime with time
  • day groups

Today

  1. Maya Ortiz commented on Checkout redesign

    Can we keep the promo field collapsed by default?
  2. Leo Park uploaded 3 files to Brand refresh

Yesterday

  1. Priya Shah completed Write release notes

  2. Milestone Beta 2 reached, 41 of 41 tasks closed

Each marker is two pseudo-elements: a colored circle from ::before and an icon from ::after, painted with background-color through an SVG mask-image. Changing the icon or its color is one custom property per event type, and the event meaning stays in the visible text.

View code
.tl-feed {
  width: min(100%, 520px);
  padding: 22px 22px 6px;
  border-radius: 16px;
  background: #fff;
  color: #1e2532;
  box-shadow: 0 1px 2px rgba(20, 30, 50, .06), 0 10px 30px rgba(20, 30, 50, .08);
}
.tl-feed__day {
  margin: 0 0 12px;
  color: #5b6577;
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.tl-feed__list {
  position: relative;
  margin: 0 0 18px;
  padding: 0;
  list-style: none;
}
.tl-feed__list::before {
  content: "";
  position: absolute;
  top: 16px;
  bottom: 16px;
  left: 15px;
  width: 2px;
  background: #e6e9ef;
}
.tl-feed__item {
  --tl-feed-c: #5b6577;
  --tl-feed-i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='5'/%3E%3C/svg%3E");
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: 12px;
  min-height: 32px;
  padding: 5px 0 16px 46px;
}
.tl-feed__item::before,
.tl-feed__item::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 32px;
  height: 32px;
  border-radius: 50%;
}
.tl-feed__item::before {
  background: var(--tl-feed-c);
}
.tl-feed__item::after {
  background: #fff;
  -webkit-mask: var(--tl-feed-i) center / 16px no-repeat;
  mask: var(--tl-feed-i) center / 16px no-repeat;
}
.tl-feed__item--comment {
  --tl-feed-c: #2f5bd3;
  --tl-feed-i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 4h16v12H10l-6 5z'/%3E%3C/svg%3E");
}
.tl-feed__item--upload {
  --tl-feed-c: #8a4fd0;
  --tl-feed-i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3l7 7h-4.5v6h-5v-6H5zM5 18h14v3H5z'/%3E%3C/svg%3E");
}
.tl-feed__item--done {
  --tl-feed-c: #13795b;
  --tl-feed-i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4.5 12.5l5 5L20 7' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.tl-feed__item--flag {
  --tl-feed-c: #b4530b;
  --tl-feed-i: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 2h2.5v20H5zM8.5 3H20l-3 5 3 5H8.5z'/%3E%3C/svg%3E");
}
.tl-feed__text {
  margin: 0;
  font-size: 14.5px;
  line-height: 1.45;
}
.tl-feed__text a {
  color: #2f5bd3;
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.tl-feed__text a:focus-visible {
  outline: 2px solid #2f5bd3;
  outline-offset: 2px;
  border-radius: 2px;
}
.tl-feed__quote {
  grid-column: 1 / -1;
  grid-row: 2;
  margin: 8px 0 0;
  padding: 8px 12px;
  border-left: 3px solid #c9d5f5;
  border-radius: 0 8px 8px 0;
  background: #f4f6fb;
  color: #3e4757;
  font-size: 14px;
}
.tl-feed__time {
  grid-column: 2;
  grid-row: 1;
  color: #5b6577;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12.5px;
  line-height: 1.7;
}

Travel itinerary with photos

  • object-fit
  • container query
  • day markers
  1. Pastel canal houses with small boats moored along the water

    Canal town walking tour

    Check in by 15:00, then a two-hour guided walk and dinner by the water.

  2. A narrow road winding below steep green cliffs under low cloud

    Island loop by car

    Single-track roads, so allow the full day. Fuel up before the ferry.

  3. A deep blue fjord between granite cliffs, seen from a high rock ledge

    Cliff ledge hike

    8 km return with 350 m of climbing. Start before 08:00 to beat the crowds.

  4. Fly home

    Airport transfer at 10:30.

Day labels sit on the rail as round markers, and each stop is a card with a photo. A container query moves the photo beside the text once the card has room, and object-fit: cover with a fixed aspect-ratio keeps every photo the same shape whatever the source size. Width and height on the img reserve the space before it loads.

View code
.tl-trip {
  container-type: inline-size;
  width: min(100%, 680px);
  color: #17262b;
}
.tl-trip__list {
  position: relative;
  display: grid;
  gap: 18px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-trip__list::before {
  content: "";
  position: absolute;
  top: 20px;
  bottom: 20px;
  left: 19px;
  border-left: 2px dashed #7fa7ad;
}
.tl-trip__stop {
  position: relative;
  padding-left: 56px;
}
.tl-trip__day {
  position: absolute;
  top: 0;
  left: 0;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border: 3px solid #f2f4fa;
  border-radius: 50%;
  background: #0e5a63;
  color: #fff;
  font-size: 13px;
  font-weight: 800;
}
.tl-trip__stop--end .tl-trip__day {
  background: #17262b;
}
.tl-trip__card {
  overflow: hidden;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(14, 90, 99, .1);
}
.tl-trip__card--plain {
  padding: 14px 16px;
}
.tl-trip__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}
.tl-trip__body {
  padding: 12px 16px 16px;
}
.tl-trip__date {
  color: #0e5a63;
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .03em;
  text-transform: uppercase;
}
.tl-trip__title {
  margin: 4px 0;
  font-size: 17px;
  font-weight: 700;
}
.tl-trip__text {
  margin: 0;
  color: #44565b;
  font-size: 14px;
  line-height: 1.55;
}
@container (min-width: 520px) {
  .tl-trip__card:not(.tl-trip__card--plain) {
    display: grid;
    grid-template-columns: 220px minmax(0, 1fr);
    align-items: center;
  }
  .tl-trip__img {
    height: 100%;
    aspect-ratio: 4 / 3;
  }
}

Conference day agenda

  • aria-current="time"
  • :focus-within
  • aria-pressed

Thursday, Hall B

  1. Layout after container queries

    Inés Duarte, keynote

  2. Happening now

    Motion that respects the reader

    Kofi Mensah, talk

  3. Lunch
  4. Scroll-driven animations in production

    Mira Laine, workshop

Hover a session or press Save.

A schedule is a timeline with a clock. The session in progress carries aria-current="time", one of the tokens ARIA defines for exactly this. Cards lift on hover only where a real hover exists, thanks to @media (hover: hover), and :focus-within gives keyboard users the same cue when they reach the Save button.

View code
.tl-ag {
  width: min(100%, 560px);
  color: #1b1830;
}
.tl-ag__heading {
  margin: 0 0 16px;
  font-size: 18px;
  font-weight: 700;
}
.tl-ag__list {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-ag__list::before {
  content: "";
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 68px;
  width: 2px;
  background: #d7d3ea;
}
.tl-ag__slot {
  position: relative;
  display: grid;
  grid-template-columns: 52px minmax(0, 1fr);
  column-gap: 34px;
  padding-bottom: 12px;
}
.tl-ag__slot::before {
  content: "";
  position: absolute;
  top: 5px;
  left: 63px;
  box-sizing: border-box;
  width: 12px;
  height: 12px;
  border: 2px solid #5c4fd6;
  border-radius: 50%;
  background: #fff;
}
.tl-ag__slot--past::before {
  border-color: #8d88a8;
  background: #8d88a8;
}
.tl-ag__slot[aria-current="time"]::before {
  top: 2px;
  left: 60px;
  width: 18px;
  height: 18px;
  border: 5px solid #5c4fd6;
}
.tl-ag__time {
  color: #4b4766;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 14px;
  font-weight: 600;
  text-align: right;
}
.tl-ag__card {
  position: relative;
  padding: 12px 84px 12px 14px;
  border: 1px solid #e3e0f2;
  border-radius: 12px;
  background: #fff;
  transition: border-color .2s, box-shadow .2s;
}
.tl-ag__slot--break .tl-ag__card {
  padding-right: 14px;
  border-style: dashed;
  background: transparent;
}
.tl-ag__slot[aria-current="time"] .tl-ag__card {
  border-color: #5c4fd6;
}
.tl-ag__live {
  margin: 0 0 4px;
  color: #4a3dc4;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
}
.tl-ag__title {
  margin: 0;
  font-size: 15.5px;
  font-weight: 700;
  line-height: 1.35;
}
.tl-ag__who {
  margin: 3px 0 0;
  color: #5a5672;
  font-size: 13.5px;
}
.tl-ag__save {
  position: absolute;
  top: 50%;
  right: 12px;
  min-width: 62px;
  min-height: 34px;
  padding: 0 12px;
  border: 1.5px solid #5c4fd6;
  border-radius: 999px;
  background: #fff;
  color: #4a3dc4;
  font: inherit;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
  translate: 0 -50%;
}
.tl-ag__save[aria-pressed="true"] {
  background: #4a3dc4;
  color: #fff;
}
.tl-ag__save:focus-visible {
  outline: 3px solid #1b1830;
  outline-offset: 2px;
}
.tl-ag__card:focus-within {
  border-color: #5c4fd6;
  box-shadow: 0 8px 22px rgba(92, 79, 214, .16);
}
@media (hover: hover) {
  .tl-ag__card:hover {
    border-color: #5c4fd6;
    box-shadow: 0 8px 22px rgba(92, 79, 214, .16);
  }
}
@media (max-width: 420px) {
  .tl-ag__slot {
    grid-template-columns: 44px minmax(0, 1fr);
    column-gap: 26px;
  }
  .tl-ag__list::before {
    left: 56px;
  }
  .tl-ag__slot::before {
    left: 51px;
  }
  .tl-ag__slot[aria-current="time"]::before {
    left: 48px;
  }
  .tl-ag__time {
    font-size: 12.5px;
  }
}

Garden journal with sticky months

  • position: sticky
  • scroll container
  • tabindex="0"

  1. Sowed tomatoes and chillies indoors, heat mat at 24 °C.
  2. Pruned the roses back to outward buds.
  3. First early potatoes in, 30 cm apart.

  1. Pricked out the tomatoes into 9 cm pots.
  2. Direct-sowed carrots and beetroot under fleece.
  3. Late frost warning, everything under cover overnight.

  1. Hardening off: trays out by day, in at night.
  2. Tomatoes planted out along the south fence.
  3. First radish harvest, slightly woody.

Scroll inside the journal.

Month headings stick to the top of the scrolling panel while their entries pass underneath, then the next month pushes them out. Sticky needs an inset (top: 0 here) and sticks to the nearest scrolling ancestor, which is why the panel is the scroller and not the page. The panel takes tabindex="0" and a label so keyboard users can scroll it.

View code
.tl-gj {
  width: min(100%, 460px);
  height: 330px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border: 1px solid #d6e2cf;
  border-radius: 16px;
  background: #fbfdf8;
  color: #1f2d1b;
}
.tl-gj:focus-visible {
  outline: 3px solid #3d6b2f;
  outline-offset: 3px;
}
.tl-gj__head {
  position: sticky;
  top: 0;
  z-index: 1;
  margin: 0;
  padding: 10px 18px;
  border-bottom: 1px solid #d6e2cf;
  background: #e8f1e1;
  color: #2c4f22;
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
  font-weight: 600;
}
.tl-gj__list {
  position: relative;
  margin: 0;
  padding: 14px 18px 6px 44px;
  list-style: none;
}
.tl-gj__list::before {
  content: "";
  position: absolute;
  top: 14px;
  bottom: 14px;
  left: 25px;
  width: 2px;
  background: #b9cfae;
}
.tl-gj__entry {
  position: relative;
  padding-bottom: 14px;
  color: #3b4a37;
  font-size: 14.5px;
  line-height: 1.5;
}
.tl-gj__entry::before {
  content: "";
  position: absolute;
  top: 6px;
  left: -24px;
  width: 10px;
  height: 10px;
  border-radius: 50% 0;
  background: #3d6b2f;
  rotate: -45deg;
}
.tl-gj__entry time {
  display: block;
  color: #2c4f22;
  font-size: 12.5px;
  font-weight: 700;
}

Horizontal timelines and steppers

When the line runs sideways, the questions change: how the list scrolls, how events sit along a scale, and what happens on a narrow screen. Each root is a size container, so the layout reacts to its own width and never stretches the page.

Horizontal mission timeline with scroll-snap

  • scroll-snap-type
  • container-type
  • cqi peek

Kestrel-2 lunar lander

  1. Launch

    Liftoff on the third attempt after two weather holds.

  2. Orbit raising

    Four engine burns lift the apoapsis to 380,000 km.

  3. Lunar transfer

    The final burn sends Kestrel-2 toward the Moon.

  4. Lunar orbit insertion

    In progress: a 14-minute braking burn on the far side.

  5. Descent and landing

    Target: a flat crater floor near the south pole.

  6. Surface operations

    Ten Earth days of drilling before lunar night.

Scroll or swipe sideways, or focus the strip and use the arrow keys.

The root is an inline-size container, so the long max-content list cannot widen the stage. Cards are sized in cqi to about 80 percent of the visible strip, which leaves the next card peeking in. scroll-snap-type: x proximity settles cards at the start edge without trapping the reader the way mandatory can.

View code
.tl-hz {
  container-type: inline-size;
  width: 100%;
  max-width: 820px;
  color: #13151c;
}
.tl-hz__heading {
  margin: 0 0 12px;
  font-size: 18px;
  font-weight: 700;
}
.tl-hz__scroller {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scroll-padding-inline: 4px;
  padding-bottom: 10px;
  border-radius: 12px;
}
.tl-hz__scroller:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}
.tl-hz__list {
  position: relative;
  display: flex;
  gap: 14px;
  width: max-content;
  margin: 0;
  padding: 0 4px;
  list-style: none;
}
.tl-hz__list::before {
  content: "";
  position: absolute;
  top: 9px;
  left: 0;
  right: 0;
  height: 2px;
  background: #d4d7e0;
}
.tl-hz__item {
  position: relative;
  flex: none;
  box-sizing: border-box;
  width: min(250px, 78cqi);
  padding-top: 34px;
  scroll-snap-align: start;
}
.tl-hz__item::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  box-sizing: border-box;
  width: 20px;
  height: 20px;
  border: 3px solid #8b90a0;
  border-radius: 50%;
  background: #fff;
}
.tl-hz__item--done::before {
  border-color: #13151c;
  background: #13151c;
}
.tl-hz__item--done::after {
  content: "";
  position: absolute;
  top: 9px;
  left: 20px;
  right: -14px;
  height: 2px;
  background: #13151c;
}
.tl-hz__item[aria-current="step"]::before {
  border: 6px solid #c2410c;
  box-shadow: 0 0 0 5px #fbe0d2;
}
.tl-hz__item time {
  color: #5d6271;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12.5px;
}
.tl-hz__item h5 {
  margin: 4px 0;
  font-size: 16px;
  font-weight: 700;
}
.tl-hz__item p {
  margin: 0;
  padding: 10px 12px;
  border-radius: 10px;
  background: #fff;
  color: #3d4250;
  font-size: 14px;
  line-height: 1.5;
}
.tl-hz__item[aria-current="step"] h5 {
  color: #b23a0a;
}

Football match events on a 90-minute scale

  • calc() positioning
  • custom properties
  • container query

Harbor City 2 : 1 Northvale

Full time,

  1. 23' Goal, R. Okafor HCY
  2. 41' Yellow card, D. Brandt NVL
  3. 52' Substitution, Mensah on HCY
  4. 63' Goal, S. Lind NVL
  5. 81' Goal, T. Mensah HCY
  6. 90+2' Yellow card, J. Kral NVL

Each event carries its minute as a --tl-m custom property, and calc() turns that into a left offset along the 90-minute bar, with home events above and away events below. The card shifts by the same fraction of its own width, so cards near kickoff or full time never overflow. Below 700px of container width, the scale gives way to a plain vertical list.

View code
.tl-mx {
  container-type: inline-size;
  width: min(100%, 780px);
  padding: 20px 20px 22px;
  border-radius: 16px;
  background: #fff;
  color: #10202b;
}
.tl-mx__score {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: baseline;
  gap: 4px 14px;
  margin: 0;
  font-size: 17px;
  font-weight: 700;
}
.tl-mx__score strong {
  font-size: 28px;
  font-variant-numeric: tabular-nums;
}
.tl-mx__home {
  color: #b42318;
}
.tl-mx__away {
  color: #1e4fa3;
}
.tl-mx__meta {
  margin: 2px 0 18px;
  color: #556370;
  font-size: 13px;
  text-align: center;
}
.tl-mx__list {
  position: relative;
  margin: 0;
  padding: 0 0 0 30px;
  list-style: none;
}
.tl-mx__list::before {
  content: "";
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 9px;
  width: 2px;
  background: #d3dae0;
}
.tl-mx__ev {
  --tl-mx-c: #b42318;
  position: relative;
  padding: 0 0 12px;
  font-size: 14px;
  line-height: 1.4;
}
.tl-mx__ev--away {
  --tl-mx-c: #1e4fa3;
}
.tl-mx__ev::before {
  content: "";
  position: absolute;
  top: 4px;
  left: -26px;
  width: 12px;
  height: 12px;
  border: 2px solid #fff;
  border-radius: 50%;
  background: var(--tl-mx-c);
}
.tl-mx__min {
  display: inline-block;
  min-width: 3.2em;
  color: var(--tl-mx-c);
  font-family: ui-monospace, Consolas, monospace;
  font-weight: 700;
}
.tl-mx__team {
  margin-left: 4px;
  padding: 1px 6px;
  border-radius: 4px;
  background: var(--tl-mx-c);
  color: #fff;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .04em;
}
@container (min-width: 700px) {
  .tl-mx__list {
    height: 170px;
    margin: 0 8px;
    padding: 0;
  }
  .tl-mx__list::before {
    top: 84px;
    bottom: auto;
    left: 0;
    right: 0;
    width: auto;
    height: 3px;
    border-radius: 3px;
    background: linear-gradient(90deg, #d3dae0 calc(50% - 1px), #10202b calc(50% - 1px) calc(50% + 1px), #d3dae0 calc(50% + 1px));
  }
  .tl-mx__ev {
    position: absolute;
    left: calc(var(--tl-m) / 90 * 100%);
    bottom: 96px;
    width: 116px;
    padding: 7px 9px;
    border-top: 3px solid var(--tl-mx-c);
    border-radius: 6px;
    background: #f3f5f7;
    font-size: 12.5px;
    translate: calc(var(--tl-m) / 90 * -100%) 0;
  }
  .tl-mx__ev--away {
    top: 96px;
    bottom: auto;
    border-top: 0;
    border-bottom: 3px solid var(--tl-mx-c);
  }
  .tl-mx__ev::before {
    top: auto;
    bottom: -19px;
    left: calc(var(--tl-m) / 90 * 116px - 8px);
  }
  .tl-mx__ev--away::before {
    top: -19px;
    bottom: auto;
  }
  .tl-mx__min {
    display: block;
    min-width: 0;
  }
  .tl-mx__team {
    display: none;
  }
}

Dark product roadmap by quarter

  • grid columns
  • container query
  • status chips

Orbitdesk roadmap

    • Shipped Shared inboxes
    • Shipped SLA timers
    • Shipped Saved replies
    • In progress Mobile app beta
    • Planned Public API v2
    • Planned SSO for all plans
    • Exploring Voice channel

Quarters are list items laid out as four grid columns, with a rail segment on each one so shipped quarters can color their part of the line. The current quarter carries aria-current="step". Under 600px of container width, the same list becomes a vertical timeline with the rail on the left, and every status is written as a word, not only shown as a color.

View code
.tl-rm {
  container-type: inline-size;
  width: min(100%, 820px);
  color: #e9edf5;
}
.tl-rm__heading {
  margin: 0 0 18px;
  font-size: 19px;
  font-weight: 700;
}
.tl-rm__list,
.tl-rm__items {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tl-rm__list {
  display: grid;
  gap: 0;
}
.tl-rm__q {
  position: relative;
  padding: 0 0 22px 34px;
}
.tl-rm__q::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 8px;
  width: 3px;
  background: #2b3448;
}
.tl-rm__q--done::before {
  background: #b9e769;
}
.tl-rm__q--now::before {
  background: linear-gradient(#b9e769 0 40%, #2b3448 40%);
}
.tl-rm__q::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 0;
  box-sizing: border-box;
  width: 19px;
  height: 19px;
  border: 3px solid #5b6680;
  border-radius: 50%;
  background: #0b0f1a;
}
.tl-rm__q--done::after {
  border-color: #b9e769;
  background: #b9e769;
}
.tl-rm__q--now::after {
  border-color: #f5b83d;
  box-shadow: 0 0 0 4px rgba(245, 184, 61, .25);
}
.tl-rm__label {
  margin: 0 0 10px;
  color: #a9b3c7;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .04em;
}
.tl-rm__q--now .tl-rm__label {
  color: #f5b83d;
}
.tl-rm__items {
  display: grid;
  gap: 8px;
}
.tl-rm__items li {
  padding: 10px 12px;
  border: 1px solid #252e42;
  border-radius: 10px;
  background: #141b2b;
  font-size: 14.5px;
  font-weight: 600;
}
.tl-rm__chip {
  display: table;
  margin-bottom: 6px;
  padding: 2px 8px;
  border-radius: 999px;
  background: #b9e769;
  color: #13200a;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.tl-rm__chip--wip {
  background: #f5b83d;
  color: #2a1a00;
}
.tl-rm__chip--plan {
  background: #2b3448;
  color: #d3dae8;
}
@container (min-width: 600px) {
  .tl-rm__list {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    column-gap: 12px;
  }
  .tl-rm__q {
    padding: 34px 0 0;
  }
  .tl-rm__q::before {
    top: 9px;
    bottom: auto;
    left: 0;
    right: -12px;
    width: auto;
    height: 3px;
  }
  .tl-rm__q:last-child::before {
    right: 0;
  }
  .tl-rm__q--now::before {
    background: linear-gradient(90deg, #b9e769 0 40%, #2b3448 40%);
  }
}

Order tracking stepper on a phone

  • aria-current="step"
  • border-drawn check
  • scrolling view

Order #48213

Arriving

Linen throw blanket, sage1 item, tracked delivery

  1. Order placed

  2. Packed

  3. Shipped

  4. In transit Now

  5. Out for delivery

    Expected Oct 29

  6. Delivered

The tracking steps are an ordered list where completed steps get a check drawn with two borders, the current step carries aria-current="step" and a visible "Now" label, and upcoming steps stay hollow. The line between markers is one ::after segment per step, colored by the state of the step it leaves.

View code
.tl-ot {
  height: 100%;
  overflow-y: auto;
  box-sizing: border-box;
  padding: 6px 20px 20px;
  background: #fff;
  color: #1a1f24;
}
.tl-ot__bar {
  padding: 8px 0 14px;
  border-bottom: 1px solid #eceef0;
}
.tl-ot__order {
  margin: 0;
  color: #5f6870;
  font-size: 13px;
  font-weight: 600;
}
.tl-ot__eta {
  margin: 2px 0 0;
  font-size: 20px;
  font-weight: 800;
  line-height: 1.25;
}
.tl-ot__eta time {
  display: block;
  color: #126b47;
}
.tl-ot__item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 0;
}
.tl-ot__thumb {
  flex: none;
  width: 48px;
  height: 48px;
  border-radius: 10px;
  background: #a7b89a;
  box-shadow: inset 0 -14px 0 #8ea381;
}
.tl-ot__name {
  margin: 0;
  font-size: 14.5px;
  font-weight: 700;
}
.tl-ot__name span {
  display: block;
  color: #5f6870;
  font-size: 13px;
  font-weight: 400;
}
.tl-ot__steps {
  margin: 4px 0 14px;
  padding: 0;
  list-style: none;
}
.tl-ot__step {
  position: relative;
  min-height: 40px;
  padding: 0 0 14px 38px;
}
.tl-ot__step::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  box-sizing: border-box;
  width: 22px;
  height: 22px;
  border: 2px solid #b9c0c6;
  border-radius: 50%;
  background: #fff;
}
.tl-ot__step:not(:last-child)::after {
  content: "";
  position: absolute;
  top: 22px;
  bottom: 0;
  left: 10px;
  width: 2px;
  background: #dfe3e6;
}
.tl-ot__step--done::before {
  border-color: #126b47;
  background: #126b47;
}
.tl-ot__step--done::after {
  background: #126b47;
}
.tl-ot__step--done .tl-ot__title::before {
  content: "";
  position: absolute;
  top: 5px;
  left: 8px;
  z-index: 2;
  width: 5px;
  height: 9px;
  border: solid #fff;
  border-width: 0 2.5px 2.5px 0;
  rotate: 45deg;
}
.tl-ot__step[aria-current="step"]::before {
  border: 6px solid #126b47;
  box-shadow: 0 0 0 5px #d6efe3;
}
.tl-ot__title {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
  line-height: 1.4;
}
.tl-ot__step:not(.tl-ot__step--done):not([aria-current]) .tl-ot__title {
  color: #5f6870;
  font-weight: 600;
}
.tl-ot__now {
  margin-left: 6px;
  padding: 1px 7px;
  border-radius: 999px;
  background: #126b47;
  color: #fff;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  vertical-align: 2px;
}
.tl-ot__time {
  display: block;
  margin: 2px 0 0;
  color: #5f6870;
  font-size: 13px;
}
.tl-ot__help {
  width: 100%;
  min-height: 46px;
  border: 1.5px solid #1a1f24;
  border-radius: 12px;
  background: #fff;
  color: #1a1f24;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.tl-ot__help:focus-visible {
  outline: 3px solid #126b47;
  outline-offset: 2px;
}

Interactive and scroll-driven timelines

Three ways to let readers do something with the timeline: watch the line fill as they scroll, expand only the entries they care about, or filter events by type with radio buttons and :has().

Line that fills as you scroll

  • animation-timeline: scroll()
  • view()
  • @supports

Kettle Point Lighthouse restoration

  1. Structural survey

    Engineers find salt damage in the upper gallery and a cracked lantern frame.

  2. Funding secured

    A heritage grant and 2,300 local donors cover the full repair budget.

  3. Lantern lifted off

    A crane removes the iron lantern room in one piece for workshop repairs.

  4. Masonry repointed

    Lime mortar replaces the cement used in a 1970s repair.

  5. Lens returns

    The restored lens goes back up, cleaned and reset by hand.

  6. Reopening to visitors

    Guided climbs to the gallery start with the spring season.

Scroll inside the panel.

The grey track is static, and a second ::after line scales from 0 to 1 on a scroll() timeline tied to the panel, so the fill tracks reading progress. Each marker switches color on its own view() timeline as it enters. Everything animated sits inside @supports and a no-preference motion query; without them the line simply shows fully drawn.

View code
.tl-sf {
  width: min(100%, 480px);
  height: 360px;
  overflow-y: auto;
  overscroll-behavior: contain;
  box-sizing: border-box;
  padding: 22px 22px 70px;
  border-radius: 18px;
  background: #0f2a3d;
  color: #eef5f9;
}
.tl-sf:focus-visible {
  outline: 3px solid #ffd166;
  outline-offset: 3px;
}
.tl-sf__heading {
  margin: 0 0 20px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
}
.tl-sf__list {
  position: relative;
  margin: 0;
  padding: 0 0 0 36px;
  list-style: none;
}
.tl-sf__list::before,
.tl-sf__list::after {
  content: "";
  position: absolute;
  top: 6px;
  bottom: 6px;
  left: 8px;
  width: 4px;
  border-radius: 4px;
  background: #ffd166;
}
.tl-sf__list::after {
  display: none;
}
.tl-sf__item {
  position: relative;
  padding-bottom: 22px;
}
.tl-sf__item::before {
  content: "";
  position: absolute;
  top: 3px;
  left: -36px;
  z-index: 1;
  box-sizing: border-box;
  width: 20px;
  height: 20px;
  border: 4px solid #ffd166;
  border-radius: 50%;
  background: #0f2a3d;
}
.tl-sf__item time {
  color: #ffd166;
  font-size: 13px;
  font-weight: 700;
}
.tl-sf__item h5 {
  margin: 2px 0 4px;
  font-size: 16px;
  font-weight: 700;
}
.tl-sf__item p {
  margin: 0;
  color: #c3d5e0;
  font-size: 14px;
  line-height: 1.55;
}
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .tl-sf__list::before {
      background: #2c4a5e;
    }
    .tl-sf__list::after {
      display: block;
      transform-origin: top;
      animation: tl-sf-fill linear both;
      animation-timeline: scroll(nearest block);
    }
    .tl-sf__item::before {
      animation: tl-sf-dot linear both;
      animation-timeline: view(block);
      animation-range: entry 100% cover 60%;
    }
  }
}
@keyframes tl-sf-fill {
  from {
    transform: scaleY(0);
  }
  to {
    transform: scaleY(1);
  }
}
@keyframes tl-sf-dot {
  from {
    border-color: #4d6b80;
    background: #0f2a3d;
  }
  to {
    border-color: #ffd166;
    background: #ffd166;
  }
}

Collapsible release notes

  • details / summary
  • ::details-content
  • interpolate-size

Ledgerly release notes

  1. 4.3.0 Latest

    Added Recurring invoices with custom schedules.

    Fixed PDF exports cut off long client addresses.

  2. 4.2.1

    Fixed Currency symbols in CSV exports.

    Fixed Duplicate reminder emails after a partial payment.

  3. 4.2.0

    Added Bank feed matching suggestions.

    Changed The dashboard now opens on unpaid invoices.

  4. 4.1.0

    Added Dark theme and a compact table view.

Open an older release.

Every release is a details element, so the full notes stay one click away without a script and the browser handles keyboard and screen reader state. Where interpolate-size and ::details-content are supported, the panel animates to its auto height; elsewhere it opens instantly. The marker on the rail turns solid when its entry is open, through :has([open]).

View code
.tl-rn {
  width: min(100%, 540px);
  min-height: 380px;
  color: #1d1b26;
}
.tl-rn__heading {
  margin: 0 0 16px;
  font-size: 19px;
  font-weight: 700;
}
.tl-rn__list {
  position: relative;
  margin: 0;
  padding: 0 0 0 34px;
  list-style: none;
}
.tl-rn__list::before {
  content: "";
  position: absolute;
  top: 18px;
  bottom: 18px;
  left: 9px;
  width: 2px;
  background: #d9d4e8;
}
.tl-rn__item {
  position: relative;
  margin-bottom: 10px;
}
.tl-rn__item::before {
  content: "";
  position: absolute;
  top: 15px;
  left: -32px;
  box-sizing: border-box;
  width: 16px;
  height: 16px;
  border: 3px solid #6d4bd8;
  border-radius: 50%;
  background: #fff;
}
.tl-rn__item:has([open])::before {
  background: #6d4bd8;
}
.tl-rn__entry {
  border: 1px solid #e3dff0;
  border-radius: 12px;
  background: #fff;
}
.tl-rn__sum {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 12px;
  min-height: 46px;
  box-sizing: border-box;
  padding: 10px 44px 10px 14px;
  position: relative;
  border-radius: 12px;
  list-style: none;
  cursor: pointer;
}
.tl-rn__sum::-webkit-details-marker {
  display: none;
}
.tl-rn__sum::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 18px;
  width: 8px;
  height: 8px;
  margin-top: -6px;
  border: solid #4b3a8f;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
.tl-rn__entry[open] .tl-rn__sum::after {
  margin-top: -2px;
  rotate: 225deg;
}
.tl-rn__sum:focus-visible {
  outline: 3px solid #6d4bd8;
  outline-offset: 2px;
}
.tl-rn__ver {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 15px;
  font-weight: 700;
}
.tl-rn__sum time {
  color: #5d5873;
  font-size: 13.5px;
}
.tl-rn__tag {
  padding: 1px 8px;
  border-radius: 999px;
  background: #ece6ff;
  color: #4b2fb0;
  font-size: 11.5px;
  font-weight: 800;
}
.tl-rn__body {
  padding: 0 14px 12px;
  color: #3e3a4f;
  font-size: 14px;
  line-height: 1.55;
}
.tl-rn__body p {
  margin: 0 0 4px;
}
.tl-rn__body strong {
  display: inline-block;
  min-width: 4.6em;
  color: #1d1b26;
}
@media (prefers-reduced-motion: no-preference) {
  .tl-rn__sum::after {
    transition: rotate .25s ease, margin-top .25s ease;
  }
  @supports (interpolate-size: allow-keywords) {
    .tl-rn {
      interpolate-size: allow-keywords;
    }
    .tl-rn__entry::details-content {
      height: 0;
      overflow: clip;
      transition: height .3s ease, content-visibility .3s allow-discrete;
    }
    .tl-rn__entry[open]::details-content {
      height: auto;
    }
  }
}

Filterable timeline with :has()

  • :has()
  • radio filters
  • CSS counters
Show
  1. Design Design system 2.0 tokens published
  2. Engineering API rate limits moved to the edge
  3. Launch Public beta opens to the waitlist
  4. Design Dark theme across every screen
  5. Engineering Search rebuilt, results three times faster
  6. Launch Version 1.0 and paid plans

Pick a filter.

The filters are real radio buttons in a fieldset, so they work with arrow keys and announce their state. A :has() rule on the wrapper hides every entry whose data-type does not match the checked radio, and a CSS counter after the list counts only the entries still displayed. Without :has() support, all entries simply stay visible.

View code
.tl-flt {
  width: min(100%, 560px);
  min-height: 420px;
  color: #102a26;
}
.tl-flt__filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 18px;
  padding: 0;
  border: 0;
}
.tl-flt__legend {
  float: left;
  margin-right: 4px;
  padding: 8px 0;
  color: #3f5652;
  font-size: 14px;
  font-weight: 700;
}
.tl-flt__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
}
.tl-flt__chip {
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  padding: 0 14px;
  border: 1.5px solid #b9cfca;
  border-radius: 999px;
  background: #fff;
  color: #1d4a43;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.tl-flt__radio:checked + .tl-flt__chip {
  border-color: #0f5c52;
  background: #0f5c52;
  color: #fff;
}
.tl-flt__radio:focus-visible + .tl-flt__chip {
  outline: 3px solid #0f5c52;
  outline-offset: 2px;
}
.tl-flt__list {
  position: relative;
  margin: 0;
  padding: 0 0 0 30px;
  list-style: none;
  counter-reset: tl-flt-n;
}
.tl-flt__list::before {
  content: "";
  position: absolute;
  top: 6px;
  bottom: 30px;
  left: 7px;
  width: 2px;
  background: #cfe0dc;
}
.tl-flt__list::after {
  content: counter(tl-flt-n) " shown";
  display: block;
  margin-top: 6px;
  color: #3f5652;
  font-size: 13px;
}
.tl-flt__item {
  --tl-flt-c: #7a4cc2;
  position: relative;
  padding: 0 0 14px;
  counter-increment: tl-flt-n;
  font-size: 14.5px;
  line-height: 1.45;
}
.tl-flt__item[data-type="eng"] {
  --tl-flt-c: #1d6fb8;
}
.tl-flt__item[data-type="launch"] {
  --tl-flt-c: #c2410c;
}
.tl-flt__item::before {
  content: "";
  position: absolute;
  top: 3px;
  left: -29px;
  width: 14px;
  height: 14px;
  border: 3px solid #f2f4fa;
  border-radius: 50%;
  background: var(--tl-flt-c);
}
.tl-flt__item time {
  display: inline-block;
  min-width: 3.6em;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  font-weight: 700;
}
.tl-flt__type {
  margin-right: 4px;
  padding: 1px 7px;
  border-radius: 5px;
  background: var(--tl-flt-c);
  color: #fff;
  font-size: 11.5px;
  font-weight: 700;
}
.tl-flt:has(#tl-flt-design:checked) .tl-flt__item:not([data-type="design"]),
.tl-flt:has(#tl-flt-eng:checked) .tl-flt__item:not([data-type="eng"]),
.tl-flt:has(#tl-flt-launch:checked) .tl-flt__item:not([data-type="launch"]) {
  display: none;
}

What Is a CSS Timeline?

The term gets used for two unrelated things, and that causes some confusion.

Here it means the component described above, a dated list built with HTML and CSS instead of a timeline library. The other meaning is a CSS property. In the CSS Scroll-driven Animations Module from the W3C CSS Working Group, animation-timeline ties an animation to scroll position instead of elapsed time.

That property is a different subject. It matters here only as an optional layer for animating the finished component.

The component also isn’t a log of changes made to a stylesheet (that is a version-control job), and it isn’t a data widget with zoom, drag and thousands of events.

Pure CSS covers the layout, the line, the markers and hover transitions. Entrance effects come from keyframe animations, with no script attached.

Anything that touches data, such as loading events, filtering or zooming, still needs JavaScript.

What Markup Should a CSS Timeline Use?

Go with an ordered list (ol) with one li per event, and put a time element with a datetime attribute inside each item.

The sequence is the whole point of a timeline, and an ol tells assistive technology that the order carries meaning. A ul works only when the events have no real order.

The date goes in the time element, with datetime in ISO 8601 form such as 2026-05-22, so software reads it as a date and not as loose text. The time element has been Baseline Widely Available since April 2020 (WebDX web-features), so that structure works across current browsers. Under it sits the title, a heading level that fits the page outline (h3 in most articles), followed by a paragraph or two of description inside the same li.

Draw the line and the markers with pseudo-elements instead of extra elements. The HTML then holds only dates, titles and text.

MDN advises against putting real content in ::before, because screen readers do not reliably read it. Dates and titles stay in real HTML.

Customer.io’s Pluma design system follows the ordered-list pattern. Its Timeline component renders an ol by default, and it treats the marker as decorative, with an optional markerLabel for cases where the visible title needs extra context.

Skip most extra attributes. The first rule of ARIA is to leave it out when a native element already supplies the semantics, and an ol already exposes the list role.

Extra ARIA doesn’t always help, at least by raw error counts. WebAIM’s 2026 Million report found that home pages with ARIA averaged 59.1 detected errors, against 42 on pages without it, though those pages were also more complex.

One attribute does earn its place. An aria-label on the ol gives the list a name such as Release history.

Safari is the exception to the “native is enough” rule. A WebKit Bugzilla report (bug 304137) documents iOS VoiceOver reading a list with list-style: none as plain text, with no list announcement and no item count. The same report says macOS Safari still announces it, and WebKit later closed it as a duplicate of the long-standing bug 122320.

Adding role=”list” to the ol restores the semantics on iOS. Validators flag the role as redundant, but keeping it is a common workaround.

Flexbox, Grid or Absolute Positioning for a Timeline Layout

Grid handles the two-sided version best. Flexbox is enough for single-sided and horizontal timelines, and absolute positioning belongs only to the line and the markers.

Each method does a different job in the component, and most good timelines combine two of them.

MethodBest forStrengthFailure mode
FlexboxSingle-sided and horizontal rowsAligns marker and card on one axisAlternating sides needs per-item overrides
GridTwo-sided, centered lineDate and card share tracksNeeds explicit column placement per item
Absolute positioningLine and markersPins a marker to an exact offsetFixed offsets break when card heights vary

Browser support doesn’t decide this one. Can I use puts global usage (full and partial support) at 97.46% for CSS Grid and 97.49% for flexbox (StatCounter data, September 2026).

With a grid layout, the center line sits in its own column and the cards fall into the columns on either side. Flexbox gets there with row-reverse on even items or auto margins, which means more per-item code.

Absolute positioning for the whole layout is the weakest option. Every card needs a hard-coded offset, and one longer description pushes the markers out of line.

If the line runs down the middle, use grid. If it hugs the left edge, flexbox is enough.

How to Build a Vertical CSS Timeline

Start with the list and work outward to the card.

  1. Set up the list: an ol with list-style none, position relative, and role=”list” for Safari.
  2. Give every li position relative and enough left padding for the line and the marker.
  3. Draw the line with ::before on the ol: absolute, top 0, bottom 0, a fixed width and a color from a variable.
  4. Draw the marker with ::before on each li: absolute, round with border-radius 50%, centered over the line.
  5. Style the card: padding, background, border or shadow, with the time element above the heading.

Draw the line and markers with pseudo-elements

Both parts are empty boxes. They need size, position and color, nothing else.

Every pseudo-element also needs a content property, even content: “”. MDN states that a ::before with no valid content value is not rendered.

  • A border-left works for a plain line, while a linear-gradient lets it fade at the ends or change color per phase.
  • A line drawn with ::after on the list paints after every item and covers the markers. Use ::before, or raise the marker z-index.
  • Overflow hidden on the li or ol cuts off any marker that sits outside the box.
  • A single line spans the whole list, so it runs past the last item. Draw one segment per li instead and remove it on the last child.

The gradient line is the fiddly one, and a CSS gradient generator shortens the trial and error. A plain border needs no tooling.

Alternate items left and right

Alternating is a placement problem more than a styling one. Move the line to the center of the list, then send odd items one way and even items the other.

With grid, use three columns (card, line, card) and put odd items in column 1 and even items in column 3. With flexbox, row-reverse on even items does the job. Either way the selectors are nth-child(odd) and nth-child(even).

Mirror the connector between card and marker on even items, or it points at the wrong side.

Theme it with custom properties

Most theming comes down to a few variables on the list. –timeline-line-color sets the line color and can be overridden per phase or event type. –timeline-line-width is the thickness, –timeline-marker-size is the dot diameter, and –timeline-gap controls the vertical space between items.

Derive the marker offset from the other two with calc(), as (line width minus marker size) divided by 2, so changing one variable never misaligns the marker.

Color-coded event types need only a modifier class that resets the color variable. Card shadows can be a variable too, and a CSS box shadow generator shortens the tuning.

How to Build a Horizontal CSS Timeline

Turn the ordered list into a flex row, wrap it in a container with overflow-x set to auto, and add scroll-snap so each item settles at the start edge.

The markup and pseudo-element rules from the vertical build still apply. What changes is the axis, and how the line is drawn.

  • The wrapper carries overflow-x auto, so the list scrolls and the page does not.
  • Items get flex: none with a fixed width, because shrinking cards wreck the spacing.
  • The line is ::before on the list with width: max-content, so it spans every item and not just the visible edge.
  • For snapping, put scroll-snap-type: x proximity on the wrapper and scroll-snap-align: start on each li.

Proximity is the safer default. Mandatory forces a snap position whenever scrolling stops (MDN), which gets in the way when a card is wider than the visible area.

Keyboard access needs one attribute. Before Chrome 132, a scroller with no focusable children could not take keyboard focus, so the wrapper needs tabindex=”0″ and an aria-label that names it.

The real limit is discoverability. Items past the right edge are invisible, so let the next card peek in at the edge to signal that the line continues.

How to Make a CSS Timeline Responsive

Collapse the layout to a single column below a breakpoint. The line moves to the left edge, every card sits on the same side, and the alternating offsets reset. A width-based media query applies the switch.

Cards go full width with padding that clears the line, and even items get the same placement as odd ones.

Write that single column as the base and add the two-sided layout at a min-width breakpoint. That is the mobile-first order, and phones get the simplest rules with nothing to undo.

Horizontal timelines need the opposite treatment: vertical below the breakpoint, horizontal above it. FlyonUI’s Tailwind-based timeline docs show it on one list that carries timeline-vertical, overflow-x-auto and lg:timeline-horizontal.

Sticky dates are the quiet breakage. position: sticky needs at least one inset value such as top, and it sticks to the nearest ancestor with a scrolling mechanism, even one that never scrolls (MDN). An overflow: hidden wrapper silently kills it.

Overflowing cards are the other usual suspect. A 1fr grid track stretches to fit long unbroken text, so use minmax(0, 1fr) to let the cards shrink.

How to Animate a CSS Timeline on Scroll Without JavaScript

Set animation-timeline to scroll() for a progress line that fills as the reader moves down the list, or to view() for item reveals as each card enters view. Leave animation-duration at auto, because scroll position drives progress, not the clock.

Chrome for Developers explains why this replaces scroll listeners. Scroll events arrive asynchronously and main-thread animations are prone to jank, while scroll-driven animations can run off the main thread.

Entrance reveals are the most common of the CSS animations on scroll, and a view timeline is built for them.

Scroll timelines and view timelines

Scroll timelines track how far a scroll container has scrolled, which is what you want on the line so it fills as the reader goes down the list. View timelines work per element instead, following its position inside the scroller. Put view() on each item and it fades in on arrival.

The animation shorthand resets animation-timeline, so declare animation-timeline after the shorthand, or the shorthand wipes it out. Add animation-range, such as entry 25% cover 50%, to finish a reveal early in the item’s pass.

Absolute positioning causes the one lookup trap. When the line’s positioned ancestor sits outside the scroller, scroll(nearest) skips the scroller, so a horizontal timeline needs a named timeline instead: scroll-timeline: –tl inline on the wrapper, animation-timeline: –tl on the line.

Browser support right now

Chrome and Edge have supported it since version 115 (Chrome for Developers, Can I use). Safari joined at version 26.0 (Can I use). Firefox 159, released on October 27, 2026, enables scroll-driven animations by default (MDN’s Firefox 159 release notes).

Older write-ups disagree on the details, and some place Safari support at version 18.2. Can I use and MDN’s compatibility data list Safari 18.7 as unsupported and 26.0 as the first supported version.

Older Firefox and Safari versions will stay in traffic for a while, so build the timeline so it reads correctly without the animation.

Fallbacks and reduced motion

Wrap every animation rule in @supports (animation-timeline: view()), and put the hidden start state inside the keyframes, never on the base rule. A base opacity of 0 leaves every card invisible in a browser that ignores the animation.

A short IntersectionObserver script that adds a class when an item enters view works as a fallback, and GSAP ScrollTrigger gives scrubbed effects with more control at the cost of a library. Static content is also a perfectly valid fallback for a timeline.

Chrome for Developers compares a view timeline to what IntersectionObserver tracks, which is why the script fallback maps one to one.

The W3C Understanding document for WCAG 2.2 SC 2.3.3, Animation from Interactions (Level AAA, updated September 2025), says motion animation triggered by interaction can be disabled unless it is essential to the functionality or the information being conveyed. It lists the prefers-reduced-motion query as a sufficient technique (C39).

Changes of color or opacity that leave size, shape and position alone fall outside its definition of motion animation, so fade-only reveals are the low-risk choice. Slides and scales belong inside an @media (prefers-reduced-motion: no-preference) block.

W3C names parallax scrolling as a common example of non-essential motion, and a timeline whose cards slide sideways on scroll sits in the same category.

When a CSS Timeline Is the Wrong Choice

Skip it when the sequence is data, when readers need to zoom or drag, or when the list grows past what anyone will scroll.

NeedBetter toolReason
Values plotted over timeChart library (Recharts, visx)The sequence is data, not a story
Zoom, drag or range selectionJavaScript timeline library (vis-timeline)Needs interaction state and script
Large event sets with filtersPaginated or filtered listA long scroll hides what matters
Reveals that must run in every engineIntersectionObserver or GSAP ScrollTriggerOlder browser versions lack animation-timeline

The pattern fits a short, fixed story, like a company history, a product roadmap or a release log.

Events live in the markup, so every edit goes through the template or the CMS. Reach for a library only when one of the needs above is real, because for a roadmap page the list and a few rules cost less to maintain than a dependency.

CSS Timeline FAQ

Can I build a CSS timeline with Tailwind or Bootstrap?

Yes, with different amounts of help. Tailwind-based kits such as FlyonUI ship ready classes (timeline-start, timeline-middle, timeline-end) that place content around the line.

Bootstrap has no timeline component of its own, so a Bootstrap timeline is built from grid columns and custom CSS for the line.

How do I add icons to timeline markers?

Set the icon as a background-image or mask-image on the marker pseudo-element. That keeps the HTML free of extra elements.

Treat the icon as decoration, and keep the event meaning in the visible date and title.

How do I number timeline events with CSS counters?

Add counter-reset on the ol, counter-increment on each li, and print counter(name) in the marker’s content property.

A native ol already carries the order for screen readers, so treat the generated number as visual only.

How do I add a hover effect to timeline cards?

Transition box-shadow or border-color on :hover, and pair it with :focus-within so keyboard users get the same cue. Wrap it in @media (hover: hover) so touch screens do not keep a stuck hover state.

A set of CSS card hover effects offers ready patterns, though a shadow shift is enough for a timeline.

Keeping a CSS Timeline Maintainable

Editors should only ever touch the date, title and text inside each item. The list markup and the marker-offset custom properties stay locked in the template.

Structure goes first in the lockdown, because a broken ordered list loses order and count for screen readers, while a visual glitch at least shows. Lock the ol, li and time structure, generate datetime from one date field, and test the page with scroll animation off.

The trade-off is that one-off layouts are out. Each new event type needs a developer to add a template variant.

Stable Firefox shipped animation-timeline in version 159 (October 27, 2026), so the scroll-animation fallback script can go once older Firefox and pre-26 Safari versions drop out of your analytics.

Edits also change what screen readers announce, which makes web accessibility checks part of every publish.