18 CSS Vertical Tabs15 / 18

Pure CSSMIT licensed

Vertical Tabs with <details name=""> Exclusive Group

The feature that finally deleted the accordion script: give a group of <details> elements the same name and the browser enforces one-open-at-a-time, exactly like radio buttons. Paired with ::details-content, interpolate-size and @starting-style, you get a fully animated exclusive tab stack in pure HTML and CSS.

Published

Live Demo
Try it

The code

<section class="vt-15" aria-label="Exclusive details group vertical tabs demo">
  <div class="vt-15__stage">
    <div class="vt-15__head">
      <p class="vt-15__eyebrow">&lt;details name&gt; · ::details-content · @starting-style</p>
      <h2 class="vt-15__title">Exclusive tabs in four attributes</h2>
    </div>
    <div class="vt-15__set">
      <details class="vt-15__item" name="vt-15" open>
        <summary class="vt-15__sum"><span class="vt-15__idx">01</span><span class="vt-15__lbl">The name attribute</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p">Matching <code>name</code> values make the group exclusive — opening one closes the rest. Unlike radios, clicking the open item closes it, so “none open” is a valid state.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">02</span><span class="vt-15__lbl">Animating the impossible</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p"><code>::details-content</code> targets the wrapper the browser generates around everything after the summary — the box that never existed for CSS before.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">03</span><span class="vt-15__lbl">height: auto, transitioned</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p"><code>interpolate-size:allow-keywords</code> opts the page into interpolating intrinsic sizes, which finally makes <code>block-size:auto</code> a legal transition endpoint.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">04</span><span class="vt-15__lbl">allow-discrete</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p">Without <code>transition-behavior:allow-discrete</code>, <code>content-visibility</code> flips instantly and the content vanishes before the height has finished collapsing.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">05</span><span class="vt-15__lbl">Nothing to hydrate</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p">Strip every enhancement and four HTML attributes still give you a working, keyboard-operable, screen-reader-correct exclusive accordion.</p></div>
      </details>
    </div>
  </div>
</section>
.vt-15 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-15-bg);
  --vt-15-bg: oklch(0.97 0.008 30);
  --vt-15-card: oklch(1 0 0);
  --vt-15-ink: oklch(0.22 0.03 25);
  --vt-15-mut: oklch(0.54 0.028 25);
  --vt-15-acc: oklch(0.55 0.2 22);
  --vt-15-line: oklch(0.22 0.03 25/.12);
  interpolate-size: allow-keywords;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-15-ink);
}

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

.vt-15__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 50% 0%,oklch(0.95 0.04 30/.8),transparent);
}

.vt-15__head {
  display: grid;
  justify-items: center;
  gap: 9px;
  text-align: center;
  max-width: 54ch;
}

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

.vt-15__title {
  font-size: clamp(24px,3.6vw,40px);
  line-height: 1.06;
  letter-spacing: -.032em;
  font-weight: 720;
  text-wrap: balance;
}

.vt-15__set {
  width: min(94vw,720px);
  background: var(--vt-15-card);
  border: 1px solid var(--vt-15-line);
  border-radius: 18px;
  box-shadow: 0 24px 64px -32px oklch(0.22 0.03 25/.4);
  overflow: hidden;
}

.vt-15__item+.vt-15__item {
  border-top: 1px solid var(--vt-15-line);
}

.vt-15__sum {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 60px;
  padding: 0 20px;
  font-size: 15.4px;
  font-weight: 640;
  color: var(--vt-15-mut);
  cursor: pointer;
  list-style: none;
  transition: color .2s ease,background .2s ease;
}

.vt-15__sum::-webkit-details-marker {
  display: none;
}

.vt-15__idx {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .05em;
  opacity: .5;
}

.vt-15__lbl {
  flex: 1 1 auto;
  min-inline-size: 0;
}

.vt-15__mark {
  position: relative;
  inline-size: 18px;
  block-size: 18px;
  flex: none;
  opacity: .6;
}

.vt-15__mark::before,
.vt-15__mark::after {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  background: currentColor;
  border-radius: 2px;
  translate: -50% -50%;
  transition: rotate .34s cubic-bezier(.32,.72,.3,1),opacity .28s ease;
}

.vt-15__mark::before {
  inline-size: 13px;
  block-size: 2px;
}

.vt-15__mark::after {
  inline-size: 2px;
  block-size: 13px;
}

.vt-15__sum:hover {
  color: var(--vt-15-ink);
  background: oklch(0.22 0.03 25/.035);
}

.vt-15__sum:focus-visible {
  outline: 2px solid var(--vt-15-acc);
  outline-offset: -3px;
}

.vt-15__item[open]>.vt-15__sum {
  color: var(--vt-15-acc);
  box-shadow: inset 3px 0 0 var(--vt-15-acc);
}

.vt-15__item[open]>.vt-15__sum .vt-15__mark {
  opacity: 1;
}

.vt-15__item[open]>.vt-15__sum .vt-15__mark::after {
  rotate: 90deg;
  opacity: 0;
}

