Open demo

CSS code

HTML
<ol class="spine-tl">
  <li class="step"><time datetime="2026-W37">Week 1</time><h2>Kick-off</h2>
    <p>Scope agreed, repository created, and the first deploy pipeline running end to end before any feature work starts.</p></li>
  <li class="step"><time datetime="2026-W39">Week 3</time><h2>Design system</h2>
    <p>Tokens, type scale and the twelve components everything else is assembled from.</p></li>
  <li class="step"><time datetime="2026-W42">Week 6</time><h2>Private beta</h2>
    <p>Forty accounts, a feedback channel, and a rule that nothing ships on a Friday.</p></li>
  <li class="step"><time datetime="2026-W45">Week 9</time><h2>Public launch</h2>
    <p>Docs, pricing and the migration path for people already on the old plan.</p></li>
  <li class="step"><time datetime="2026-W48">Week 12</time><h2>First review</h2>
    <p>What shipped against what was promised, and what to stop doing next quarter.</p></li>
</ol>
CSS
/* Scroll-filled spine: an <ol> whose ::after fill is scaled by a scroll-driven animation (animation-timeline: view()).
   No JavaScript. Unsupported browsers and reduced-motion users get the spine complete and faded. */
.spine-tl{--sp-from:#4f46e5;--sp-to:#a855f7;--sp-track:#dde3f0;--sp-dot:#4f46e5;--sp-muted:#5f6a86;--sp-light:#646f8b;
  position:relative;width:100%;max-width:620px;margin:0;padding:0 0 0 34px;list-style:none}
.spine-tl,.spine-tl *{box-sizing:border-box}
.spine-tl :where(li,h2,p){margin:0;padding:0}
.spine-tl::before{content:"";position:absolute;left:7px;top:8px;bottom:8px;width:3px;background:var(--sp-track);border-radius:99px}
.spine-tl::after{content:"";position:absolute;left:7px;top:8px;width:3px;border-radius:99px;
  background:linear-gradient(180deg,var(--sp-from),var(--sp-to));height:100%;transform-origin:50% 0;transform:scaleY(0)}
