22 CSS Feature Sections22 / 22

Pure CSSMIT licensed

Feature Section Timeline Layout Vertical

Six features as milestones on a central rail, alternating left and right, with the rail drawn as a gradient so it fades at both ends instead of stopping abruptly. Built on an ordered list because the sequence is the content, and the whole thing collapses to a single left-aligned column on narrow screens without changing the marker positions.

Published Updated

Live Demo
Try it

The code

<section class="fs-22" aria-labelledby="fs-22-heading">
  <div class="fs-22__hero" aria-hidden="true">
    <span class="fs-22__wordmark">quill/changelog</span>
    <span class="fs-22__heronav">shipped · in progress · planned</span>
  </div>

  <div class="fs-22__stage">
    <div class="fs-22__head">
      <p class="fs-22__eyebrow">Onboarding, in order</p>
      <h2 class="fs-22__heading" id="fs-22-heading">What your first fortnight with Quill looks like</h2>
      <p class="fs-22__sub">Six milestones. Most teams reach the last one inside two weeks.</p>
    </div>

    <ol class="fs-22__timeline">
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 1</p>
          <h3 class="fs-22__ctitle">Connect your first source</h3>
          <p class="fs-22__cprose">Paste a connection string, pick the schemas you care about, and the first sync completes while you finish the tour.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 2</p>
          <h3 class="fs-22__ctitle">Invite the team, map the roles</h3>
          <p class="fs-22__cprose">Directory groups become Quill roles, so analysts get write access to their own space and nothing else.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 4</p>
          <h3 class="fs-22__ctitle">Set freshness expectations</h3>
          <p class="fs-22__cprose">Declare an SLA per dataset. Breaches page the owner with the last good run attached, not a generic alert.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 7</p>
          <h3 class="fs-22__ctitle">Ship the first automation</h3>
          <p class="fs-22__cprose">Trigger on schema drift or a null spike, dry-run it against last week of real rows, then enable it.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 10</p>
          <h3 class="fs-22__ctitle">Wire the warehouse export</h3>
          <p class="fs-22__cprose">Incremental exports on a schedule, with a manifest your dbt project can depend on safely.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 14</p>
          <h3 class="fs-22__ctitle">Hand over the runbook</h3>
          <p class="fs-22__cprose">Ownership, escalation and retention are documented from your actual configuration — nothing left to write up.</p>
        </article>
      </li>
    </ol>
  </div>

  <div class="fs-22__below" aria-hidden="true">Full changelog · Migration guide</div>
</section>
.fs-22 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #fbfbfc;
  --page: oklch(0.985 0.002 270);
  --card: #ffffff;
  --ink: #17181d;
  --ink: oklch(0.22 0.007 275);
  --muted: #5c6070;
  --muted: oklch(0.5 0.014 272);
  --rule: #e2e3e9;
  --rule: oklch(0.9 0.005 270);
  --accent: #4b56d9;
  --accent: oklch(0.5 0.19 272);
  --rail: 2px;
  --font-display: "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.fs-22 *,
.fs-22 *::before,
.fs-22 *::after {
  box-sizing: border-box;
}

.fs-22__hero {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  justify-content: space-between;
  align-items: baseline;
  padding: 14px 20px;
  background: var(--card);
  border-block-end: 1px solid var(--rule);
  font: 500 12px/1 var(--font-display);
  color: var(--muted);
}

.fs-22__wordmark {
  color: var(--ink);
}

.fs-22__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(28px, 5vw, 60px) 16px;
}

.fs-22__head {
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  margin: 0 0 clamp(24px, 4vw, 40px);
  text-align: center;
}

.fs-22__eyebrow {
  margin: 0 0 12px;
  font: 500 11px/1 var(--font-display);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}

