32 CSS Timelines25 / 32
Product Roadmap Timeline — Quarter Columns with Status Chips
The public-roadmap layout SaaS companies publish: four quarter columns of feature cards, each double-coded with a status chip (Shipped ✓ / In progress ◐ / Planned ○), and a 'today' line dropped precisely between quarters with a labeled flag — the format that turns 'when is dark mode coming' support tickets into a URL.
Published Updated
The code
<section class="tls-25" aria-label="Product roadmap timeline demo">
<div class="tls-25__inner">
<header class="tls-25__head">
<h3 class="tls-25__title">2026 Roadmap</h3>
<p class="tls-25__legend"><span data-status="shipped" class="tls-25__chip">Shipped</span><span data-status="progress" class="tls-25__chip">In progress</span><span data-status="planned" class="tls-25__chip">Planned</span></p>
</header>
<div class="tls-25__boardWrap" style="--today:62.5%">
<div class="tls-25__now" aria-hidden="true"><span class="tls-25__nowFlag">Today</span></div>
<div class="tls-25__board">
<section class="tls-25__q">
<h4 class="tls-25__qh">Q1</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">SSO & SAML</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Audit log export</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q2</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Mobile app (iOS + Android)</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Workflow templates</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q3</h4>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Real-time collaboration</p></article>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Dark mode</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Public API v2</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q4</h4>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Self-hosted edition</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Plugin marketplace</p></article>
</section>
</div>
</div>
</div>
</section><section class="tls-25" aria-label="Product roadmap timeline demo">
<div class="tls-25__inner">
<header class="tls-25__head">
<h3 class="tls-25__title">2026 Roadmap</h3>
<p class="tls-25__legend"><span data-status="shipped" class="tls-25__chip">Shipped</span><span data-status="progress" class="tls-25__chip">In progress</span><span data-status="planned" class="tls-25__chip">Planned</span></p>
</header>
<div class="tls-25__boardWrap" style="--today:62.5%">
<div class="tls-25__now" aria-hidden="true"><span class="tls-25__nowFlag">Today</span></div>
<div class="tls-25__board">
<section class="tls-25__q">
<h4 class="tls-25__qh">Q1</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">SSO & SAML</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Audit log export</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q2</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Mobile app (iOS + Android)</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Workflow templates</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q3</h4>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Real-time collaboration</p></article>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Dark mode</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Public API v2</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q4</h4>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Self-hosted edition</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Plugin marketplace</p></article>
</section>
</div>
</div>
</div>
</section>.tls-25,
.tls-25 *,
.tls-25 *::before,
.tls-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-25 {
min-height: 100vh;
width: 100%;
--tl-done: #15803d;
--tl-prog: #b45309;
--tl-plan: #64748b;
--tl-line: #e2e8f0;
--tl-ink: #0f172a;
--tl-mut: #64748b;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f8fafc;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-25__inner {
width: min(860px,100%);
}
.tls-25__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.tls-25__title {
font-size: clamp(22px,3.5vw,28px);
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.02em;
}
.tls-25__legend {
display: flex;
gap: 8px;
}
/* Board wrapper is position:relative so the TODAY line — a direct
absolute-positioned child of the wrapper, NOT a grid item — is
positioned relative to the wrapper's padding-box (which stretches
the full quarters grid width AND height). This avoids the CSS Grid
quirk where an absolutely-positioned grid item auto-flows into
cell 1,1 and its left offset resolves relative to that cell
instead of the whole grid. */
.tls-25__boardWrap {
position: relative;
}
.tls-25__board {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.tls-25__now {
position: absolute;
left: var(--today,50%);
top: 32px;
bottom: 0;
width: 2px;
background: #dc2626;
z-index: 2;
pointer-events: none;
}
.tls-25__nowFlag {
position: absolute;
top: -14px;
left: 50%;
translate: -50% 0;
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
color: #fff;
background: #dc2626;
border-radius: 4px;
padding: 2px 9px;
white-space: nowrap;
box-shadow: 0 4px 12px -4px rgba(220,38,38,.5);
}
/* Each quarter is a vertical stack; align-content:start pushes cards
to the top so a 2-card quarter and a 3-card quarter both start at
the same y-coordinate. */
.tls-25__q {
display: grid;
grid-auto-rows: min-content;
gap: 10px;
align-content: start;
}
.tls-25__qh {
font-size: 12px;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--tl-mut);
border-bottom: 2px solid var(--tl-line);
padding-bottom: 8px;
margin-bottom: 2px;
}
.tls-25__card {
background: #fff;
border: 1px solid var(--tl-line);
border-left-width: 3px;
border-radius: 10px;
padding: 12px 14px;
display: grid;
gap: 8px;
}
.tls-25__card[data-status="shipped"] {
border-left-color: var(--tl-done);
opacity: .75;
}
.tls-25__card[data-status="progress"] {
border-left-color: var(--tl-prog);
}
.tls-25__card[data-status="planned"] {
border-left-color: var(--tl-plan);
border-style: solid solid solid dashed;
}
.tls-25__f {
font-size: 13.5px;
font-weight: 600;
color: var(--tl-ink);
line-height: 1.4;
text-wrap: pretty;
}
.tls-25__chip {
display: inline-flex;
align-items: center;
gap: 5px;
justify-self: start;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .04em;
border-radius: 999px;
padding: 2px 9px;
}
.tls-25__chip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.tls-25__chip[data-status="shipped"] {
color: var(--tl-done);
background: #f0fdf4;
}
.tls-25__chip[data-status="shipped"]::before {
background: var(--tl-done);
}
.tls-25__chip[data-status="progress"] {
color: var(--tl-prog);
background: #fffbeb;
}
.tls-25__chip[data-status="progress"]::before {
background: linear-gradient(90deg,var(--tl-prog) 50%,#fde68a 50%);
}
.tls-25__chip[data-status="planned"] {
color: var(--tl-plan);
background: #f1f5f9;
}
.tls-25__chip[data-status="planned"]::before {
background: transparent;
border: 2px solid var(--tl-plan);
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.tls-25__board {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 8px;
}
.tls-25__q {
flex: none;
width: 75%;
scroll-snap-align: start;
}
.tls-25__now,
.tls-25__boardWrap>.tls-25__now {
display: none;
}
}.tls-25,
.tls-25 *,
.tls-25 *::before,
.tls-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-25 {
min-height: 100vh;
width: 100%;
--tl-done: #15803d;
--tl-prog: #b45309;
--tl-plan: #64748b;
--tl-line: #e2e8f0;
--tl-ink: #0f172a;
--tl-mut: #64748b;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f8fafc;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-25__inner {
width: min(860px,100%);
}
.tls-25__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.tls-25__title {
font-size: clamp(22px,3.5vw,28px);
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.02em;
}
.tls-25__legend {
display: flex;
gap: 8px;
}
/* Board wrapper is position:relative so the TODAY line — a direct
absolute-positioned child of the wrapper, NOT a grid item — is
positioned relative to the wrapper's padding-box (which stretches
the full quarters grid width AND height). This avoids the CSS Grid
quirk where an absolutely-positioned grid item auto-flows into
cell 1,1 and its left offset resolves relative to that cell
instead of the whole grid. */
.tls-25__boardWrap {
position: relative;
}
.tls-25__board {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.tls-25__now {
position: absolute;
left: var(--today,50%);
top: 32px;
bottom: 0;
width: 2px;
background: #dc2626;
z-index: 2;
pointer-events: none;
}
.tls-25__nowFlag {
position: absolute;
top: -14px;
left: 50%;
translate: -50% 0;
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
color: #fff;
background: #dc2626;
border-radius: 4px;
padding: 2px 9px;
white-space: nowrap;
box-shadow: 0 4px 12px -4px rgba(220,38,38,.5);
}
/* Each quarter is a vertical stack; align-content:start pushes cards
to the top so a 2-card quarter and a 3-card quarter both start at
the same y-coordinate. */
.tls-25__q {
display: grid;
grid-auto-rows: min-content;
gap: 10px;
align-content: start;
}
.tls-25__qh {
font-size: 12px;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--tl-mut);
border-bottom: 2px solid var(--tl-line);
padding-bottom: 8px;
margin-bottom: 2px;
}
.tls-25__card {
background: #fff;
border: 1px solid var(--tl-line);
border-left-width: 3px;
border-radius: 10px;
padding: 12px 14px;
display: grid;
gap: 8px;
}
.tls-25__card[data-status="shipped"] {
border-left-color: var(--tl-done);
opacity: .75;
}
.tls-25__card[data-status="progress"] {
border-left-color: var(--tl-prog);
}
.tls-25__card[data-status="planned"] {
border-left-color: var(--tl-plan);
border-style: solid solid solid dashed;
}
.tls-25__f {
font-size: 13.5px;
font-weight: 600;
color: var(--tl-ink);
line-height: 1.4;
text-wrap: pretty;
}
.tls-25__chip {
display: inline-flex;
align-items: center;
gap: 5px;
justify-self: start;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .04em;
border-radius: 999px;
padding: 2px 9px;
}
.tls-25__chip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.tls-25__chip[data-status="shipped"] {
color: var(--tl-done);
background: #f0fdf4;
}
.tls-25__chip[data-status="shipped"]::before {
background: var(--tl-done);
}
.tls-25__chip[data-status="progress"] {
color: var(--tl-prog);
background: #fffbeb;
}
.tls-25__chip[data-status="progress"]::before {
background: linear-gradient(90deg,var(--tl-prog) 50%,#fde68a 50%);
}
.tls-25__chip[data-status="planned"] {
color: var(--tl-plan);
background: #f1f5f9;
}
.tls-25__chip[data-status="planned"]::before {
background: transparent;
border: 2px solid var(--tl-plan);
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.tls-25__board {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 8px;
}
.tls-25__q {
flex: none;
width: 75%;
scroll-snap-align: start;
}
.tls-25__now,
.tls-25__boardWrap>.tls-25__now {
display: none;
}
}/* No JavaScript — status is a data-status attribute driving chip glyph, color and border via attribute selectors; the today line position is one --today custom property (compute it server-side for live dates). */
/* No JavaScript — status is a data-status attribute driving chip glyph, color and border via attribute selectors; the today line position is one --today custom property (compute it server-side for live dates). */Here's a working CSS Timeline from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Product Roadmap Timeline — Quarter Columns with Status Chips
Source: https://codefronts.com/layouts/css-timelines/stacked-progress/
The public-roadmap layout SaaS companies publish: four quarter columns of feature cards, each double-coded with a status chip (Shipped ✓ / In progress ◐ / Planned ○), and a 'today' line dropped precisely between quarters with a labeled flag — the format that turns 'when is dark mode coming' support tickets into a URL.
## HTML
```html
<section class="tls-25" aria-label="Product roadmap timeline demo">
<div class="tls-25__inner">
<header class="tls-25__head">
<h3 class="tls-25__title">2026 Roadmap</h3>
<p class="tls-25__legend"><span data-status="shipped" class="tls-25__chip">Shipped</span><span data-status="progress" class="tls-25__chip">In progress</span><span data-status="planned" class="tls-25__chip">Planned</span></p>
</header>
<div class="tls-25__boardWrap" style="--today:62.5%">
<div class="tls-25__now" aria-hidden="true"><span class="tls-25__nowFlag">Today</span></div>
<div class="tls-25__board">
<section class="tls-25__q">
<h4 class="tls-25__qh">Q1</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">SSO & SAML</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Audit log export</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q2</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Mobile app (iOS + Android)</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Workflow templates</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q3</h4>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Real-time collaboration</p></article>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Dark mode</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Public API v2</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q4</h4>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Self-hosted edition</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Plugin marketplace</p></article>
</section>
</div>
</div>
</div>
</section>
```
## CSS
```css
.tls-25,
.tls-25 *,
.tls-25 *::before,
.tls-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-25 {
min-height: 100vh;
width: 100%;
--tl-done: #15803d;
--tl-prog: #b45309;
--tl-plan: #64748b;
--tl-line: #e2e8f0;
--tl-ink: #0f172a;
--tl-mut: #64748b;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f8fafc;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-25__inner {
width: min(860px,100%);
}
.tls-25__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.tls-25__title {
font-size: clamp(22px,3.5vw,28px);
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.02em;
}
.tls-25__legend {
display: flex;
gap: 8px;
}
/* Board wrapper is position:relative so the TODAY line — a direct
absolute-positioned child of the wrapper, NOT a grid item — is
positioned relative to the wrapper's padding-box (which stretches
the full quarters grid width AND height). This avoids the CSS Grid
quirk where an absolutely-positioned grid item auto-flows into
cell 1,1 and its left offset resolves relative to that cell
instead of the whole grid. */
.tls-25__boardWrap {
position: relative;
}
.tls-25__board {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.tls-25__now {
position: absolute;
left: var(--today,50%);
top: 32px;
bottom: 0;
width: 2px;
background: #dc2626;
z-index: 2;
pointer-events: none;
}
.tls-25__nowFlag {
position: absolute;
top: -14px;
left: 50%;
translate: -50% 0;
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
color: #fff;
background: #dc2626;
border-radius: 4px;
padding: 2px 9px;
white-space: nowrap;
box-shadow: 0 4px 12px -4px rgba(220,38,38,.5);
}
/* Each quarter is a vertical stack; align-content:start pushes cards
to the top so a 2-card quarter and a 3-card quarter both start at
the same y-coordinate. */
.tls-25__q {
display: grid;
grid-auto-rows: min-content;
gap: 10px;
align-content: start;
}
.tls-25__qh {
font-size: 12px;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--tl-mut);
border-bottom: 2px solid var(--tl-line);
padding-bottom: 8px;
margin-bottom: 2px;
}
.tls-25__card {
background: #fff;
border: 1px solid var(--tl-line);
border-left-width: 3px;
border-radius: 10px;
padding: 12px 14px;
display: grid;
gap: 8px;
}
.tls-25__card[data-status="shipped"] {
border-left-color: var(--tl-done);
opacity: .75;
}
.tls-25__card[data-status="progress"] {
border-left-color: var(--tl-prog);
}
.tls-25__card[data-status="planned"] {
border-left-color: var(--tl-plan);
border-style: solid solid solid dashed;
}
.tls-25__f {
font-size: 13.5px;
font-weight: 600;
color: var(--tl-ink);
line-height: 1.4;
text-wrap: pretty;
}
.tls-25__chip {
display: inline-flex;
align-items: center;
gap: 5px;
justify-self: start;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .04em;
border-radius: 999px;
padding: 2px 9px;
}
.tls-25__chip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.tls-25__chip[data-status="shipped"] {
color: var(--tl-done);
background: #f0fdf4;
}
.tls-25__chip[data-status="shipped"]::before {
background: var(--tl-done);
}
.tls-25__chip[data-status="progress"] {
color: var(--tl-prog);
background: #fffbeb;
}
.tls-25__chip[data-status="progress"]::before {
background: linear-gradient(90deg,var(--tl-prog) 50%,#fde68a 50%);
}
.tls-25__chip[data-status="planned"] {
color: var(--tl-plan);
background: #f1f5f9;
}
.tls-25__chip[data-status="planned"]::before {
background: transparent;
border: 2px solid var(--tl-plan);
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.tls-25__board {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 8px;
}
.tls-25__q {
flex: none;
width: 75%;
scroll-snap-align: start;
}
.tls-25__now,
.tls-25__boardWrap>.tls-25__now {
display: none;
}
}
```
## JavaScript
```js
/* No JavaScript — status is a data-status attribute driving chip glyph, color and border via attribute selectors; the today line position is one --today custom property (compute it server-side for live dates). */
```Here's a working CSS Timeline from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Product Roadmap Timeline — Quarter Columns with Status Chips
Source: https://codefronts.com/layouts/css-timelines/stacked-progress/
The public-roadmap layout SaaS companies publish: four quarter columns of feature cards, each double-coded with a status chip (Shipped ✓ / In progress ◐ / Planned ○), and a 'today' line dropped precisely between quarters with a labeled flag — the format that turns 'when is dark mode coming' support tickets into a URL.
## HTML
```html
<section class="tls-25" aria-label="Product roadmap timeline demo">
<div class="tls-25__inner">
<header class="tls-25__head">
<h3 class="tls-25__title">2026 Roadmap</h3>
<p class="tls-25__legend"><span data-status="shipped" class="tls-25__chip">Shipped</span><span data-status="progress" class="tls-25__chip">In progress</span><span data-status="planned" class="tls-25__chip">Planned</span></p>
</header>
<div class="tls-25__boardWrap" style="--today:62.5%">
<div class="tls-25__now" aria-hidden="true"><span class="tls-25__nowFlag">Today</span></div>
<div class="tls-25__board">
<section class="tls-25__q">
<h4 class="tls-25__qh">Q1</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">SSO & SAML</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Audit log export</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q2</h4>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Mobile app (iOS + Android)</p></article>
<article class="tls-25__card" data-status="shipped"><span class="tls-25__chip" data-status="shipped">Shipped</span><p class="tls-25__f">Workflow templates</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q3</h4>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Real-time collaboration</p></article>
<article class="tls-25__card" data-status="progress"><span class="tls-25__chip" data-status="progress">In progress</span><p class="tls-25__f">Dark mode</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Public API v2</p></article>
</section>
<section class="tls-25__q">
<h4 class="tls-25__qh">Q4</h4>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Self-hosted edition</p></article>
<article class="tls-25__card" data-status="planned"><span class="tls-25__chip" data-status="planned">Planned</span><p class="tls-25__f">Plugin marketplace</p></article>
</section>
</div>
</div>
</div>
</section>
```
## CSS
```css
.tls-25,
.tls-25 *,
.tls-25 *::before,
.tls-25 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-25 {
min-height: 100vh;
width: 100%;
--tl-done: #15803d;
--tl-prog: #b45309;
--tl-plan: #64748b;
--tl-line: #e2e8f0;
--tl-ink: #0f172a;
--tl-mut: #64748b;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #f8fafc;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-25__inner {
width: min(860px,100%);
}
.tls-25__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.tls-25__title {
font-size: clamp(22px,3.5vw,28px);
font-weight: 800;
color: var(--tl-ink);
letter-spacing: -.02em;
}
.tls-25__legend {
display: flex;
gap: 8px;
}
/* Board wrapper is position:relative so the TODAY line — a direct
absolute-positioned child of the wrapper, NOT a grid item — is
positioned relative to the wrapper's padding-box (which stretches
the full quarters grid width AND height). This avoids the CSS Grid
quirk where an absolutely-positioned grid item auto-flows into
cell 1,1 and its left offset resolves relative to that cell
instead of the whole grid. */
.tls-25__boardWrap {
position: relative;
}
.tls-25__board {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.tls-25__now {
position: absolute;
left: var(--today,50%);
top: 32px;
bottom: 0;
width: 2px;
background: #dc2626;
z-index: 2;
pointer-events: none;
}
.tls-25__nowFlag {
position: absolute;
top: -14px;
left: 50%;
translate: -50% 0;
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
color: #fff;
background: #dc2626;
border-radius: 4px;
padding: 2px 9px;
white-space: nowrap;
box-shadow: 0 4px 12px -4px rgba(220,38,38,.5);
}
/* Each quarter is a vertical stack; align-content:start pushes cards
to the top so a 2-card quarter and a 3-card quarter both start at
the same y-coordinate. */
.tls-25__q {
display: grid;
grid-auto-rows: min-content;
gap: 10px;
align-content: start;
}
.tls-25__qh {
font-size: 12px;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--tl-mut);
border-bottom: 2px solid var(--tl-line);
padding-bottom: 8px;
margin-bottom: 2px;
}
.tls-25__card {
background: #fff;
border: 1px solid var(--tl-line);
border-left-width: 3px;
border-radius: 10px;
padding: 12px 14px;
display: grid;
gap: 8px;
}
.tls-25__card[data-status="shipped"] {
border-left-color: var(--tl-done);
opacity: .75;
}
.tls-25__card[data-status="progress"] {
border-left-color: var(--tl-prog);
}
.tls-25__card[data-status="planned"] {
border-left-color: var(--tl-plan);
border-style: solid solid solid dashed;
}
.tls-25__f {
font-size: 13.5px;
font-weight: 600;
color: var(--tl-ink);
line-height: 1.4;
text-wrap: pretty;
}
.tls-25__chip {
display: inline-flex;
align-items: center;
gap: 5px;
justify-self: start;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .04em;
border-radius: 999px;
padding: 2px 9px;
}
.tls-25__chip::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.tls-25__chip[data-status="shipped"] {
color: var(--tl-done);
background: #f0fdf4;
}
.tls-25__chip[data-status="shipped"]::before {
background: var(--tl-done);
}
.tls-25__chip[data-status="progress"] {
color: var(--tl-prog);
background: #fffbeb;
}
.tls-25__chip[data-status="progress"]::before {
background: linear-gradient(90deg,var(--tl-prog) 50%,#fde68a 50%);
}
.tls-25__chip[data-status="planned"] {
color: var(--tl-plan);
background: #f1f5f9;
}
.tls-25__chip[data-status="planned"]::before {
background: transparent;
border: 2px solid var(--tl-plan);
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.tls-25__board {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 8px;
}
.tls-25__q {
flex: none;
width: 75%;
scroll-snap-align: start;
}
.tls-25__now,
.tls-25__boardWrap>.tls-25__now {
display: none;
}
}
```
## JavaScript
```js
/* No JavaScript — status is a data-status attribute driving chip glyph, color and border via attribute selectors; the today line position is one --today custom property (compute it server-side for live dates). */
```How this works
The board is a four-column grid with grid-auto-rows: min-content so cards pack upward in each quarter independently. Quarter headers are sticky-free and semantic — each column is a <section> with an <h4>, so a screen reader walks the roadmap quarter by quarter, exactly how a PM would present it.
Status is a data-status attribute driving everything through attribute selectors: chip color, chip glyph (drawn with a ::before — check, half-disc, hollow ring), and card left-border. The glyph + text label means status survives grayscale printing and color-blindness; the demo's palette is merely reinforcement (WCAG 1.4.1). Shipped cards also get opacity:.75 — done items recede, current work pops.
The 'today' line is one absolutely-positioned element at left: 62.5% — halfway through Q3 (2 quarters + half = 2.5/4). In production, compute the offset server-side or with one line of JS ((dayOfYear/365)*100%) and set it as an inline --today custom property; the CSS consumes the token either way. Its flag label sits in a rotated tab so it never collides with card content.
Techniques used: quarter sections as grid columns (semantic h4 per column) · data-status attribute API → chip glyph + color + border via attribute selectors · CSS-drawn status glyphs (::before check / half-disc / ring) · --today custom-property-positioned now-line with flag · shipped-card recession via opacity · mobile fallback stacks quarters with a horizontal scroll-snap strip
Make it yours
- Move the now-line: set
--today: 37%inline on the board — one token, resolved by the CSS. - Add a status: one
data-status="at-risk"ruleset (chip color + glyph) — no HTML changes elsewhere. - Quarters → months: change grid-template-columns to repeat(6,1fr) and add sections; the pattern is column-count agnostic.
- De-emphasis: the .75 opacity on shipped cards — drop to .55 for long roadmaps where history dominates.
Gotchas — read before shipping
- Never color-only the status — the chip's glyph and text are the message; a red/green pair alone excludes ~8% of male users.
- The now-line must be pointer-events:none or it blocks clicks on cards beneath it.
- Don't promise dates INSIDE cards on a public roadmap — quarters are the commitment; per-card dates get screenshot and quoted back at you.
- On mobile the four columns MUST become scrollable or stacked — 4-up at 375px yields 80px columns and broken words; the demo ships the scroll-snap fallback.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by text-wrap as this demo is written. The core technique itself goes back further — Chrome all.
Grid, attribute selectors and custom properties — baseline everything. The scroll-snap mobile fallback is baseline since 2019.