18 CSS Vertical Tabs08 / 18

Pure CSSMIT licensed

Vertical Tabs Accordion Hybrid (Details/Summary)

Start with markup that works before a single line of CSS loads — a stack of native <details> disclosures — then let CSS promote it to a two-column vertical tab set on wide screens. The most robust pattern in this collection: no JS, no ARIA to get wrong, and full keyboard and screen-reader support straight from the HTML spec.

Published

Live Demo
Try it

The code

<section class="vt-08" aria-label="Details and summary tabs hybrid demo">
  <div class="vt-08__stage">
    <div class="vt-08__head">
      <p class="vt-08__eyebrow">&lt;details&gt; · @container · progressive enhancement</p>
      <h2 class="vt-08__title">Accordion first, tabs when there's room</h2>
    </div>
    <div class="vt-08__wrap">
      <div class="vt-08__set">
        <details class="vt-08__item" name="vt-08" open>
          <summary class="vt-08__sum"><span>Works before CSS</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">HTML that already works</h3><p class="vt-08__p">Disable every stylesheet and this is still a usable, keyboard-operable, screen-reader-friendly FAQ. Layout is the only thing CSS adds.</p></div></div>
        </details>
        <details class="vt-08__item" name="vt-08">
          <summary class="vt-08__sum"><span>Nothing re-implemented</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">The summary stays a summary</h3><p class="vt-08__p">Only the body moves. Enter and Space still toggle, expanded state is still exposed, and Chrome still force-opens closed panels for Ctrl+F.</p></div></div>
        </details>
        <details class="vt-08__item" name="vt-08">
          <summary class="vt-08__sum"><span>Exclusive by name</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">One open at a time</h3><p class="vt-08__p">A shared <code>name</code> makes the group behave like radio buttons, which is exactly what turns an accordion into tabs.</p></div></div>
        </details>
        <details class="vt-08__item" name="vt-08">
          <summary class="vt-08__sum"><span>Enhancement only</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">Fails to a plain accordion</h3><p class="vt-08__p">No container queries? Stacked disclosures. No <code>::details-content</code>? Instant open. Every fallback is still a finished component.</p></div></div>
        </details>
      </div>
    </div>
  </div>
</section>
.vt-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-08-bg);
  --vt-08-bg: oklch(0.955 0.014 145);
  --vt-08-card: oklch(1 0 0);
  --vt-08-ink: oklch(0.24 0.03 155);
  --vt-08-mut: oklch(0.52 0.025 155);
  --vt-08-acc: oklch(0.52 0.13 158);
  --vt-08-line: oklch(0.24 0.03 155/.13);
  interpolate-size: allow-keywords;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-08-ink);
}

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

.vt-08__stage {
  min-height: 100svh;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: clamp(16px,2.6vw,28px);
  padding: clamp(20px,5vw,64px);
  background: radial-gradient(80% 55% at 50% 100%,oklch(0.93 0.06 155/.7),transparent);
}

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

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

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

.vt-08__wrap {
  container-type: inline-size;
  width: min(94vw,860px);
}

.vt-08__set {
  background: var(--vt-08-card);
  border: 1px solid var(--vt-08-line);
  border-radius: 18px;
  box-shadow: 0 22px 62px -32px oklch(0.24 0.03 155/.42);
  overflow: hidden;
}

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

.vt-08__sum {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 56px;
  padding: 0 18px;
  font-size: 14.8px;
  font-weight: 650;
  color: var(--vt-08-mut);
  cursor: pointer;
  list-style: none;
  transition: color .2s ease,background .2s ease;
}

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

.vt-08__sum:hover {
  color: var(--vt-08-ink);
  background: oklch(0.24 0.03 155/.04);
}

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

.vt-08__chev {
  inline-size: 9px;
  block-size: 9px;
  flex: none;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  rotate: -45deg;
  transition: rotate .3s cubic-bezier(.32,.72,.3,1);
}

.vt-08__item[open]>.vt-08__sum {
  color: var(--vt-08-acc);
  background: color-mix(in oklch,var(--vt-08-acc) 9%,transparent);
}

.vt-08__item[open]>.vt-08__sum .vt-08__chev {
  rotate: 45deg;
}

.vt-08__inner {
  display: grid;
  gap: 10px;
  padding: 4px 18px 20px;
}

.vt-08__h {
  font-size: clamp(17px,2.1vw,22px);
  line-height: 1.24;
  letter-spacing: -.02em;
  font-weight: 680;
  text-wrap: balance;
}

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

.vt-08__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(0.24 0.03 155/.07);
  padding: 2px 6px;
  border-radius: 5px;
}

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

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