.vt-15__item[open]>.vt-15__sum .vt-15__mark::before {
  rotate: 180deg;
}

.vt-15__body {
  padding: 0 20px 20px 52px;
}

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

.vt-15__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: color-mix(in oklch,var(--vt-15-acc) 10%,transparent);
  color: var(--vt-15-acc);
  padding: 2px 6px;
  border-radius: 5px;
}

.vt-15__item::details-content {
  block-size: 0;
  overflow: hidden;
  opacity: 0;
  transition: block-size .35s cubic-bezier(.32,.72,.3,1),opacity .3s ease,content-visibility .35s allow-discrete;
}

.vt-15__item[open]::details-content {
  block-size: auto;
  opacity: 1;
}

@starting-style {
  .vt-15__item[open]::details-content {
    block-size: 0;
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .vt-15 * {
    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 <details name=""> Exclusive Group
Source: https://codefronts.com/navigation/css-vertical-tabs/vertical-tabs-with-details-name-exclusive-group/

The feature that finally deleted the accordion script: give a group of <details> elements the same name and the browser enforces one-open-at-a-time, exactly like radio buttons. Paired with ::details-content, interpolate-size and @starting-style, you get a fully animated exclusive tab stack in pure HTML and CSS.
## HTML
```html
<section class="vt-15" aria-label="Exclusive details group vertical tabs demo">
  <div class="vt-15__stage">
    <div class="vt-15__head">
      <p class="vt-15__eyebrow">&lt;details name&gt; · ::details-content · @starting-style</p>
      <h2 class="vt-15__title">Exclusive tabs in four attributes</h2>
    </div>
    <div class="vt-15__set">
      <details class="vt-15__item" name="vt-15" open>
        <summary class="vt-15__sum"><span class="vt-15__idx">01</span><span class="vt-15__lbl">The name attribute</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p">Matching <code>name</code> values make the group exclusive — opening one closes the rest. Unlike radios, clicking the open item closes it, so “none open” is a valid state.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">02</span><span class="vt-15__lbl">Animating the impossible</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p"><code>::details-content</code> targets the wrapper the browser generates around everything after the summary — the box that never existed for CSS before.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">03</span><span class="vt-15__lbl">height: auto, transitioned</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p"><code>interpolate-size:allow-keywords</code> opts the page into interpolating intrinsic sizes, which finally makes <code>block-size:auto</code> a legal transition endpoint.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">04</span><span class="vt-15__lbl">allow-discrete</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p">Without <code>transition-behavior:allow-discrete</code>, <code>content-visibility</code> flips instantly and the content vanishes before the height has finished collapsing.</p></div>
      </details>
      <details class="vt-15__item" name="vt-15">
        <summary class="vt-15__sum"><span class="vt-15__idx">05</span><span class="vt-15__lbl">Nothing to hydrate</span><i class="vt-15__mark" aria-hidden="true"></i></summary>
        <div class="vt-15__body"><p class="vt-15__p">Strip every enhancement and four HTML attributes still give you a working, keyboard-operable, screen-reader-correct exclusive accordion.</p></div>
      </details>
    </div>
  </div>
</section>
```
## CSS
```css
.vt-15 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-15-bg);
  --vt-15-bg: oklch(0.97 0.008 30);
  --vt-15-card: oklch(1 0 0);
  --vt-15-ink: oklch(0.22 0.03 25);
  --vt-15-mut: oklch(0.54 0.028 25);
  --vt-15-acc: oklch(0.55 0.2 22);
  --vt-15-line: oklch(0.22 0.03 25/.12);
  interpolate-size: allow-keywords;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-15-ink);
}

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

.vt-15__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 50% 0%,oklch(0.95 0.04 30/.8),transparent);
}

.vt-15__head {
  display: grid;
  justify-items: center;
  gap: 9px;
  text-align: center;
  max-width: 54ch;
}

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

.vt-15__title {
  font-size: clamp(24px,3.6vw,40px);
  line-height: 1.06;
  letter-spacing: -.032em;
  font-weight: 720;
  text-wrap: balance;
}

.vt-15__set {
  width: min(94vw,720px);
  background: var(--vt-15-card);
  border: 1px solid var(--vt-15-line);
  border-radius: 18px;
  box-shadow: 0 24px 64px -32px oklch(0.22 0.03 25/.4);
  overflow: hidden;
}

.vt-15__item+.vt-15__item {
  border-top: 1px solid var(--vt-15-line);
}

.vt-15__sum {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 60px;
  padding: 0 20px;
  font-size: 15.4px;
  font-weight: 640;
  color: var(--vt-15-mut);
  cursor: pointer;
  list-style: none;
  transition: color .2s ease,background .2s ease;
}

.vt-15__sum::-webkit-details-marker {
  display: none;
}

.vt-15__idx {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .05em;
  opacity: .5;
}

.vt-15__lbl {
  flex: 1 1 auto;
  min-inline-size: 0;
}

.vt-15__mark {
  position: relative;
  inline-size: 18px;
  block-size: 18px;
  flex: none;
  opacity: .6;
}

.vt-15__mark::before,
.vt-15__mark::after {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  background: currentColor;
  border-radius: 2px;
  translate: -50% -50%;
  transition: rotate .34s cubic-bezier(.32,.72,.3,1),opacity .28s ease;
}

.vt-15__mark::before {
  inline-size: 13px;
  block-size: 2px;
}

.vt-15__mark::after {
  inline-size: 2px;
  block-size: 13px;
}

.vt-15__sum:hover {
  color: var(--vt-15-ink);
  background: oklch(0.22 0.03 25/.035);
}

.vt-15__sum:focus-visible {
  outline: 2px solid var(--vt-15-acc);
  outline-offset: -3px;
}

.vt-15__item[open]>.vt-15__sum {
  color: var(--vt-15-acc);
  box-shadow: inset 3px 0 0 var(--vt-15-acc);
}

.vt-15__item[open]>.vt-15__sum .vt-15__mark {
  opacity: 1;
}

.vt-15__item[open]>.vt-15__sum .vt-15__mark::after {
  rotate: 90deg;
  opacity: 0;
}

.vt-15__item[open]>.vt-15__sum .vt-15__mark::before {
  rotate: 180deg;
}

.vt-15__body {
  padding: 0 20px 20px 52px;
}

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

.vt-15__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: color-mix(in oklch,var(--vt-15-acc) 10%,transparent);
  color: var(--vt-15-acc);
  padding: 2px 6px;
  border-radius: 5px;
}

.vt-15__item::details-content {
  block-size: 0;
  overflow: hidden;
  opacity: 0;
  transition: block-size .35s cubic-bezier(.32,.72,.3,1),opacity .3s ease,content-visibility .35s allow-discrete;
}

.vt-15__item[open]::details-content {
  block-size: auto;
  opacity: 1;
}

@starting-style {
  .vt-15__item[open]::details-content {
    block-size: 0;
    opacity: 0;
  }
}

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

How this works

The behaviour is entirely in the markup:

<details name="specs" open><summary>Materials</summary>…</details>
<details name="specs"><summary>Dimensions</summary>…</details>

Matching name values form an exclusive group: opening one closes the others, and — unlike a radio group — clicking the open one closes it, leaving none open. They don't even have to be siblings, which makes the pattern usable across a layout.

Animating it used to be impossible, because a closing <details> removed its content from the box tree instantly. Three primitives fixed that:

:root{ interpolate-size:allow-keywords }        /* lets auto be interpolated */
details::details-content{
  block-size:0; overflow:hidden; opacity:0;
  transition:block-size .35s, opacity .3s, content-visibility .35s allow-discrete; }
details[open]::details-content{ block-size:auto; opacity:1 }

::details-content targets the browser-generated wrapper around everything after the summary. interpolate-size:allow-keywords opts the page into animating to and from intrinsic sizes, so block-size:auto becomes a real transition endpoint. And transition-behavior:allow-discrete (the allow-discrete keyword) keeps content-visibility from snapping the content away before the height finishes collapsing.

The summary marker is replaced with a custom plus/minus built from two pseudo-element bars, because ::-webkit-details-marker is non-standard and list-style handling still differs across engines. Every one of these enhancements is additive: strip them all and you still have a working, accessible, keyboard-operable exclusive accordion from four HTML attributes.

Make it yours

  • Multi-open again: delete the name attributes. Everything else — animation, styling, keyboard support — is unchanged.
  • Groups across a layout: two <details> in different columns can share a name and still behave exclusively. Radio inputs cannot do that without a common form owner.
  • Open-by-default: put open on exactly one member. Two open attributes in the same group is invalid HTML and the browser keeps only the last.
  • Deep links: Chrome and Safari auto-open a <details> containing the URL fragment target, so #specs-materials just works.

Gotchas — read before shipping

  • Only one member of a named group may carry open in the source. The browser silently discards the earlier ones.
  • Height animation requires BOTH interpolate-size:allow-keywords and ::details-content support. Without them the panel snaps open — acceptable, but test it deliberately.
  • Don't animate details itself with max-height; the old hack needs a magic number and either clips content or eases against dead space.
  • Search-in-page: Chrome force-opens closed <details> to reveal a match, which will yank a transition mid-flight. Keep durations under ~400ms so it never feels stuck.
  • An exclusive group is still a disclosure group to assistive tech, not a tablist. That is usually the right semantic — but don't claim WAI-ARIA tab conformance with it.

Browser support

ChromeSafariFirefoxEdge
129+not yetnot yet129+

Floor set by oklch(), color-mix(), @starting-style, transition-behavior, text-wrap, interpolate-size as this demo is written. The core technique itself goes back further — Chrome 120+.

The name attribute: Chrome 120 (Dec 2023), Safari 17.2, Firefox 130. ::details-content + interpolate-size: Chrome 131, Safari 18.4, Firefox 137+. Older browsers get a plain multi-open accordion that still works perfectly.

Techniques used in this demo

Search CodeFronts

Loading…