18 CSS Vertical Tabs05 / 18

Pure CSSMIT licensed

Vertical Tabs with Sliding Active Indicator Bar

The "magic line" effect — a single pill that glides between tabs instead of four separate highlights popping on and off — with no JavaScript measuring. A :has() lookup maps the checked radio to an index integer, and the marker rides translateY(calc(var(--i) * var(--step))) on a spring curve. One rule per tab, no ResizeObserver, no getBoundingClientRect.

Published

Live Demo
Try it

The code

<section class="vt-05" aria-label="Sliding active indicator vertical tabs demo">
  <div class="vt-05__stage">
    <div class="vt-05__head">
      <p class="vt-05__eyebrow">:has() → --i · translate · spring</p>
      <h2 class="vt-05__title">One marker, four stops</h2>
    </div>
    <div class="vt-05__card">
      <div class="vt-05__rail" role="radiogroup" aria-label="Release channels">
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t1" checked>
        <label class="vt-05__tab" for="vt-05-t1">Stable<small>v4.2.0</small></label>
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t2">
        <label class="vt-05__tab" for="vt-05-t2">Beta<small>v4.3.0-rc1</small></label>
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t3">
        <label class="vt-05__tab" for="vt-05-t3">Canary<small>nightly</small></label>
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t4">
        <label class="vt-05__tab" for="vt-05-t4">Legacy<small>v3.9.x</small></label>
      </div>
      <div class="vt-05__panels">
        <article class="vt-05__panel" id="vt-05-p1"><h3 class="vt-05__h">Position is arithmetic</h3><p class="vt-05__p">Fixed-height rows turn the indicator's offset into <code>index × step</code>. No measuring, no resize listener, no layout thrash.</p><p class="vt-05__chip">translate: 0 calc(var(--i) * var(--step))</p></article>
        <article class="vt-05__panel" id="vt-05-p2"><h3 class="vt-05__h">:has() reaches upward</h3><p class="vt-05__p">A checked descendant sets a custom property on its ancestor — the thing CSS could never do before 2023.</p><p class="vt-05__chip">.rail:has(#vt-05-t2:checked){--i:1}</p></article>
        <article class="vt-05__panel" id="vt-05-p3"><h3 class="vt-05__h">The curve is the product</h3><p class="vt-05__p">A 7% overshoot makes the pill feel like an object with mass. Linear easing makes it feel like a spreadsheet cursor.</p><p class="vt-05__chip">cubic-bezier(.34, 1.32, .44, 1)</p></article>
        <article class="vt-05__panel" id="vt-05-p4"><h3 class="vt-05__h">Degrades quietly</h3><p class="vt-05__p">Where <code>:has()</code> is missing the marker parks at the top and the checked tab keeps its own tint — no broken state.</p><p class="vt-05__chip">@supports selector(:has(*))</p></article>
      </div>
    </div>
  </div>
</section>
@property --vt-05-i {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

.vt-05 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-05-bg);
  --vt-05-bg: oklch(0.21 0.03 296);
  --vt-05-card: oklch(0.26 0.035 296);
  --vt-05-ink: oklch(0.97 0.01 296);
  --vt-05-mut: oklch(0.73 0.02 296);
  --vt-05-acc: oklch(0.76 0.17 322);
  --vt-05-acc2: oklch(0.7 0.17 268);
  --vt-05-line: oklch(1 0 0/.11);
  --vt-05-step: 56px;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-05-ink);
}

.vt-05 *,
.vt-05 *::before,
.vt-05 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.vt-05__stage {
  min-height: 100svh;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: clamp(18px,3vw,30px);
  padding: clamp(20px,5vw,64px);
  background: radial-gradient(75% 55% at 70% 10%,oklch(0.38 0.11 320/.5),transparent),radial-gradient(60% 50% at 20% 90%,oklch(0.36 0.1 270/.45),transparent);
}

.vt-05__head {
  display: grid;
  justify-items: center;
  gap: 10px;
  text-align: center;
}

.vt-05__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--vt-05-acc);
}

.vt-05__title {
  font-size: clamp(24px,3.6vw,38px);
  line-height: 1.08;
  letter-spacing: -.028em;
  font-weight: 700;
  text-wrap: balance;
}