.fs-22__heading {
  margin: 0 auto 10px;
  max-inline-size: 26ch;
  font: 600 clamp(25px, 4.6vw, 38px)/1.12 system-ui, sans-serif;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

.fs-22__sub {
  margin: 0 auto;
  max-inline-size: 48ch;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.fs-22__timeline {
  position: relative;
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 20px;
}

.fs-22__timeline::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 50%;
  translate: -50% 0;
  inline-size: var(--rail);
  background: linear-gradient(180deg, transparent, var(--rule) 7%, var(--rule) 93%, transparent);
}

.fs-22__item {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 46px minmax(0, 1fr);
  align-items: center;
  min-inline-size: 0;
}

.fs-22__item > .fs-22__card {
  grid-column: 1;
  text-align: end;
}

.fs-22__item:nth-child(even) > .fs-22__card {
  grid-column: 3;
  text-align: start;
}

.fs-22__marker {
  grid-column: 2;
  justify-self: center;
  inline-size: 15px;
  block-size: 15px;
  border-radius: 50%;
  background: var(--card);
  border: var(--rail) solid var(--accent);
  box-shadow: 0 0 0 5px color-mix(in oklab, var(--accent) 12%, var(--page));
}

.fs-22__card {
  min-inline-size: 0;
  padding: 16px 18px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: 10px;
}

.fs-22__stamp {
  margin: 0 0 8px;
  font: 500 10.5px/1 var(--font-display);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}

.fs-22__ctitle {
  margin: 0 0 7px;
  font: 600 16px/1.3 system-ui, sans-serif;
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.fs-22__cprose {
  margin: 0;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.fs-22__below {
  padding: 14px 20px;
  background: var(--card);
  border-block-start: 1px solid var(--rule);
  font: 500 11.5px/1 var(--font-display);
  color: var(--muted);
}

@media (max-width: 700px) {
  .fs-22__timeline::before {
    inset-inline-start: 8px;
    translate: 0 0;
  }

  .fs-22__item {
    grid-template-columns: 26px minmax(0, 1fr);
  }

  .fs-22__item > .fs-22__card,
    .fs-22__item:nth-child(even) > .fs-22__card {
    grid-column: 2;
    text-align: start;
  }

  .fs-22__marker {
    grid-column: 1;
    justify-self: start;
    margin-inline-start: 1px;
  }
}

@media (prefers-color-scheme: dark) {
  .fs-22 {
    --page: #0e0f13;
    --card: #16181f;
    --ink: #eeeff4;
    --muted: #979cae;
    --rule: #252833;
    --accent: #8b93ff;
  }
}

[data-theme="dark"] .fs-22 {
  --page: #0e0f13;
  --card: #16181f;
  --ink: #eeeff4;
  --muted: #979cae;
  --rule: #252833;
  --accent: #8b93ff;
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Feature Section 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: Feature Section Timeline Layout Vertical
Source: https://codefronts.com/layouts/css-feature-sections/css-feature-section-timeline-layout/

Six features as milestones on a central rail, alternating left and right, with the rail drawn as a gradient so it fades at both ends instead of stopping abruptly. Built on an ordered list because the sequence is the content, and the whole thing collapses to a single left-aligned column on narrow screens without changing the marker positions.
## HTML
```html
<section class="fs-22" aria-labelledby="fs-22-heading">
  <div class="fs-22__hero" aria-hidden="true">
    <span class="fs-22__wordmark">quill/changelog</span>
    <span class="fs-22__heronav">shipped · in progress · planned</span>
  </div>

  <div class="fs-22__stage">
    <div class="fs-22__head">
      <p class="fs-22__eyebrow">Onboarding, in order</p>
      <h2 class="fs-22__heading" id="fs-22-heading">What your first fortnight with Quill looks like</h2>
      <p class="fs-22__sub">Six milestones. Most teams reach the last one inside two weeks.</p>
    </div>

    <ol class="fs-22__timeline">
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 1</p>
          <h3 class="fs-22__ctitle">Connect your first source</h3>
          <p class="fs-22__cprose">Paste a connection string, pick the schemas you care about, and the first sync completes while you finish the tour.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 2</p>
          <h3 class="fs-22__ctitle">Invite the team, map the roles</h3>
          <p class="fs-22__cprose">Directory groups become Quill roles, so analysts get write access to their own space and nothing else.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 4</p>
          <h3 class="fs-22__ctitle">Set freshness expectations</h3>
          <p class="fs-22__cprose">Declare an SLA per dataset. Breaches page the owner with the last good run attached, not a generic alert.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 7</p>
          <h3 class="fs-22__ctitle">Ship the first automation</h3>
          <p class="fs-22__cprose">Trigger on schema drift or a null spike, dry-run it against last week of real rows, then enable it.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 10</p>
          <h3 class="fs-22__ctitle">Wire the warehouse export</h3>
          <p class="fs-22__cprose">Incremental exports on a schedule, with a manifest your dbt project can depend on safely.</p>
        </article>
      </li>
      <li class="fs-22__item">
        <span class="fs-22__marker" aria-hidden="true"></span>
        <article class="fs-22__card">
          <p class="fs-22__stamp">Day 14</p>
          <h3 class="fs-22__ctitle">Hand over the runbook</h3>
          <p class="fs-22__cprose">Ownership, escalation and retention are documented from your actual configuration — nothing left to write up.</p>
        </article>
      </li>
    </ol>
  </div>

  <div class="fs-22__below" aria-hidden="true">Full changelog · Migration guide</div>
</section>
```
## CSS
```css
.fs-22 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #fbfbfc;
  --page: oklch(0.985 0.002 270);
  --card: #ffffff;
  --ink: #17181d;
  --ink: oklch(0.22 0.007 275);
  --muted: #5c6070;
  --muted: oklch(0.5 0.014 272);
  --rule: #e2e3e9;
  --rule: oklch(0.9 0.005 270);
  --accent: #4b56d9;
  --accent: oklch(0.5 0.19 272);
  --rail: 2px;
  --font-display: "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.fs-22 *,
.fs-22 *::before,
.fs-22 *::after {
  box-sizing: border-box;
}

.fs-22__hero {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  justify-content: space-between;
  align-items: baseline;
  padding: 14px 20px;
  background: var(--card);
  border-block-end: 1px solid var(--rule);
  font: 500 12px/1 var(--font-display);
  color: var(--muted);
}

.fs-22__wordmark {
  color: var(--ink);
}

.fs-22__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(28px, 5vw, 60px) 16px;
}

.fs-22__head {
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  margin: 0 0 clamp(24px, 4vw, 40px);
  text-align: center;
}

.fs-22__eyebrow {
  margin: 0 0 12px;
  font: 500 11px/1 var(--font-display);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}

.fs-22__heading {
  margin: 0 auto 10px;
  max-inline-size: 26ch;
  font: 600 clamp(25px, 4.6vw, 38px)/1.12 system-ui, sans-serif;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

.fs-22__sub {
  margin: 0 auto;
  max-inline-size: 48ch;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.fs-22__timeline {
  position: relative;
  inline-size: 100%;
  max-inline-size: 58rem;
  min-inline-size: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 20px;
}

.fs-22__timeline::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 50%;
  translate: -50% 0;
  inline-size: var(--rail);
  background: linear-gradient(180deg, transparent, var(--rule) 7%, var(--rule) 93%, transparent);
}

.fs-22__item {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 46px minmax(0, 1fr);
  align-items: center;
  min-inline-size: 0;
}

.fs-22__item > .fs-22__card {
  grid-column: 1;
  text-align: end;
}

.fs-22__item:nth-child(even) > .fs-22__card {
  grid-column: 3;
  text-align: start;
}

.fs-22__marker {
  grid-column: 2;
  justify-self: center;
  inline-size: 15px;
  block-size: 15px;
  border-radius: 50%;
  background: var(--card);
  border: var(--rail) solid var(--accent);
  box-shadow: 0 0 0 5px color-mix(in oklab, var(--accent) 12%, var(--page));
}

.fs-22__card {
  min-inline-size: 0;
  padding: 16px 18px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: 10px;
}

.fs-22__stamp {
  margin: 0 0 8px;
  font: 500 10.5px/1 var(--font-display);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}

.fs-22__ctitle {
  margin: 0 0 7px;
  font: 600 16px/1.3 system-ui, sans-serif;
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.fs-22__cprose {
  margin: 0;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.fs-22__below {
  padding: 14px 20px;
  background: var(--card);
  border-block-start: 1px solid var(--rule);
  font: 500 11.5px/1 var(--font-display);
  color: var(--muted);
}

@media (max-width: 700px) {
  .fs-22__timeline::before {
    inset-inline-start: 8px;
    translate: 0 0;
  }

  .fs-22__item {
    grid-template-columns: 26px minmax(0, 1fr);
  }

  .fs-22__item > .fs-22__card,
    .fs-22__item:nth-child(even) > .fs-22__card {
    grid-column: 2;
    text-align: start;
  }

  .fs-22__marker {
    grid-column: 1;
    justify-self: start;
    margin-inline-start: 1px;
  }
}

@media (prefers-color-scheme: dark) {
  .fs-22 {
    --page: #0e0f13;
    --card: #16181f;
    --ink: #eeeff4;
    --muted: #979cae;
    --rule: #252833;
    --accent: #8b93ff;
  }
}

[data-theme="dark"] .fs-22 {
  --page: #0e0f13;
  --card: #16181f;
  --ink: #eeeff4;
  --muted: #979cae;
  --rule: #252833;
  --accent: #8b93ff;
}
```

How this works

An ordered list is the right base. If reordering the items would change their meaning, the sequence is content and <ol> says so — a screen reader announces 'item 3 of 6' and the reading order matches the visual order. A stack of divs with absolutely positioned dots communicates nothing about progression, which is the whole point of choosing a timeline over a grid.

The rail is a gradient, not a border. A border-left or a solid pseudo-element stops dead at the first and last item, which reads as a broken line. A linear-gradient(transparent, rule 8%, rule 92%, transparent) on a 2px column fades in and out, so the timeline feels like a section of something longer — and it costs one element that can be positioned once and forgotten.

Alternation is one rule and one reset. Odd items take the left column and right-align their text; even items take the right column and left-align. Markers stay on the rail because they are positioned against the rail's column, not against the card — which is what keeps them perfectly aligned when a card's text wraps to a different height on each side.

The collapse must not move the rail. On narrow viewports, alternating sides leaves half the content squeezed into a 40% column, so the layout switches to a single column with the rail pinned to the inline start and every card on one side. Do this with a grid template change rather than by hiding the rail — a timeline without its rail is just a list, and the visual metaphor is the reason the section exists.

Make it yours

  • Add a milestone by adding one <li> — alternation is automatic.
  • Change the rail width and fade by editing its background gradient stops.
  • Retint a single milestone by setting --accent on that <li>.
  • Set the marker to a square with a rotation for a diamond node.
  • Move the collapse point by editing the one max-width query.
  • Drop the date stamps if your features are not dated — the layout does not depend on them.

Gotchas — read before shipping

  • A rail drawn as a border stops abruptly at the first and last item and looks like a rendering bug; use a faded gradient.
  • Markers positioned relative to each card drift out of line as soon as two facing cards have different heights — position them against the rail column.
  • Using divs instead of an <ol> loses the sequence semantics that justify a timeline in the first place.
  • Keeping the alternation below about 700px squeezes each card into a narrow column and forces four-word lines.
  • Hiding the rail on mobile removes the only thing that makes the section a timeline — collapse the columns and keep it.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

:has() and oklch() dictate the floor (:has() at Chromium 105 / Safari 15.4 / Firefox 121; oklch() at Chromium 111 / Safari 15.4 / Firefox 113). The central rail is a linear-gradient() rather than a border, which needs nothing newer; older engines get the same timeline with flat sRGB tints.

Techniques used in this demo

Search CodeFronts

Loading…