18 CSS Vertical Tabs02 / 18

Pure CSSMIT licensed

Responsive Vertical Tabs (Desktop → Mobile Accordion)

One component, two layouts: a two-column vertical tab set on desktop that becomes a stacked accordion on narrow screens — where a fixed 240px rail would eat half the viewport. Driven by a container query so it adapts to the space the component actually has, not the space the browser has, which means it works inside a sidebar, a modal or a CMS column with no extra breakpoints.

Published

Live Demo
Try it

The code

<section class="vt-02" aria-label="Responsive vertical tabs demo">
  <div class="vt-02__stage">
    <div class="vt-02__head">
      <p class="vt-02__eyebrow">@container · order · 0fr → 1fr</p>
      <h2 class="vt-02__title">Tabs wide, accordion narrow</h2>
      <p class="vt-02__sub">Resize the frame — the flip is driven by the component's own width, not the viewport's.</p>
    </div>
    <div class="vt-02__wrap">
      <div class="vt-02__tabs" role="radiogroup" aria-label="Delivery options">
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t1" checked>
        <label class="vt-02__tab" for="vt-02-t1">Shipping<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p1"><div class="vt-02__inner">
          <h3 class="vt-02__h">Same DOM, two shapes</h3>
          <p class="vt-02__p">No cloned markup and no duplicate ids — the narrow layout just reorders the flat grid so each label sits above its own panel.</p>
        </div></div>
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t2">
        <label class="vt-02__tab" for="vt-02-t2">Returns<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p2"><div class="vt-02__inner">
          <h3 class="vt-02__h">Container, not viewport</h3>
          <p class="vt-02__p">Drop this in a 380px dashboard column on a 4K display and it still renders the accordion. Media queries can't know that.</p>
        </div></div>
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t3">
        <label class="vt-02__tab" for="vt-02-t3">Warranty<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p3"><div class="vt-02__inner">
          <h3 class="vt-02__h">Height without measuring</h3>
          <p class="vt-02__p">The open panel animates from <code>grid-template-rows:0fr</code> to <code>1fr</code> — auto-height easing with nothing to measure in JavaScript.</p>
        </div></div>
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t4">
        <label class="vt-02__tab" for="vt-02-t4">Support<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p4"><div class="vt-02__inner">
          <h3 class="vt-02__h">One breakpoint to tune</h3>
          <p class="vt-02__p">Every responsive behaviour in this component keys off a single <code>max-width</code> value. Change it once.</p>
        </div></div>
      </div>
    </div>
  </div>
</section>
.vt-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-02-bg);
  --vt-02-bg: oklch(0.24 0.026 250);
  --vt-02-card: oklch(0.28 0.03 252);
  --vt-02-ink: oklch(0.97 0.008 250);
  --vt-02-mut: oklch(0.76 0.018 250);
  --vt-02-acc: oklch(0.82 0.16 178);
  --vt-02-line: oklch(1 0 0/.11);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-02-ink);
}

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

.vt-02__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(80% 55% at 50% 0%,oklch(0.32 0.05 200/.55),transparent);
}

.vt-02__head {
  display: grid;
  justify-items: center;
  gap: 10px;
  text-align: center;
  max-width: 52ch;
}

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

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

.vt-02__sub {
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--vt-02-mut);
  text-wrap: pretty;
}

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

.vt-02__tabs {
  display: grid;
  grid-template-columns: clamp(150px,25%,224px) 1fr;
  grid-template-rows: repeat(4,auto);
  align-content: start;
  background: var(--vt-02-card);
  border: 1px solid var(--vt-02-line);
  border-radius: 18px;
  box-shadow: 0 26px 70px -34px oklch(0 0 0/.7);
  overflow: hidden;
}

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

.vt-02__tab {
  grid-column: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  min-height: 54px;
  padding: 0 18px;
  font-size: 14.5px;
  font-weight: 600;
  color: var(--vt-02-mut);
  border-right: 1px solid var(--vt-02-line);
  cursor: pointer;
  transition: color .2s ease,background .2s ease;
}

.vt-02__tab:nth-of-type(1) {
  grid-row: 1;
}

.vt-02__tab:nth-of-type(2) {
  grid-row: 2;
}

.vt-02__tab:nth-of-type(3) {
  grid-row: 3;
}

.vt-02__tab:nth-of-type(4) {
  grid-row: 4;
}