.vt-05__card {
  width: min(94vw,860px);
  display: grid;
  grid-template-columns: clamp(170px,28%,246px) 1fr;
  background: var(--vt-05-card);
  border: 1px solid var(--vt-05-line);
  border-radius: 20px;
  box-shadow: 0 28px 74px -36px oklch(0 0 0/.75);
  overflow: hidden;
}

.vt-05__rail {
  --vt-05-i: 0;
  position: relative;
  display: grid;
  align-content: start;
  padding: 14px 12px;
  border-right: 1px solid var(--vt-05-line);
  background: oklch(0 0 0/.14);
}

.vt-05__rail::before {
  content: "";
  position: absolute;
  z-index: 0;
  inset-block-start: 14px;
  inset-inline: 12px;
  block-size: var(--vt-05-step);
  border-radius: 13px;
  background: linear-gradient(120deg,color-mix(in oklch,var(--vt-05-acc) 26%,transparent),color-mix(in oklch,var(--vt-05-acc2) 26%,transparent));
  border: 1px solid color-mix(in oklch,var(--vt-05-acc) 42%,transparent);
  box-shadow: 0 8px 22px -10px color-mix(in oklch,var(--vt-05-acc) 70%,transparent);
  pointer-events: none;
  translate: 0 calc(var(--vt-05-i) * var(--vt-05-step));
  transition: translate .42s cubic-bezier(.34,1.32,.44,1);
}

.vt-05__rail:has(#vt-05-t2:checked) {
  --vt-05-i: 1;
}

.vt-05__rail:has(#vt-05-t3:checked) {
  --vt-05-i: 2;
}

.vt-05__rail:has(#vt-05-t4:checked) {
  --vt-05-i: 3;
}

.vt-05__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.vt-05__tab {
  position: relative;
  z-index: 1;
  display: grid;
  align-content: center;
  gap: 2px;
  block-size: var(--vt-05-step);
  padding: 0 15px;
  border-radius: 13px;
  font-size: 14.6px;
  font-weight: 650;
  color: var(--vt-05-mut);
  cursor: pointer;
  transition: color .24s ease;
}

.vt-05__tab small {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 10.5px;
  font-weight: 500;
  opacity: .72;
}

.vt-05__tab:hover {
  color: var(--vt-05-ink);
}

.vt-05__radio:checked+.vt-05__tab {
  color: var(--vt-05-ink);
}

.vt-05__radio:focus-visible+.vt-05__tab {
  outline: 2px solid var(--vt-05-acc);
  outline-offset: -2px;
}

.vt-05__panels {
  display: grid;
  min-height: clamp(250px,33vw,300px);
  padding: clamp(22px,3.6vw,42px);
}

.vt-05__panel {
  grid-area: 1/1;
  align-content: center;
  display: grid;
  gap: 12px;
  justify-items: start;
  opacity: 0;
  visibility: hidden;
  transform: translateY(9px);
  transition: opacity .3s ease,transform .36s cubic-bezier(.32,.72,.3,1),visibility 0s .36s;
}

