18 CSS Vertical Tabs13 / 18

Pure CSSMIT licensed

Vertical Tabs with :has() Active State (No JS)

What the parent selector actually unlocks: not just a nicer way to write the radio trick, but a component whose whole environment reacts to the selected tab. Choosing a tab here re-themes the card, moves a spotlight gradient, restyles the header and swaps the accent — every one of those a rule CSS physically could not write before :has().

Published

Live Demo
Try it

The code

<section class="vt-13" aria-label="CSS :has() active state vertical tabs demo">
  <div class="vt-13__stage">
    <div class="vt-13__card" role="radiogroup" aria-label="Studio disciplines">
      <header class="vt-13__header">
        <p class="vt-13__eyebrow">:has() · the parent selector</p>
        <h2 class="vt-13__title">The container reacts, not just the panel</h2>
      </header>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t1" checked>
      <label class="vt-13__tab" for="vt-13-t1"><i aria-hidden="true"></i>Research</label>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t2">
      <label class="vt-13__tab" for="vt-13-t2"><i aria-hidden="true"></i>Interface</label>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t3">
      <label class="vt-13__tab" for="vt-13-t3"><i aria-hidden="true"></i>Motion</label>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t4">
      <label class="vt-13__tab" for="vt-13-t4"><i aria-hidden="true"></i>Systems</label>
      <div class="vt-13__panels">
        <article class="vt-13__panel" id="vt-13-p1"><h3 class="vt-13__h">State that cascades</h3><p class="vt-13__p">One rule sets <code>--accent</code> on the card. The rail, the glow, the border and this caption all re-theme from it — no per-element selectors.</p><p class="vt-13__chip">.card:has(#t1:checked){--accent:…}</p></article>
        <article class="vt-13__panel" id="vt-13-p2"><h3 class="vt-13__h">DOM order stops mattering</h3><p class="vt-13__p">The sibling trick could only look forward. <code>:has()</code> reads upward, so the rail may sit anywhere in the source and still drive the layout.</p><p class="vt-13__chip">no ~ combinator required</p></article>
        <article class="vt-13__panel" id="vt-13-p3"><h3 class="vt-13__h">Mind the specificity</h3><p class="vt-13__p"><code>:has()</code> inherits the weight of its heaviest argument, so <code>:has(#id:checked)</code> carries ID specificity. Plan for it instead of reaching for <code>!important</code>.</p><p class="vt-13__chip">specificity = heaviest argument</p></article>
        <article class="vt-13__panel" id="vt-13-p4"><h3 class="vt-13__h">Fast enough, scoped</h3><p class="vt-13__p">Bounded subtree matching is cheap. The expensive shape is a deep, unscoped argument evaluated against the document root.</p><p class="vt-13__chip">avoid :has(.a .b .c) at :root</p></article>
      </div>
    </div>
  </div>
</section>
.vt-13 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-13-bg);
  --vt-13-bg: oklch(0.17 0.02 270);
  --vt-13-ink: oklch(0.97 0.008 270);
  --vt-13-mut: oklch(0.71 0.017 270);
  --vt-13-line: oklch(1 0 0/.11);
  --vt-13-acc: oklch(0.78 0.15 195);
  --vt-13-spot: 18%;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-13-ink);
}

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

.vt-13__stage {
  min-height: 100svh;
  display: grid;
  place-content: center;
  justify-items: center;
  padding: clamp(18px,4vw,60px);
}

.vt-13__card {
  position: relative;
  width: min(96vw,900px);
  display: grid;
  grid-template-columns: minmax(160px,232px) minmax(0,1fr);
  grid-template-rows: auto repeat(4,auto) 1fr;
  background: linear-gradient(180deg,oklch(0.225 0.024 270),oklch(0.2 0.022 270));
  border: 1px solid color-mix(in oklch,var(--vt-13-acc) 26%,var(--vt-13-line));
  border-radius: 22px;
  box-shadow: 0 30px 80px -40px oklch(0 0 0/.9),0 0 60px -30px color-mix(in oklch,var(--vt-13-acc) 60%,transparent);
  overflow: hidden;
  transition: border-color .5s ease,box-shadow .5s ease;
}