.vt-02__chev {
  width: 9px;
  height: 9px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  rotate: -45deg;
  opacity: 0;
  transition: rotate .3s ease,opacity .2s ease;
}

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

.vt-02__radio:checked+.vt-02__tab {
  color: var(--vt-02-acc);
  background: color-mix(in oklch,var(--vt-02-acc) 12%,transparent);
  box-shadow: inset 3px 0 0 var(--vt-02-acc);
}

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

.vt-02__panel {
  grid-column: 2;
  grid-row: 1/-1;
  display: grid;
  grid-template-rows: 1fr;
  align-content: center;
  align-self: stretch;
  opacity: 0;
  visibility: hidden;
  transform: translateY(8px);
  transition: opacity .3s ease,transform .34s cubic-bezier(.32,.72,.3,1),visibility 0s .34s;
}

.vt-02__inner {
  min-height: 0;
  overflow: hidden;
  display: grid;
  gap: 11px;
  padding: clamp(22px,3.4vw,38px);
}

.vt-02__radio:checked+.vt-02__tab+.vt-02__panel {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.vt-02__h {
  font-size: clamp(18px,2.3vw,23px);
  line-height: 1.22;
  letter-spacing: -.02em;
  font-weight: 680;
  text-wrap: balance;
}

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

.vt-02__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(1 0 0/.09);
  padding: 2px 6px;
  border-radius: 5px;
}

