Open demo

CSS code

HTML
<div class="log">
  <h2 class="month">September 2026</h2>
  <ol class="entries">
    <li class="entry" style="--c:#16a34a"><h3>Scroll-driven progress <span class="tag new">New</span></h3>
      <p>Timelines can now fill their spine as the page scrolls, with no scroll listener.</p>
      <span class="ver">v4.2.0 · <time datetime="2026-09-01">1 Sep</time></span></li>
    <li class="entry" style="--c:#f59e0b"><h3>Sticky headings no longer jump <span class="tag fix">Fix</span></h3>
      <p>A rounding error made the heading shift by a pixel at certain zoom levels.</p>
      <span class="ver">v4.1.3 · <time datetime="2026-09-01">1 Sep</time></span></li>
  </ol>
  <h2 class="month">August 2026</h2>
  <ol class="entries">
    <li class="entry" style="--c:#dc2626"><h3>Renamed the axis prop <span class="tag brk">Breaking</span></h3>
      <p><strong>direction</strong> is now <strong>axis</strong>, to match the CSS property it sets.</p>
      <span class="ver">v4.0.0 · <time datetime="2026-08-22">22 Aug</time></span></li>
    <li class="entry" style="--c:#16a34a"><h3>Grouped entries <span class="tag new">New</span></h3>
      <p>Entries can be collected under a heading without wrapping each group in its own component.</p>
      <span class="ver">v3.9.0 · <time datetime="2026-08-14">14 Aug</time></span></li>
  </ol>
</div>
CSS
/* Grouped changelog: a month heading (sticky) over an <ol> of entries; the spine and dots are pseudo-elements.
   Set --gl-bg to the page background so the sticky heading covers the entries scrolling under it. */
.log{--gl-bg:#f5f7fb;--gl-line:#dde3f0;--gl-muted:#5f6a86;--gl-light:#646f8b;width:100%;max-width:620px}
.log,.log *{box-sizing:border-box}
.log :where(h2,h3,p,ol,li){margin:0;padding:0;list-style:none}
.log .month{position:sticky;top:0;background:var(--gl-bg);padding:10px 0 8px;z-index:3;
  font-size:.74rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--gl-light)}