.vt-13__card::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(50% 60% at var(--vt-13-spot) 0%,color-mix(in oklch,var(--vt-13-acc) 26%,transparent),transparent 70%);
  transition: background .55s cubic-bezier(.32,.72,.3,1);
}

.vt-13__card:has(#vt-13-t1:checked) {
  --vt-13-acc: oklch(0.78 0.15 195);
  --vt-13-spot: 14%;
}

.vt-13__card:has(#vt-13-t2:checked) {
  --vt-13-acc: oklch(0.76 0.17 42);
  --vt-13-spot: 36%;
}

.vt-13__card:has(#vt-13-t3:checked) {
  --vt-13-acc: oklch(0.74 0.19 328);
  --vt-13-spot: 58%;
}

.vt-13__card:has(#vt-13-t4:checked) {
  --vt-13-acc: oklch(0.82 0.17 138);
  --vt-13-spot: 80%;
}

.vt-13__header {
  position: relative;
  z-index: 1;
  grid-column: 1/-1;
  display: grid;
  gap: 7px;
  padding: clamp(20px,3vw,30px) clamp(22px,3.4vw,36px);
  border-bottom: 1px solid var(--vt-13-line);
}

.vt-13__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--vt-13-acc);
  transition: color .45s ease;
}

.vt-13__title {
  font-size: clamp(21px,3vw,30px);
  line-height: 1.1;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

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

.vt-13__tab {
  position: relative;
  z-index: 1;
  grid-column: 1;
  display: flex;
  align-items: center;
  gap: 11px;
  min-height: 56px;
  padding: 0 18px;
  font-size: 14.6px;
  font-weight: 640;
  color: var(--vt-13-mut);
  cursor: pointer;
  border-right: 1px solid var(--vt-13-line);
  transition: color .24s ease,background .24s ease;
}

.vt-13__tab i {
  inline-size: 8px;
  block-size: 8px;
  flex: none;
  border-radius: 99px;
  background: currentColor;
  opacity: .35;
  transition: opacity .24s ease,box-shadow .3s ease;
}

.vt-13__tab:hover {
  color: var(--vt-13-ink);
  background: oklch(1 0 0/.05);
}

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

.vt-13__radio:checked+.vt-13__tab i {
  opacity: 1;
  box-shadow: 0 0 0 4px color-mix(in oklch,var(--vt-13-acc) 22%,transparent);
}

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

.vt-13__panels {
  position: relative;
  z-index: 1;
  grid-column: 2;
  grid-row: 2/-1;
  display: grid;
  min-height: clamp(240px,30vw,282px);
  padding: clamp(22px,3.4vw,40px);
}

.vt-13__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-13__radio:nth-of-type(1):checked~.vt-13__panels #vt-13-p1,
.vt-13__radio:nth-of-type(2):checked~.vt-13__panels #vt-13-p2,
.vt-13__radio:nth-of-type(3):checked~.vt-13__panels #vt-13-p3,
.vt-13__radio:nth-of-type(4):checked~.vt-13__panels #vt-13-p4 {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.vt-13__h {
  font-size: clamp(19px,2.4vw,26px);
  line-height: 1.18;
  letter-spacing: -.024em;
  font-weight: 690;
  text-wrap: balance;
}

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

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

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

.vt-13__chip {
  font-size: 11px;
  padding: 7px 13px;
  border-radius: 99px;
  color: var(--vt-13-acc);
  background: color-mix(in oklch,var(--vt-13-acc) 11%,transparent);
  border: 1px solid color-mix(in oklch,var(--vt-13-acc) 28%,transparent);
  overflow-wrap: anywhere;
  transition: color .45s ease,background .45s ease,border-color .45s ease;
}