@container (min-width: 700px) {
  .vt-08__set {
    position: relative;
    padding-inline-end: 60%;
    min-height: clamp(250px,30vw,290px);
  }

  .vt-08__set::after {
    content: "";
    position: absolute;
    inset: 0 0 0 40%;
    border-left: 1px solid var(--vt-08-line);
    pointer-events: none;
  }

  .vt-08__item {
    border-top: 0;
  }

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

  .vt-08__chev {
    rotate: -45deg;
    opacity: .45;
  }

  .vt-08__item[open]>.vt-08__sum .vt-08__chev {
    rotate: -45deg;
    opacity: 1;
  }

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

  .vt-08__body {
    position: absolute;
    inset: 0 0 0 40%;
    display: grid;
    align-content: center;
    overflow: auto;
  }

  .vt-08__inner {
    padding: clamp(20px,3vw,36px);
    animation: vt-08-in .34s cubic-bezier(.32,.72,.3,1) both;
  }

  .vt-08__item::details-content {
    block-size: auto;
    opacity: 1;
    overflow: visible;
  }
}

@keyframes vt-08-in {
  from {
    opacity: 0;
    translate: 10px 0;
  }

  to {
    opacity: 1;
    translate: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .vt-08 * {
    transition-duration: .01ms !important;
    animation: none !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 Accordion Hybrid (Details/Summary)
Source: https://codefronts.com/navigation/css-vertical-tabs/vertical-tabs-accordion-hybrid-details-summary/

Start with markup that works before a single line of CSS loads — a stack of native <details> disclosures — then let CSS promote it to a two-column vertical tab set on wide screens. The most robust pattern in this collection: no JS, no ARIA to get wrong, and full keyboard and screen-reader support straight from the HTML spec.
## HTML
```html
<section class="vt-08" aria-label="Details and summary tabs hybrid demo">
  <div class="vt-08__stage">
    <div class="vt-08__head">
      <p class="vt-08__eyebrow">&lt;details&gt; · @container · progressive enhancement</p>
      <h2 class="vt-08__title">Accordion first, tabs when there's room</h2>
    </div>
    <div class="vt-08__wrap">
      <div class="vt-08__set">
        <details class="vt-08__item" name="vt-08" open>
          <summary class="vt-08__sum"><span>Works before CSS</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">HTML that already works</h3><p class="vt-08__p">Disable every stylesheet and this is still a usable, keyboard-operable, screen-reader-friendly FAQ. Layout is the only thing CSS adds.</p></div></div>
        </details>
        <details class="vt-08__item" name="vt-08">
          <summary class="vt-08__sum"><span>Nothing re-implemented</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">The summary stays a summary</h3><p class="vt-08__p">Only the body moves. Enter and Space still toggle, expanded state is still exposed, and Chrome still force-opens closed panels for Ctrl+F.</p></div></div>
        </details>
        <details class="vt-08__item" name="vt-08">
          <summary class="vt-08__sum"><span>Exclusive by name</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">One open at a time</h3><p class="vt-08__p">A shared <code>name</code> makes the group behave like radio buttons, which is exactly what turns an accordion into tabs.</p></div></div>
        </details>
        <details class="vt-08__item" name="vt-08">
          <summary class="vt-08__sum"><span>Enhancement only</span><i class="vt-08__chev" aria-hidden="true"></i></summary>
          <div class="vt-08__body"><div class="vt-08__inner"><h3 class="vt-08__h">Fails to a plain accordion</h3><p class="vt-08__p">No container queries? Stacked disclosures. No <code>::details-content</code>? Instant open. Every fallback is still a finished component.</p></div></div>
        </details>
      </div>
    </div>
  </div>
</section>
```
## CSS
```css
.vt-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-08-bg);
  --vt-08-bg: oklch(0.955 0.014 145);
  --vt-08-card: oklch(1 0 0);
  --vt-08-ink: oklch(0.24 0.03 155);
  --vt-08-mut: oklch(0.52 0.025 155);
  --vt-08-acc: oklch(0.52 0.13 158);
  --vt-08-line: oklch(0.24 0.03 155/.13);
  interpolate-size: allow-keywords;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-08-ink);
}

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

.vt-08__stage {
  min-height: 100svh;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: clamp(16px,2.6vw,28px);
  padding: clamp(20px,5vw,64px);
  background: radial-gradient(80% 55% at 50% 100%,oklch(0.93 0.06 155/.7),transparent);
}

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

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

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

.vt-08__wrap {
  container-type: inline-size;
  width: min(94vw,860px);
}

.vt-08__set {
  background: var(--vt-08-card);
  border: 1px solid var(--vt-08-line);
  border-radius: 18px;
  box-shadow: 0 22px 62px -32px oklch(0.24 0.03 155/.42);
  overflow: hidden;
}

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

.vt-08__sum {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 56px;
  padding: 0 18px;
  font-size: 14.8px;
  font-weight: 650;
  color: var(--vt-08-mut);
  cursor: pointer;
  list-style: none;
  transition: color .2s ease,background .2s ease;
}

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

.vt-08__sum:hover {
  color: var(--vt-08-ink);
  background: oklch(0.24 0.03 155/.04);
}

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

.vt-08__chev {
  inline-size: 9px;
  block-size: 9px;
  flex: none;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  rotate: -45deg;
  transition: rotate .3s cubic-bezier(.32,.72,.3,1);
}

.vt-08__item[open]>.vt-08__sum {
  color: var(--vt-08-acc);
  background: color-mix(in oklch,var(--vt-08-acc) 9%,transparent);
}

.vt-08__item[open]>.vt-08__sum .vt-08__chev {
  rotate: 45deg;
}

.vt-08__inner {
  display: grid;
  gap: 10px;
  padding: 4px 18px 20px;
}

.vt-08__h {
  font-size: clamp(17px,2.1vw,22px);
  line-height: 1.24;
  letter-spacing: -.02em;
  font-weight: 680;
  text-wrap: balance;
}

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

.vt-08__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(0.24 0.03 155/.07);
  padding: 2px 6px;
  border-radius: 5px;
}

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

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

@container (min-width: 700px) {
  .vt-08__set {
    position: relative;
    padding-inline-end: 60%;
    min-height: clamp(250px,30vw,290px);
  }

  .vt-08__set::after {
    content: "";
    position: absolute;
    inset: 0 0 0 40%;
    border-left: 1px solid var(--vt-08-line);
    pointer-events: none;
  }

  .vt-08__item {
    border-top: 0;
  }

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

  .vt-08__chev {
    rotate: -45deg;
    opacity: .45;
  }

  .vt-08__item[open]>.vt-08__sum .vt-08__chev {
    rotate: -45deg;
    opacity: 1;
  }

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

  .vt-08__body {
    position: absolute;
    inset: 0 0 0 40%;
    display: grid;
    align-content: center;
    overflow: auto;
  }

  .vt-08__inner {
    padding: clamp(20px,3vw,36px);
    animation: vt-08-in .34s cubic-bezier(.32,.72,.3,1) both;
  }

  .vt-08__item::details-content {
    block-size: auto;
    opacity: 1;
    overflow: visible;
  }
}

@keyframes vt-08-in {
  from {
    opacity: 0;
    translate: 10px 0;
  }

  to {
    opacity: 1;
    translate: 0;
  }
}

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

How this works

The base layer is plain HTML that any browser since 2011 renders correctly:

<details name="faq" open><summary>Shipping</summary><p>…</p></details>
<details name="faq"><summary>Returns</summary><p>…</p></details>

The promotion to tabs is pure layout. The wrapper becomes position:relative with a reserved right-hand region; the <details> elements stack in the narrow left column, and each one's body is lifted out of the flow into that region:

@container (min-width:700px){
  .set{ position:relative; padding-inline-end:60% }
  .body{ position:absolute; inset:0 0 0 40%; overflow:auto }
}

Because only the body is repositioned, the summary stays a real disclosure button — Enter and Space still toggle it, the browser still exposes expanded/collapsed state, and Ctrl+F still finds text in closed panels in Chrome (which force-opens them to reveal matches). Nothing about the interaction model is re-implemented, so nothing about it can be broken.

Two flourishes stay out of the way of that robustness: ::details-content plus interpolate-size:allow-keywords animates the open/close height where supported and is simply ignored where it isn't, and the name attribute makes the group exclusive so desktop behaves like true tabs. The trade-off to know: this is a disclosure group, so assistive tech announces "expanded/collapsed", not "tab 2 of 4" — usually a feature, occasionally a spec violation.

Make it yours

  • Rail width: one number, the padding-inline-end on the set and the matching inset on the bodies. Keep them complementary (40% / 60%).
  • Allow multi-open on mobile only: you cannot toggle the name attribute from CSS, so either accept exclusivity everywhere or strip the attribute with three lines of JS below your breakpoint.
  • Custom marker: summary::-webkit-details-marker{display:none} plus summary{list-style:none}, then draw your own chevron with a pseudo-element that rotates on [open].
  • Deep-linkable: give each <details> an id and add details:target{...}; Chrome and Safari already auto-open a details element containing the fragment target.

Gotchas — read before shipping

  • <summary> must be the FIRST child of <details>. Anywhere else and the browser synthesises its own "Details" marker and your summary becomes ordinary content.
  • Do not put interactive controls inside a <summary> — a button or link nested in the disclosure button is an accessibility conflict and clicks get eaten by the toggle.
  • Absolutely positioned bodies leave no height behind. Give the set a min-height or the tallest panel will overflow the card.
  • Height animation needs interpolate-size:allow-keywords AND ::details-content support; without both, transitions on height:auto do nothing. Treat it strictly as enhancement.
  • Screen readers announce this as a disclosure group, not a tablist. If a design system mandates role="tab", use demo 18 — don't paint tab roles onto <details>.

Browser support

ChromeSafariFirefoxEdge
129+not yetnot yet129+

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

<details>/<summary> is universal. The exclusive name attribute needs Chrome 120 / Safari 17.2 / Firefox 130; ::details-content + interpolate-size is Chrome 131+ / Safari 18.4+. Everything degrades to a plain, fully working accordion.

Techniques used in this demo

Search CodeFronts

Loading…