@container (max-width: 640px) {
  .vt-02__tabs {
    grid-template-columns: 1fr;
    grid-template-rows: none;
    grid-auto-rows: auto;
  }

  .vt-02__tab {
    grid-column: 1;
    grid-row: auto;
    border-right: 0;
    border-bottom: 1px solid var(--vt-02-line);
  }

  .vt-02__tab:nth-of-type(n) {
    grid-row: auto;
  }

  .vt-02__chev {
    opacity: 1;
  }

  .vt-02__radio:checked+.vt-02__tab .vt-02__chev {
    rotate: 45deg;
  }

  .vt-02__panel {
    grid-column: 1;
    grid-row: auto;
    min-height: 0;
    grid-template-rows: 0fr;
    transform: none;
    opacity: 1;
    visibility: visible;
    align-content: start;
    align-self: start;
    transition: grid-template-rows .34s cubic-bezier(.32,.72,.3,1);
  }

  .vt-02__panel .vt-02__inner {
    padding-block: 0;
    transition: padding-block .34s cubic-bezier(.32,.72,.3,1);
  }

  .vt-02__radio:checked+.vt-02__tab+.vt-02__panel {
    grid-template-rows: 1fr;
    border-bottom: 1px solid var(--vt-02-line);
  }

  .vt-02__radio:checked+.vt-02__tab+.vt-02__panel .vt-02__inner {
    padding-block: clamp(22px,3.4vw,38px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .vt-02 * {
    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: Responsive Vertical Tabs (Desktop → Mobile Accordion)
Source: https://codefronts.com/navigation/css-vertical-tabs/responsive-vertical-tabs-desktop-mobile-accordion/

One component, two layouts: a two-column vertical tab set on desktop that becomes a stacked accordion on narrow screens — where a fixed 240px rail would eat half the viewport. Driven by a container query so it adapts to the space the component actually has, not the space the browser has, which means it works inside a sidebar, a modal or a CMS column with no extra breakpoints.
## HTML
```html
<section class="vt-02" aria-label="Responsive vertical tabs demo">
  <div class="vt-02__stage">
    <div class="vt-02__head">
      <p class="vt-02__eyebrow">@container · order · 0fr → 1fr</p>
      <h2 class="vt-02__title">Tabs wide, accordion narrow</h2>
      <p class="vt-02__sub">Resize the frame — the flip is driven by the component's own width, not the viewport's.</p>
    </div>
    <div class="vt-02__wrap">
      <div class="vt-02__tabs" role="radiogroup" aria-label="Delivery options">
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t1" checked>
        <label class="vt-02__tab" for="vt-02-t1">Shipping<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p1"><div class="vt-02__inner">
          <h3 class="vt-02__h">Same DOM, two shapes</h3>
          <p class="vt-02__p">No cloned markup and no duplicate ids — the narrow layout just reorders the flat grid so each label sits above its own panel.</p>
        </div></div>
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t2">
        <label class="vt-02__tab" for="vt-02-t2">Returns<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p2"><div class="vt-02__inner">
          <h3 class="vt-02__h">Container, not viewport</h3>
          <p class="vt-02__p">Drop this in a 380px dashboard column on a 4K display and it still renders the accordion. Media queries can't know that.</p>
        </div></div>
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t3">
        <label class="vt-02__tab" for="vt-02-t3">Warranty<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p3"><div class="vt-02__inner">
          <h3 class="vt-02__h">Height without measuring</h3>
          <p class="vt-02__p">The open panel animates from <code>grid-template-rows:0fr</code> to <code>1fr</code> — auto-height easing with nothing to measure in JavaScript.</p>
        </div></div>
        <input class="vt-02__radio" type="radio" name="vt-02" id="vt-02-t4">
        <label class="vt-02__tab" for="vt-02-t4">Support<span class="vt-02__chev" aria-hidden="true"></span></label>
        <div class="vt-02__panel" id="vt-02-p4"><div class="vt-02__inner">
          <h3 class="vt-02__h">One breakpoint to tune</h3>
          <p class="vt-02__p">Every responsive behaviour in this component keys off a single <code>max-width</code> value. Change it once.</p>
        </div></div>
      </div>
    </div>
  </div>
</section>
```
## CSS
```css
.vt-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--vt-02-bg);
  --vt-02-bg: oklch(0.24 0.026 250);
  --vt-02-card: oklch(0.28 0.03 252);
  --vt-02-ink: oklch(0.97 0.008 250);
  --vt-02-mut: oklch(0.76 0.018 250);
  --vt-02-acc: oklch(0.82 0.16 178);
  --vt-02-line: oklch(1 0 0/.11);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--vt-02-ink);
}

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

.vt-02__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(80% 55% at 50% 0%,oklch(0.32 0.05 200/.55),transparent);
}

.vt-02__head {
  display: grid;
  justify-items: center;
  gap: 10px;
  text-align: center;
  max-width: 52ch;
}

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

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

.vt-02__sub {
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--vt-02-mut);
  text-wrap: pretty;
}

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

.vt-02__tabs {
  display: grid;
  grid-template-columns: clamp(150px,25%,224px) 1fr;
  grid-template-rows: repeat(4,auto);
  align-content: start;
  background: var(--vt-02-card);
  border: 1px solid var(--vt-02-line);
  border-radius: 18px;
  box-shadow: 0 26px 70px -34px oklch(0 0 0/.7);
  overflow: hidden;
}

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

.vt-02__tab {
  grid-column: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  min-height: 54px;
  padding: 0 18px;
  font-size: 14.5px;
  font-weight: 600;
  color: var(--vt-02-mut);
  border-right: 1px solid var(--vt-02-line);
  cursor: pointer;
  transition: color .2s ease,background .2s ease;
}

.vt-02__tab:nth-of-type(1) {
  grid-row: 1;
}

.vt-02__tab:nth-of-type(2) {
  grid-row: 2;
}

.vt-02__tab:nth-of-type(3) {
  grid-row: 3;
}

.vt-02__tab:nth-of-type(4) {
  grid-row: 4;
}

.vt-02__chev {
  width: 9px;
  height: 9px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  rotate: -45deg;
  opacity: 0;
  transition: rotate .3s ease,opacity .2s ease;
}

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

.vt-02__radio:checked+.vt-02__tab {
  color: var(--vt-02-acc);
  background: color-mix(in oklch,var(--vt-02-acc) 12%,transparent);
  box-shadow: inset 3px 0 0 var(--vt-02-acc);
}

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

.vt-02__panel {
  grid-column: 2;
  grid-row: 1/-1;
  display: grid;
  grid-template-rows: 1fr;
  align-content: center;
  align-self: stretch;
  opacity: 0;
  visibility: hidden;
  transform: translateY(8px);
  transition: opacity .3s ease,transform .34s cubic-bezier(.32,.72,.3,1),visibility 0s .34s;
}

.vt-02__inner {
  min-height: 0;
  overflow: hidden;
  display: grid;
  gap: 11px;
  padding: clamp(22px,3.4vw,38px);
}

.vt-02__radio:checked+.vt-02__tab+.vt-02__panel {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.vt-02__h {
  font-size: clamp(18px,2.3vw,23px);
  line-height: 1.22;
  letter-spacing: -.02em;
  font-weight: 680;
  text-wrap: balance;
}

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

.vt-02__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(1 0 0/.09);
  padding: 2px 6px;
  border-radius: 5px;
}

@container (max-width: 640px) {
  .vt-02__tabs {
    grid-template-columns: 1fr;
    grid-template-rows: none;
    grid-auto-rows: auto;
  }

  .vt-02__tab {
    grid-column: 1;
    grid-row: auto;
    border-right: 0;
    border-bottom: 1px solid var(--vt-02-line);
  }

  .vt-02__tab:nth-of-type(n) {
    grid-row: auto;
  }

  .vt-02__chev {
    opacity: 1;
  }

  .vt-02__radio:checked+.vt-02__tab .vt-02__chev {
    rotate: 45deg;
  }

  .vt-02__panel {
    grid-column: 1;
    grid-row: auto;
    min-height: 0;
    grid-template-rows: 0fr;
    transform: none;
    opacity: 1;
    visibility: visible;
    align-content: start;
    align-self: start;
    transition: grid-template-rows .34s cubic-bezier(.32,.72,.3,1);
  }

  .vt-02__panel .vt-02__inner {
    padding-block: 0;
    transition: padding-block .34s cubic-bezier(.32,.72,.3,1);
  }

  .vt-02__radio:checked+.vt-02__tab+.vt-02__panel {
    grid-template-rows: 1fr;
    border-bottom: 1px solid var(--vt-02-line);
  }

  .vt-02__radio:checked+.vt-02__tab+.vt-02__panel .vt-02__inner {
    padding-block: clamp(22px,3.4vw,38px);
  }
}

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

How this works

The layout flip is a handful of declarations. Same DOM, same radio state, different grid:

.wrap{ container-type:inline-size }

.tabs{ display:grid; grid-template-columns:240px 1fr }   /* wide  */
@container (max-width:640px){
  .tabs{ grid-template-columns:1fr }                     /* stack */
  .tabs label{ order:0 }
  .tabs .panel{ order:0 }        /* label → its own panel → next label */
}

The hard part is not the columns, it is the interleaving. A tab set puts all triggers first and all panels second; an accordion needs trigger, panel, trigger, panel. Rather than duplicating markup, the narrow layout uses order on a flat grid: each label gets order:N and each panel order:N too, so they zip together into disclosure pairs. Zero JS, zero cloned nodes, one source of truth.

Container queries beat media queries here for a real reason. @media (max-width:768px) asks how big the window is; this component might be rendered in a 380px-wide dashboard column on a 1440px monitor and would still show the desktop rail, badly. @container asks how big the component's own box is, so the same file is correct in every slot. The classic media-query version is included as a commented fallback — swapping one at-rule keyword is the entire downgrade.

Panel heights animate with grid-template-rows:0fr → 1fr, the modern way to transition to auto height without measuring anything in JavaScript.

Make it yours

  • Breakpoint: change the single @container (max-width:640px) value. Everything — rail width, interleaving, chevrons — hangs off that one rule.
  • Accordion-first: invert to @container (min-width:641px) and author the stacked layout as the default for a true mobile-first cascade.
  • Keep tabs everywhere: delete the container block and the component stays a plain vertical tab set — the desktop styles are complete on their own.
  • Multi-open on mobile: swap the radios for checkboxes inside the container query only… which CSS cannot do. If you need it, use demo 15's <details> group and drop the name attribute at that breakpoint via JS.

Gotchas — read before shipping

  • A container query needs an ancestor with container-type:inline-size — and the queried element must be a DESCENDANT, never the container itself. Querying the same element silently never matches.
  • container-type:inline-size creates size containment: children can no longer stretch the container's width. Percentage-width children behave, but intrinsic min-content layouts can clip.
  • Transitioning height:auto still doesn't work reliably; grid-template-rows:0fr→1fr does, but the inner element needs min-height:0;overflow:hidden or it refuses to shrink.
  • Don't hide the desktop rail with display:none and render a second accordion — duplicated triggers mean duplicated ids, broken labels and a screen reader hearing every tab twice.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

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

Container queries: Chrome 105, Safari 16, Firefox 110 — safe everywhere since early 2023. The 0fr→1fr row transition needs Chrome 107+/Safari 16+/Firefox 130+; older Firefox simply snaps open instead of sliding.

Techniques used in this demo

Search CodeFronts

Loading…