@supports (animation-timeline: view()){
  .spine-tl::after{animation:spine-fill linear both;animation-timeline:view();animation-range:cover 0% cover 88%}
}
@keyframes spine-fill{to{transform:scaleY(1)}}
@supports not (animation-timeline: view()){ .spine-tl::after{transform:scaleY(1);opacity:.35} }
@media(prefers-reduced-motion:reduce){.spine-tl::after{animation:none;transform:scaleY(1);opacity:.35}}
.spine-tl .step{position:relative;padding:0 0 46px}
.spine-tl .step:last-child{padding-bottom:0}
.spine-tl .step::before{content:"";position:absolute;left:-34px;top:4px;width:17px;height:17px;border-radius:50%;
  background:#fff;border:3px solid var(--sp-dot);z-index:1}
.spine-tl h2{font-size:1rem;letter-spacing:-.01em;margin-bottom:5px}
.spine-tl time{font-size:.75rem;color:var(--sp-light);font-weight:700;letter-spacing:.05em;text-transform:uppercase;
  display:block;margin-bottom:6px}
.spine-tl p{font-size:.86rem;color:var(--sp-muted);line-height:1.7}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<ol class="spine-tl list-unstyled position-relative w-100 m-0">
  <li class="step position-relative"><time class="d-block fw-bold text-uppercase" datetime="2026-W37">Week 1</time><h2>Kick-off</h2>
    <p>Scope agreed, repository created, and the first deploy pipeline running end to end before any feature work starts.</p></li>
  <li class="step position-relative"><time class="d-block fw-bold text-uppercase" datetime="2026-W39">Week 3</time><h2>Design system</h2>
    <p>Tokens, type scale and the twelve components everything else is assembled from.</p></li>
  <li class="step position-relative"><time class="d-block fw-bold text-uppercase" datetime="2026-W42">Week 6</time><h2>Private beta</h2>
    <p>Forty accounts, a feedback channel, and a rule that nothing ships on a Friday.</p></li>
  <li class="step position-relative"><time class="d-block fw-bold text-uppercase" datetime="2026-W45">Week 9</time><h2>Public launch</h2>
    <p>Docs, pricing and the migration path for people already on the old plan.</p></li>
  <li class="step position-relative"><time class="d-block fw-bold text-uppercase" datetime="2026-W48">Week 12</time><h2>First review</h2>
    <p>What shipped against what was promised, and what to stop doing next quarter.</p></li>
</ol>
CSS
/* Bootstrap list and type utilities; the spine fill is a scroll-driven animation (animation-timeline: view()).
   Unsupported browsers and reduced-motion users get the spine complete and faded. */
.spine-tl{--sp-from:#4f46e5;--sp-to:#a855f7;--sp-track:#dde3f0;--sp-dot:#4f46e5;--sp-muted:#5f6a86;--sp-light:#646f8b;
  max-width:620px;padding-left:34px}
.spine-tl::before{content:"";position:absolute;left:7px;top:8px;bottom:8px;width:3px;background:var(--sp-track);border-radius:99px}
.spine-tl::after{content:"";position:absolute;left:7px;top:8px;width:3px;border-radius:99px;
  background:linear-gradient(180deg,var(--sp-from),var(--sp-to));height:100%;transform-origin:50% 0;transform:scaleY(0)}
@supports (animation-timeline: view()){
  .spine-tl::after{animation:spine-fill linear both;animation-timeline:view();animation-range:cover 0% cover 88%}
}
@keyframes spine-fill{to{transform:scaleY(1)}}
@supports not (animation-timeline: view()){ .spine-tl::after{transform:scaleY(1);opacity:.35} }
@media(prefers-reduced-motion:reduce){.spine-tl::after{animation:none;transform:scaleY(1);opacity:.35}}
.spine-tl .step{padding-bottom:46px}
.spine-tl .step:last-child{padding-bottom:0}
.spine-tl .step::before{content:"";position:absolute;left:-34px;top:4px;width:23px;height:23px;border-radius:50%;
  background:#fff;border:3px solid var(--sp-dot);z-index:1}
.spine-tl h2{font-size:1rem;font-weight:700;letter-spacing:-.01em;line-height:normal;margin:0 0 5px}
.spine-tl time{font-size:.75rem;color:var(--sp-light);letter-spacing:.05em;margin-bottom:6px}
.spine-tl p{font-size:.86rem;color:var(--sp-muted);line-height:1.7;margin:0}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<ol class="spine-tl relative w-full max-w-[620px] pl-[34px] before:absolute before:top-2 before:bottom-2 before:left-[7px] before:w-[3px] before:rounded-full before:bg-sp-track">
  <li class="relative pb-[46px] last:pb-0 before:absolute before:top-1 before:-left-[34px] before:z-[1] before:size-[23px] before:rounded-full before:border-3 before:border-sp-dot before:bg-white"><time class="mb-1.5 block text-[.75rem] font-bold tracking-[.05em] text-sp-light uppercase" datetime="2026-W37">Week 1</time><h2 class="mb-[5px] text-[1rem] font-bold tracking-[-.01em]">Kick-off</h2>
    <p class="text-[.86rem] leading-[1.7] text-sp-muted">Scope agreed, repository created, and the first deploy pipeline running end to end before any feature work starts.</p></li>
  <li class="relative pb-[46px] last:pb-0 before:absolute before:top-1 before:-left-[34px] before:z-[1] before:size-[23px] before:rounded-full before:border-3 before:border-sp-dot before:bg-white"><time class="mb-1.5 block text-[.75rem] font-bold tracking-[.05em] text-sp-light uppercase" datetime="2026-W39">Week 3</time><h2 class="mb-[5px] text-[1rem] font-bold tracking-[-.01em]">Design system</h2>
    <p class="text-[.86rem] leading-[1.7] text-sp-muted">Tokens, type scale and the twelve components everything else is assembled from.</p></li>
  <li class="relative pb-[46px] last:pb-0 before:absolute before:top-1 before:-left-[34px] before:z-[1] before:size-[23px] before:rounded-full before:border-3 before:border-sp-dot before:bg-white"><time class="mb-1.5 block text-[.75rem] font-bold tracking-[.05em] text-sp-light uppercase" datetime="2026-W42">Week 6</time><h2 class="mb-[5px] text-[1rem] font-bold tracking-[-.01em]">Private beta</h2>
    <p class="text-[.86rem] leading-[1.7] text-sp-muted">Forty accounts, a feedback channel, and a rule that nothing ships on a Friday.</p></li>
  <li class="relative pb-[46px] last:pb-0 before:absolute before:top-1 before:-left-[34px] before:z-[1] before:size-[23px] before:rounded-full before:border-3 before:border-sp-dot before:bg-white"><time class="mb-1.5 block text-[.75rem] font-bold tracking-[.05em] text-sp-light uppercase" datetime="2026-W45">Week 9</time><h2 class="mb-[5px] text-[1rem] font-bold tracking-[-.01em]">Public launch</h2>
    <p class="text-[.86rem] leading-[1.7] text-sp-muted">Docs, pricing and the migration path for people already on the old plan.</p></li>
  <li class="relative pb-[46px] last:pb-0 before:absolute before:top-1 before:-left-[34px] before:z-[1] before:size-[23px] before:rounded-full before:border-3 before:border-sp-dot before:bg-white"><time class="mb-1.5 block text-[.75rem] font-bold tracking-[.05em] text-sp-light uppercase" datetime="2026-W48">Week 12</time><h2 class="mb-[5px] text-[1rem] font-bold tracking-[-.01em]">First review</h2>
    <p class="text-[.86rem] leading-[1.7] text-sp-muted">What shipped against what was promised, and what to stop doing next quarter.</p></li>
</ol>
CSS
@theme {
  --color-sp-from: #4f46e5;
  --color-sp-to: #a855f7;
  --color-sp-track: #dde3f0;
  --color-sp-dot: #4f46e5;
  --color-sp-muted: #5f6a86;
  --color-sp-light: #646f8b;
}
/* The fill is a scroll-driven animation, which has no utility: it lives here. */
.spine-tl::after{content:"";position:absolute;left:7px;top:8px;width:3px;height:100%;border-radius:99px;
  background:linear-gradient(180deg,var(--color-sp-from),var(--color-sp-to));transform-origin:50% 0;transform:scaleY(0)}
@supports (animation-timeline: view()){
  .spine-tl::after{animation:spine-fill linear both;animation-timeline:view();animation-range:cover 0% cover 88%}
}
@keyframes spine-fill{to{transform:scaleY(1)}}
@supports not (animation-timeline: view()){ .spine-tl::after{transform:scaleY(1);opacity:.35} }
@media(prefers-reduced-motion:reduce){.spine-tl::after{animation:none;transform:scaleY(1);opacity:.35}}

About this set

A vertical project timeline whose spine fills with an indigo to violet gradient as the timeline scrolls through the viewport, with no JavaScript and no scroll listener. The demo walks through a twelve-week launch plan, from kick-off and design system to private beta, public launch and a first review, with a week label, a heading and a paragraph per step and a ring on the spine for each. Use it on a case study, a project plan or an about page where the reader’s progress down the page is part of the story. The fill is a ::after pseudo-element scaled from zero to full height by a CSS scroll-driven animation, animation-timeline: view(), so the browser drives it from scroll position off the main thread. Browsers without scroll-driven animations get the spine complete and faded, and so do visitors who ask for reduced motion, so the information never depends on the effect. The steps are an ordered list, and each week label is a time element with an ISO week value. The Bootstrap and Tailwind versions share the same few lines of scroll-driven CSS, since neither framework has a utility for it.

What’s included

  • Spine fills on scroll with animation-timeline: view()
  • No JavaScript and no scroll listener
  • Complete, faded spine as the fallback and for reduced motion
  • Ordered list with week labels in time elements
  • Gradient fill set by two custom properties

Accessibility

Steps are an ordered list with headings, and each week label is a time element with an ISO week value. The spine, its fill and the rings are CSS pseudo-elements that screen readers ignore. Under prefers-reduced-motion the scroll-linked animation is switched off and the spine is shown complete. Text meets 4.5:1 contrast.

Customise it

Change --sp-from and --sp-to for the fill gradient and --sp-track, --sp-dot and --sp-muted on .spine-tl (plain CSS and Bootstrap), or the --color-sp-* theme colours in Tailwind. animation-range sets how much of the scroll the fill takes.