.log .month::after{content:"";display:block;height:1px;background:var(--gl-line);margin-top:8px}
.log .entry{position:relative;padding:16px 0 16px 30px}
.log .entry::before{content:"";position:absolute;left:5px;top:0;bottom:0;width:2px;background:var(--gl-line)}
.log .entries:last-of-type .entry:last-child::before{bottom:auto;height:24px}
.log .entry::after{content:"";position:absolute;left:0;top:22px;width:12px;height:12px;border-radius:50%;
  background:#fff;border:3px solid var(--c,#4f46e5)}
.log h3{font-size:.94rem;letter-spacing:-.01em;display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.log .tag{font-size:.68rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 8px;border-radius:99px}
.log .new{background:#e8f8ee;color:#15803d}
.log .fix{background:#fff4e5;color:#b45309}
.log .brk{background:#fdecec;color:#b91c1c}
.log p{font-size:.84rem;color:var(--gl-muted);line-height:1.65;margin-top:6px}
.log .ver{font-size:.74rem;color:var(--gl-light);margin-top:8px;display:block;font-variant-numeric:tabular-nums}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="log w-100">
  <h2 class="month sticky-top text-uppercase m-0">September 2026</h2>
  <ol class="entries list-unstyled m-0">
    <li class="entry position-relative" style="--c:#16a34a"><h3 class="d-flex align-items-center flex-wrap m-0">Scroll-driven progress <span class="badge rounded-pill text-uppercase new">New</span></h3>
      <p>Timelines can now fill their spine as the page scrolls, with no scroll listener.</p>
      <span class="ver d-block">v4.2.0 · <time datetime="2026-09-01">1 Sep</time></span></li>
    <li class="entry position-relative" style="--c:#f59e0b"><h3 class="d-flex align-items-center flex-wrap m-0">Sticky headings no longer jump <span class="badge rounded-pill text-uppercase fix">Fix</span></h3>
      <p>A rounding error made the heading shift by a pixel at certain zoom levels.</p>
      <span class="ver d-block">v4.1.3 · <time datetime="2026-09-01">1 Sep</time></span></li>
  </ol>
  <h2 class="month sticky-top text-uppercase m-0">August 2026</h2>
  <ol class="entries list-unstyled m-0">
    <li class="entry position-relative" style="--c:#dc2626"><h3 class="d-flex align-items-center flex-wrap m-0">Renamed the axis prop <span class="badge rounded-pill text-uppercase brk">Breaking</span></h3>
      <p><strong>direction</strong> is now <strong>axis</strong>, to match the CSS property it sets.</p>
      <span class="ver d-block">v4.0.0 · <time datetime="2026-08-22">22 Aug</time></span></li>
    <li class="entry position-relative" style="--c:#16a34a"><h3 class="d-flex align-items-center flex-wrap m-0">Grouped entries <span class="badge rounded-pill text-uppercase new">New</span></h3>
      <p>Entries can be collected under a heading without wrapping each group in its own component.</p>
      <span class="ver d-block">v3.9.0 · <time datetime="2026-08-14">14 Aug</time></span></li>
  </ol>
</div>
CSS
/* Bootstrap .sticky-top headings and .badge tags; the spine and dots are pseudo-elements.
   Set --gl-bg to the page background so the sticky heading covers the entries scrolling under it. */
.log{--gl-bg:#f5f7fb;--gl-line:#dde3f0;--gl-muted:#5f6a86;--gl-light:#646f8b;max-width:620px}
.log .month{background:var(--gl-bg);padding:10px 0 8px;z-index:3;
  font-size:.74rem;font-weight:800;letter-spacing:.1em;color:var(--gl-light);line-height:normal}
.log .month::after{content:"";display:block;height:1px;background:var(--gl-line);margin-top:8px}
.log .entry{padding:16px 0 16px 30px}
.log .entry::before{content:"";position:absolute;left:5px;top:0;bottom:0;width:2px;background:var(--gl-line)}
.log .entries:last-of-type .entry:last-child::before{bottom:auto;height:24px}
.log .entry::after{content:"";position:absolute;left:0;top:22px;width:18px;height:18px;border-radius:50%;
  background:#fff;border:3px solid var(--c,#4f46e5)}
.log h3{font-size:.94rem;font-weight:700;letter-spacing:-.01em;gap:9px;line-height:normal}
.log .badge{--bs-badge-font-size:.68rem;--bs-badge-font-weight:800;--bs-badge-padding-y:2px;--bs-badge-padding-x:8px;letter-spacing:.04em;line-height:normal}
.log .new{background:#e8f8ee;color:#15803d}
.log .fix{background:#fff4e5;color:#b45309}
.log .brk{background:#fdecec;color:#b91c1c}
.log p{font-size:.84rem;color:var(--gl-muted);line-height:1.65;margin:6px 0 0}
.log .ver{font-size:.74rem;color:var(--gl-light);margin-top:8px;font-variant-numeric:tabular-nums}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="w-full max-w-[620px]">
  <h2 class="sticky top-0 z-[3] bg-gl-bg pt-2.5 pb-2 text-[.74rem] font-extrabold tracking-[.1em] text-gl-light uppercase after:mt-2 after:block after:h-px after:bg-gl-line">September 2026</h2>
  <ol>
    <li class="relative py-4 pl-[30px] before:absolute before:left-[5px] before:top-0 before:bottom-0 before:w-0.5 before:bg-gl-line after:absolute after:top-[22px] after:left-0 after:size-[18px] after:rounded-full after:border-3 after:border-(--c) after:bg-white" style="--c:#16a34a"><h3 class="flex flex-wrap items-center gap-[9px] text-[.94rem] font-bold tracking-[-.01em]">Scroll-driven progress <span class="rounded-full px-2 py-0.5 text-[.68rem] font-extrabold tracking-[.04em] uppercase bg-[#e8f8ee] text-[#15803d]">New</span></h3>
      <p class="mt-1.5 text-[.84rem] leading-[1.65] text-gl-muted">Timelines can now fill their spine as the page scrolls, with no scroll listener.</p>
      <span class="mt-2 block text-[.74rem] text-gl-light tabular-nums">v4.2.0 · <time datetime="2026-09-01">1 Sep</time></span></li>
    <li class="relative py-4 pl-[30px] before:absolute before:left-[5px] before:top-0 before:bottom-0 before:w-0.5 before:bg-gl-line after:absolute after:top-[22px] after:left-0 after:size-[18px] after:rounded-full after:border-3 after:border-(--c) after:bg-white" style="--c:#f59e0b"><h3 class="flex flex-wrap items-center gap-[9px] text-[.94rem] font-bold tracking-[-.01em]">Sticky headings no longer jump <span class="rounded-full px-2 py-0.5 text-[.68rem] font-extrabold tracking-[.04em] uppercase bg-[#fff4e5] text-[#b45309]">Fix</span></h3>
      <p class="mt-1.5 text-[.84rem] leading-[1.65] text-gl-muted">A rounding error made the heading shift by a pixel at certain zoom levels.</p>
      <span class="mt-2 block text-[.74rem] text-gl-light tabular-nums">v4.1.3 · <time datetime="2026-09-01">1 Sep</time></span></li>
  </ol>
  <h2 class="sticky top-0 z-[3] bg-gl-bg pt-2.5 pb-2 text-[.74rem] font-extrabold tracking-[.1em] text-gl-light uppercase after:mt-2 after:block after:h-px after:bg-gl-line">August 2026</h2>
  <ol>
    <li class="relative py-4 pl-[30px] before:absolute before:left-[5px] before:top-0 before:bottom-0 before:w-0.5 before:bg-gl-line after:absolute after:top-[22px] after:left-0 after:size-[18px] after:rounded-full after:border-3 after:border-(--c) after:bg-white" style="--c:#dc2626"><h3 class="flex flex-wrap items-center gap-[9px] text-[.94rem] font-bold tracking-[-.01em]">Renamed the axis prop <span class="rounded-full px-2 py-0.5 text-[.68rem] font-extrabold tracking-[.04em] uppercase bg-[#fdecec] text-[#b91c1c]">Breaking</span></h3>
      <p class="mt-1.5 text-[.84rem] leading-[1.65] text-gl-muted"><strong>direction</strong> is now <strong>axis</strong>, to match the CSS property it sets.</p>
      <span class="mt-2 block text-[.74rem] text-gl-light tabular-nums">v4.0.0 · <time datetime="2026-08-22">22 Aug</time></span></li>
    <li class="relative py-4 pl-[30px] before:absolute before:left-[5px] before:top-0 before:h-6 before:w-0.5 before:bg-gl-line after:absolute after:top-[22px] after:left-0 after:size-[18px] after:rounded-full after:border-3 after:border-(--c) after:bg-white" style="--c:#16a34a"><h3 class="flex flex-wrap items-center gap-[9px] text-[.94rem] font-bold tracking-[-.01em]">Grouped entries <span class="rounded-full px-2 py-0.5 text-[.68rem] font-extrabold tracking-[.04em] uppercase bg-[#e8f8ee] text-[#15803d]">New</span></h3>
      <p class="mt-1.5 text-[.84rem] leading-[1.65] text-gl-muted">Entries can be collected under a heading without wrapping each group in its own component.</p>
      <span class="mt-2 block text-[.74rem] text-gl-light tabular-nums">v3.9.0 · <time datetime="2026-08-14">14 Aug</time></span></li>
  </ol>
</div>
CSS
@theme {
  --color-gl-bg: #f5f7fb;
  --color-gl-line: #dde3f0;
  --color-gl-muted: #5f6a86;
  --color-gl-light: #646f8b;
}

About this set

A changelog laid out as a timeline, with entries grouped under month headings that stick to the top of the viewport while you scroll through that month. Each entry has a coloured ring on a thin spine, a title followed by a tag, New, Fix or Breaking, a sentence of detail and a version number with its release date. Use it for product release notes, a documentation changelog or a status history page that grows by a few entries a week. The month headings are h2 elements with position: sticky and a background that matches the page, so entries slide under them, and each month’s entries are an ordered list with h3 titles. The ring colour follows the tag through a –c custom property, but the tag text carries the meaning, so nothing relies on colour. Release dates are time elements with full ISO dates. The spine stops at the last entry’s ring so the timeline ends cleanly. The Bootstrap version uses .sticky-top for the headings and .badge for the tags, and the Tailwind version uses sticky top-0 and before: and after: utilities for the spine and rings.

What’s included

  • Sticky month headings over grouped entries
  • New, Fix and Breaking tags with matching ring colours
  • Version number and release date on every entry
  • Heading structure: h2 per month, h3 per entry
  • Spine ends at the final entry

Accessibility

Months are h2 headings and entries are h3 headings inside ordered lists, so the changelog can be navigated by heading or by list. Each entry's type is written in its tag, and the coloured ring only repeats it. Grey text was darkened to meet 4.5:1 and the tag colours pass on their tinted backgrounds. Nothing animates.

Customise it

Set --gl-bg on .log to your page background so the sticky headings cover what scrolls under them, and change --gl-line, --gl-muted and --gl-light for the spine and text (Tailwind: --color-gl-*). Each entry's ring colour is its inline --c.