@supports not selector(:has(*)) {
  .vt-13__card::before {
    background: radial-gradient(50% 60% at 18% 0%,color-mix(in oklch,var(--vt-13-acc) 22%,transparent),transparent 70%);
  }
}

@media (max-width: 680px) {
  .vt-13__card {
    grid-template-columns: 1fr;
    grid-template-rows: auto repeat(4,auto) auto;
  }

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

  .vt-13__panels {
    grid-column: 1;
    grid-row: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .vt-13 * {
    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 :has() Active State (No JS)
Source: https://codefronts.com/navigation/css-vertical-tabs/vertical-tabs-with-has-active-state-no-js/

What the parent selector actually unlocks: not just a nicer way to write the radio trick, but a component whose whole environment reacts to the selected tab. Choosing a tab here re-themes the card, moves a spotlight gradient, restyles the header and swaps the accent — every one of those a rule CSS physically could not write before :has().
## HTML
```html
<section class="vt-13" aria-label="CSS :has() active state vertical tabs demo">
  <div class="vt-13__stage">
    <div class="vt-13__card" role="radiogroup" aria-label="Studio disciplines">
      <header class="vt-13__header">
        <p class="vt-13__eyebrow">:has() · the parent selector</p>
        <h2 class="vt-13__title">The container reacts, not just the panel</h2>
      </header>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t1" checked>
      <label class="vt-13__tab" for="vt-13-t1"><i aria-hidden="true"></i>Research</label>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t2">
      <label class="vt-13__tab" for="vt-13-t2"><i aria-hidden="true"></i>Interface</label>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t3">
      <label class="vt-13__tab" for="vt-13-t3"><i aria-hidden="true"></i>Motion</label>
      <input class="vt-13__radio" type="radio" name="vt-13" id="vt-13-t4">
      <label class="vt-13__tab" for="vt-13-t4"><i aria-hidden="true"></i>Systems</label>
      <div class="vt-13__panels">
        <article class="vt-13__panel" id="vt-13-p1"><h3 class="vt-13__h">State that cascades</h3><p class="vt-13__p">One rule sets <code>--accent</code> on the card. The rail, the glow, the border and this caption all re-theme from it — no per-element selectors.</p><p class="vt-13__chip">.card:has(#t1:checked){--accent:…}</p></article>
        <article class="vt-13__panel" id="vt-13-p2"><h3 class="vt-13__h">DOM order stops mattering</h3><p class="vt-13__p">The sibling trick could only look forward. <code>:has()</code> reads upward, so the rail may sit anywhere in the source and still drive the layout.</p><p class="vt-13__chip">no ~ combinator required</p></article>
        <article class="vt-13__panel" id="vt-13-p3"><h3 class="vt-13__h">Mind the specificity</h3><p class="vt-13__p"><code>:has()</code> inherits the weight of its heaviest argument, so <code>:has(#id:checked)</code> carries ID specificity. Plan for it instead of reaching for <code>!important</code>.</p><p class="vt-13__chip">specificity = heaviest argument</p></article>
        <article class="vt-13__panel" id="vt-13-p4"><h3 class="vt-13__h">Fast enough, scoped</h3><p class="vt-13__p">Bounded subtree matching is cheap. The expensive shape is a deep, unscoped argument evaluated against the document root.</p><p class="vt-13__chip">avoid :has(.a .b .c) at :root</p></article>
      </div>
    </div>
  </div>
</section>
```
## CSS
```css
.vt-13 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-13-bg);
  --vt-13-bg: oklch(0.17 0.02 270);
  --vt-13-ink: oklch(0.97 0.008 270);
  --vt-13-mut: oklch(0.71 0.017 270);
  --vt-13-line: oklch(1 0 0/.11);
  --vt-13-acc: oklch(0.78 0.15 195);
  --vt-13-spot: 18%;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-13-ink);
}

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

.vt-13__stage {
  min-height: 100svh;
  display: grid;
  place-content: center;
  justify-items: center;
  padding: clamp(18px,4vw,60px);
}

.vt-13__card {
  position: relative;
  width: min(96vw,900px);
  display: grid;
  grid-template-columns: minmax(160px,232px) minmax(0,1fr);
  grid-template-rows: auto repeat(4,auto) 1fr;
  background: linear-gradient(180deg,oklch(0.225 0.024 270),oklch(0.2 0.022 270));
  border: 1px solid color-mix(in oklch,var(--vt-13-acc) 26%,var(--vt-13-line));
  border-radius: 22px;
  box-shadow: 0 30px 80px -40px oklch(0 0 0/.9),0 0 60px -30px color-mix(in oklch,var(--vt-13-acc) 60%,transparent);
  overflow: hidden;
  transition: border-color .5s ease,box-shadow .5s ease;
}

.vt-13__card::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(50% 60% at var(--vt-13-spot) 0%,color-mix(in oklch,var(--vt-13-acc) 26%,transparent),transparent 70%);
  transition: background .55s cubic-bezier(.32,.72,.3,1);
}

.vt-13__card:has(#vt-13-t1:checked) {
  --vt-13-acc: oklch(0.78 0.15 195);
  --vt-13-spot: 14%;
}

.vt-13__card:has(#vt-13-t2:checked) {
  --vt-13-acc: oklch(0.76 0.17 42);
  --vt-13-spot: 36%;
}

.vt-13__card:has(#vt-13-t3:checked) {
  --vt-13-acc: oklch(0.74 0.19 328);
  --vt-13-spot: 58%;
}

.vt-13__card:has(#vt-13-t4:checked) {
  --vt-13-acc: oklch(0.82 0.17 138);
  --vt-13-spot: 80%;
}

.vt-13__header {
  position: relative;
  z-index: 1;
  grid-column: 1/-1;
  display: grid;
  gap: 7px;
  padding: clamp(20px,3vw,30px) clamp(22px,3.4vw,36px);
  border-bottom: 1px solid var(--vt-13-line);
}

.vt-13__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--vt-13-acc);
  transition: color .45s ease;
}

.vt-13__title {
  font-size: clamp(21px,3vw,30px);
  line-height: 1.1;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

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

.vt-13__tab {
  position: relative;
  z-index: 1;
  grid-column: 1;
  display: flex;
  align-items: center;
  gap: 11px;
  min-height: 56px;
  padding: 0 18px;
  font-size: 14.6px;
  font-weight: 640;
  color: var(--vt-13-mut);
  cursor: pointer;
  border-right: 1px solid var(--vt-13-line);
  transition: color .24s ease,background .24s ease;
}

.vt-13__tab i {
  inline-size: 8px;
  block-size: 8px;
  flex: none;
  border-radius: 99px;
  background: currentColor;
  opacity: .35;
  transition: opacity .24s ease,box-shadow .3s ease;
}

.vt-13__tab:hover {
  color: var(--vt-13-ink);
  background: oklch(1 0 0/.05);
}

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

.vt-13__radio:checked+.vt-13__tab i {
  opacity: 1;
  box-shadow: 0 0 0 4px color-mix(in oklch,var(--vt-13-acc) 22%,transparent);
}

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

.vt-13__panels {
  position: relative;
  z-index: 1;
  grid-column: 2;
  grid-row: 2/-1;
  display: grid;
  min-height: clamp(240px,30vw,282px);
  padding: clamp(22px,3.4vw,40px);
}

.vt-13__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-13__radio:nth-of-type(1):checked~.vt-13__panels #vt-13-p1,
.vt-13__radio:nth-of-type(2):checked~.vt-13__panels #vt-13-p2,
.vt-13__radio:nth-of-type(3):checked~.vt-13__panels #vt-13-p3,
.vt-13__radio:nth-of-type(4):checked~.vt-13__panels #vt-13-p4 {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.vt-13__h {
  font-size: clamp(19px,2.4vw,26px);
  line-height: 1.18;
  letter-spacing: -.024em;
  font-weight: 690;
  text-wrap: balance;
}

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

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

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

.vt-13__chip {
  font-size: 11px;
  padding: 7px 13px;
  border-radius: 99px;
  color: var(--vt-13-acc);
  background: color-mix(in oklch,var(--vt-13-acc) 11%,transparent);
  border: 1px solid color-mix(in oklch,var(--vt-13-acc) 28%,transparent);
  overflow-wrap: anywhere;
  transition: color .45s ease,background .45s ease,border-color .45s ease;
}

@supports not selector(:has(*)) {
  .vt-13__card::before {
    background: radial-gradient(50% 60% at 18% 0%,color-mix(in oklch,var(--vt-13-acc) 22%,transparent),transparent 70%);
  }
}

@media (max-width: 680px) {
  .vt-13__card {
    grid-template-columns: 1fr;
    grid-template-rows: auto repeat(4,auto) auto;
  }

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

  .vt-13__panels {
    grid-column: 1;
    grid-row: auto;
  }
}

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

How this works

The old sibling technique could only ever style things that came after the input. :has() reads state upward and outward:

/* old: forward-only, DOM-order dependent */
#t2:checked ~ .panels #p2{ … }

/* new: the ancestor itself responds */
.card:has(#t2:checked){ --accent:oklch(.72 .17 40); --spot:32% }
.card:has(#t2:checked) .header{ … }
.card:has(#t2:checked) .rail::before{ … }

Set custom properties in the :has() rule and every descendant re-themes at once — the tab, the panel, the glow, the border, the caption — from a single line per tab. That is the real upgrade: state that cascades instead of state you have to re-select for each element.

Three more things it buys you here. :has() is unforgiving in a useful way: it is not affected by DOM order, so the tab rail can sit after the panels in the source (better for reading order) and still control them. It composes with other selectors — .card:has(#t3:checked):hover is legal. And it is not specificity-neutral: :has() takes the specificity of its most specific argument, so :has(#t2:checked) carries an ID's weight and will beat most of your other rules — plan for that rather than fighting it with !important.

Performance is a fair question, and the answer for this shape is: fine. Engines optimise :has() against a bounded subtree; the pattern to avoid is an unscoped, deep descendant match like :has(.x .y .z) evaluated against the document root.

Make it yours

  • Per-tab imagery: add background-image to the same :has() rule and the whole card changes scene per tab.
  • Ambient glow: --spot-x per tab drives a radial gradient position, so the light source follows the selection.
  • Conditional layout: .card:has(#t4:checked){grid-template-columns:1fr} lets one specific tab go full-bleed.
  • Form-aware chrome: .card:has(:invalid) or :has(:focus-visible) restyles the container the moment a nested control needs attention — the same mechanism, different trigger.

Gotchas — read before shipping

  • :has() takes the specificity of its most specific argument. :has(#id) is ID-weight and will silently outrank later rules you expected to win.
  • It is a forgiving selector list: an unsupported argument inside :has() invalidates only that argument, not the whole rule — great for resilience, confusing when debugging.
  • Firefox only shipped :has() in 121 (Dec 2023). Guard enhancements with @supports selector(:has(*)) and make the default state the correct fallback.
  • Avoid unscoped, deep arguments (:has(.a .b .c)) matched at the root — that is the shape that actually costs style-recalculation time.
  • :has() cannot be nested inside another :has(), and it cannot match pseudo-elements. Both fail silently.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

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

:has() — Chrome 105, Edge 105, Safari 15.4, Firefox 121. Global support is now above 93%. The @supports guard here keeps the component fully usable in anything older.

Techniques used in this demo

Search CodeFronts

Loading…