.vt-05__card:has(#vt-05-t1:checked) #vt-05-p1,
.vt-05__card:has(#vt-05-t2:checked) #vt-05-p2,
.vt-05__card:has(#vt-05-t3:checked) #vt-05-p3,
.vt-05__card:has(#vt-05-t4:checked) #vt-05-p4 {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.vt-05__h {
  font-size: clamp(19px,2.4vw,25px);
  line-height: 1.2;
  letter-spacing: -.022em;
  font-weight: 680;
  text-wrap: balance;
}

.vt-05__p {
  max-width: 44ch;
  font-size: 14.8px;
  line-height: 1.64;
  color: var(--vt-05-mut);
  text-wrap: pretty;
}

.vt-05__p code,
.vt-05__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
}

.vt-05__p code {
  font-size: .85em;
  background: oklch(1 0 0/.09);
  padding: 2px 6px;
  border-radius: 5px;
}

.vt-05__chip {
  font-size: 11px;
  color: var(--vt-05-mut);
  padding: 7px 13px;
  border: 1px solid var(--vt-05-line);
  border-radius: 99px;
  overflow-wrap: anywhere;
}

@supports not selector(:has(*)) {
  .vt-05__rail::before {
    opacity: 0;
  }

  .vt-05__radio:checked+.vt-05__tab {
    background: color-mix(in oklch,var(--vt-05-acc) 22%,transparent);
  }
}

@media (max-width: 640px) {
  .vt-05__card {
    grid-template-columns: 1fr;
  }

  .vt-05__rail {
    border-right: 0;
    border-bottom: 1px solid var(--vt-05-line);
  }
}

@media (prefers-reduced-motion: reduce) {
  .vt-05 * {
    transition-duration: .01ms !important;
  }
}
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 Vertical Tab 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: Vertical Tabs with Sliding Active Indicator Bar
Source: https://codefronts.com/navigation/css-vertical-tabs/vertical-tabs-with-sliding-active-indicator-bar/

The "magic line" effect — a single pill that glides between tabs instead of four separate highlights popping on and off — with no JavaScript measuring. A :has() lookup maps the checked radio to an index integer, and the marker rides translateY(calc(var(--i) * var(--step))) on a spring curve. One rule per tab, no ResizeObserver, no getBoundingClientRect.
## HTML
```html
<section class="vt-05" aria-label="Sliding active indicator vertical tabs demo">
  <div class="vt-05__stage">
    <div class="vt-05__head">
      <p class="vt-05__eyebrow">:has() → --i · translate · spring</p>
      <h2 class="vt-05__title">One marker, four stops</h2>
    </div>
    <div class="vt-05__card">
      <div class="vt-05__rail" role="radiogroup" aria-label="Release channels">
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t1" checked>
        <label class="vt-05__tab" for="vt-05-t1">Stable<small>v4.2.0</small></label>
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t2">
        <label class="vt-05__tab" for="vt-05-t2">Beta<small>v4.3.0-rc1</small></label>
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t3">
        <label class="vt-05__tab" for="vt-05-t3">Canary<small>nightly</small></label>
        <input class="vt-05__radio" type="radio" name="vt-05" id="vt-05-t4">
        <label class="vt-05__tab" for="vt-05-t4">Legacy<small>v3.9.x</small></label>
      </div>
      <div class="vt-05__panels">
        <article class="vt-05__panel" id="vt-05-p1"><h3 class="vt-05__h">Position is arithmetic</h3><p class="vt-05__p">Fixed-height rows turn the indicator's offset into <code>index × step</code>. No measuring, no resize listener, no layout thrash.</p><p class="vt-05__chip">translate: 0 calc(var(--i) * var(--step))</p></article>
        <article class="vt-05__panel" id="vt-05-p2"><h3 class="vt-05__h">:has() reaches upward</h3><p class="vt-05__p">A checked descendant sets a custom property on its ancestor — the thing CSS could never do before 2023.</p><p class="vt-05__chip">.rail:has(#vt-05-t2:checked){--i:1}</p></article>
        <article class="vt-05__panel" id="vt-05-p3"><h3 class="vt-05__h">The curve is the product</h3><p class="vt-05__p">A 7% overshoot makes the pill feel like an object with mass. Linear easing makes it feel like a spreadsheet cursor.</p><p class="vt-05__chip">cubic-bezier(.34, 1.32, .44, 1)</p></article>
        <article class="vt-05__panel" id="vt-05-p4"><h3 class="vt-05__h">Degrades quietly</h3><p class="vt-05__p">Where <code>:has()</code> is missing the marker parks at the top and the checked tab keeps its own tint — no broken state.</p><p class="vt-05__chip">@supports selector(:has(*))</p></article>
      </div>
    </div>
  </div>
</section>
```
## CSS
```css
@property --vt-05-i {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

.vt-05 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-05-bg);
  --vt-05-bg: oklch(0.21 0.03 296);
  --vt-05-card: oklch(0.26 0.035 296);
  --vt-05-ink: oklch(0.97 0.01 296);
  --vt-05-mut: oklch(0.73 0.02 296);
  --vt-05-acc: oklch(0.76 0.17 322);
  --vt-05-acc2: oklch(0.7 0.17 268);
  --vt-05-line: oklch(1 0 0/.11);
  --vt-05-step: 56px;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-05-ink);
}

.vt-05 *,
.vt-05 *::before,
.vt-05 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.vt-05__stage {
  min-height: 100svh;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: clamp(18px,3vw,30px);
  padding: clamp(20px,5vw,64px);
  background: radial-gradient(75% 55% at 70% 10%,oklch(0.38 0.11 320/.5),transparent),radial-gradient(60% 50% at 20% 90%,oklch(0.36 0.1 270/.45),transparent);
}

.vt-05__head {
  display: grid;
  justify-items: center;
  gap: 10px;
  text-align: center;
}

.vt-05__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--vt-05-acc);
}

.vt-05__title {
  font-size: clamp(24px,3.6vw,38px);
  line-height: 1.08;
  letter-spacing: -.028em;
  font-weight: 700;
  text-wrap: balance;
}

.vt-05__card {
  width: min(94vw,860px);
  display: grid;
  grid-template-columns: clamp(170px,28%,246px) 1fr;
  background: var(--vt-05-card);
  border: 1px solid var(--vt-05-line);
  border-radius: 20px;
  box-shadow: 0 28px 74px -36px oklch(0 0 0/.75);
  overflow: hidden;
}

.vt-05__rail {
  --vt-05-i: 0;
  position: relative;
  display: grid;
  align-content: start;
  padding: 14px 12px;
  border-right: 1px solid var(--vt-05-line);
  background: oklch(0 0 0/.14);
}

.vt-05__rail::before {
  content: "";
  position: absolute;
  z-index: 0;
  inset-block-start: 14px;
  inset-inline: 12px;
  block-size: var(--vt-05-step);
  border-radius: 13px;
  background: linear-gradient(120deg,color-mix(in oklch,var(--vt-05-acc) 26%,transparent),color-mix(in oklch,var(--vt-05-acc2) 26%,transparent));
  border: 1px solid color-mix(in oklch,var(--vt-05-acc) 42%,transparent);
  box-shadow: 0 8px 22px -10px color-mix(in oklch,var(--vt-05-acc) 70%,transparent);
  pointer-events: none;
  translate: 0 calc(var(--vt-05-i) * var(--vt-05-step));
  transition: translate .42s cubic-bezier(.34,1.32,.44,1);
}

.vt-05__rail:has(#vt-05-t2:checked) {
  --vt-05-i: 1;
}

.vt-05__rail:has(#vt-05-t3:checked) {
  --vt-05-i: 2;
}

.vt-05__rail:has(#vt-05-t4:checked) {
  --vt-05-i: 3;
}

.vt-05__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.vt-05__tab {
  position: relative;
  z-index: 1;
  display: grid;
  align-content: center;
  gap: 2px;
  block-size: var(--vt-05-step);
  padding: 0 15px;
  border-radius: 13px;
  font-size: 14.6px;
  font-weight: 650;
  color: var(--vt-05-mut);
  cursor: pointer;
  transition: color .24s ease;
}

.vt-05__tab small {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 10.5px;
  font-weight: 500;
  opacity: .72;
}

.vt-05__tab:hover {
  color: var(--vt-05-ink);
}

.vt-05__radio:checked+.vt-05__tab {
  color: var(--vt-05-ink);
}

.vt-05__radio:focus-visible+.vt-05__tab {
  outline: 2px solid var(--vt-05-acc);
  outline-offset: -2px;
}

.vt-05__panels {
  display: grid;
  min-height: clamp(250px,33vw,300px);
  padding: clamp(22px,3.6vw,42px);
}

.vt-05__panel {
  grid-area: 1/1;
  align-content: center;
  display: grid;
  gap: 12px;
  justify-items: start;
  opacity: 0;
  visibility: hidden;
  transform: translateY(9px);
  transition: opacity .3s ease,transform .36s cubic-bezier(.32,.72,.3,1),visibility 0s .36s;
}

.vt-05__card:has(#vt-05-t1:checked) #vt-05-p1,
.vt-05__card:has(#vt-05-t2:checked) #vt-05-p2,
.vt-05__card:has(#vt-05-t3:checked) #vt-05-p3,
.vt-05__card:has(#vt-05-t4:checked) #vt-05-p4 {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.vt-05__h {
  font-size: clamp(19px,2.4vw,25px);
  line-height: 1.2;
  letter-spacing: -.022em;
  font-weight: 680;
  text-wrap: balance;
}

.vt-05__p {
  max-width: 44ch;
  font-size: 14.8px;
  line-height: 1.64;
  color: var(--vt-05-mut);
  text-wrap: pretty;
}

.vt-05__p code,
.vt-05__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
}

.vt-05__p code {
  font-size: .85em;
  background: oklch(1 0 0/.09);
  padding: 2px 6px;
  border-radius: 5px;
}

.vt-05__chip {
  font-size: 11px;
  color: var(--vt-05-mut);
  padding: 7px 13px;
  border: 1px solid var(--vt-05-line);
  border-radius: 99px;
  overflow-wrap: anywhere;
}

@supports not selector(:has(*)) {
  .vt-05__rail::before {
    opacity: 0;
  }

  .vt-05__radio:checked+.vt-05__tab {
    background: color-mix(in oklch,var(--vt-05-acc) 22%,transparent);
  }
}

@media (max-width: 640px) {
  .vt-05__card {
    grid-template-columns: 1fr;
  }

  .vt-05__rail {
    border-right: 0;
    border-bottom: 1px solid var(--vt-05-line);
  }
}

@media (prefers-reduced-motion: reduce) {
  .vt-05 * {
    transition-duration: .01ms !important;
  }
}
```

How this works

Sliding indicators are usually JavaScript: measure the active element, write top and height inline, re-measure on resize. All of that disappears if the tabs are a fixed-height rhythm, because then position is arithmetic:

.rail{ --i:0; --step:52px; position:relative }
.rail::before{ position:absolute; inset-block-start:0; block-size:var(--step);
  translate:0 calc(var(--i) * var(--step));
  transition:translate .42s cubic-bezier(.34,1.32,.44,1); }

.rail:has(#t2:checked){ --i:1 }
.rail:has(#t3:checked){ --i:2 }

:has() is what makes this possible in pure CSS — it lets a descendant's :checked state set a custom property on the ancestor that owns the marker. Before :has() you had to place the marker after the inputs and reach it with ~, which constrained the DOM badly. Now the marker can be a pseudo-element on the rail itself.

Register the index with @property { syntax:'<integer>' } and it becomes an animatable, type-checked value; a bare custom property is an unanimatable token string, so only the resulting translate transitions. Either way the movement is a compositor transform — never animate top, which triggers layout on every frame.

The easing is the whole personality. cubic-bezier(.34,1.32,.44,1) overshoots ~7% and settles, which reads as physical; a linear or ease-in-out marker reads as a spreadsheet. Pair it with a subtle scaleY squash on the marker during transit and it feels like a real object.

Make it yours

  • Variable-height tabs break the arithmetic. Either keep a fixed --step, or hand-write per-index offsets (:has(#t3:checked){--y:118px}) — still zero JS.
  • Horizontal version: swap translate:0 calc(...) for translate:calc(...) 0 and --step becomes tab width. Same nine lines.
  • Marker style: it's just a box — try a 3px rail line, a full-width tinted pill, an outlined ghost, or a gradient bar with filter:blur(14px) behind it for a glow.
  • Motion feel: .42s cubic-bezier(.34,1.32,.44,1) is springy; .24s cubic-bezier(.4,0,.2,1) is corporate-crisp. Only the curve changes.

Gotchas — read before shipping

  • Animate translate/transform, never top or margin-top — the latter re-layouts the whole rail 60 times a second.
  • The arithmetic assumes every tab is exactly --step tall. A single wrapped label breaks alignment; enforce the height or switch to explicit offsets.
  • :has() is not supported in Firefox before 121. Ship a fallback that gives the checked tab its own background so the component still reads correctly without the slide.
  • Without @property, transitioning --i itself does nothing (it's a token, not a number) — put the transition on translate, which is what actually moves.
  • Give the marker pointer-events:none, or it intercepts clicks on the tab it currently covers.

Browser support

ChromeSafariFirefoxEdge
114+17.5+128+114+

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

:has() shipped Chrome 105 / Safari 15.4 / Firefox 121 (Dec 2023). @property is Chrome 85 / Safari 16.4 / Firefox 128. Without either, the marker simply doesn't slide — the checked-tab styling still works.

Techniques used in this demo

Search CodeFronts

Loading…