CSS Vertical Tabs

Vertical tabbed navigation using radio inputs and the :checked pseudo-class.

Published June 9, 2026 Intermediate 8 min read

Vertical tabs put the tab strip down the side instead of across the top, which buys room for longer labels and for more of them. The mechanism is identical to a horizontal strip: one radio per tab, all sharing a name, :checked marking the active one, and the ~ combinator carrying that state to the label and the panel. Only the layout changes, and that is a two column grid.

The vertical arrangement earns its keep when labels are words rather than single nouns, or when there are more tabs than fit across a line without wrapping. Two other versions follow: one with an indicator bar that slides down the rail, and one built on :target so each section has its own URL.

Pick a section from the left

Overview

A quick introduction to the product. Use vertical tabs to organize content into logical sections without page reloads.

Features

Zero JavaScript, accessible via keyboard, fully themeable with CSS custom properties. Works on all modern browsers.

Pricing

Free and open source. Use it in personal and commercial projects. No attribution required.

Support

Open an issue on GitHub or check the documentation. Community-driven support available 24/7.

HTML

<!-- The radios must be siblings of .vtab-layout for ~ to reach it. -->
<input type="radio" name="vtab" id="vtab1" class="vtab-radio" checked>
<input type="radio" name="vtab" id="vtab2" class="vtab-radio">
<input type="radio" name="vtab" id="vtab3" class="vtab-radio">
<input type="radio" name="vtab" id="vtab4" class="vtab-radio">

<div class="vtab-layout">
  <nav class="vtab-nav">
    <label for="vtab1">Overview</label>
    <label for="vtab2">Features</label>
    <label for="vtab3">Pricing</label>
    <label for="vtab4">Support</label>
  </nav>
  <div class="vtab-panels">
    <div class="vtab-panel vtab-panel-1">
      <h3>Overview</h3>
      <p>A quick introduction to the product. Use vertical tabs to organize content into logical sections without page reloads.</p>
    </div>
    <div class="vtab-panel vtab-panel-2">
      <h3>Features</h3>
      <p>Zero JavaScript, accessible via keyboard, fully themeable with CSS custom properties. Works on all modern browsers.</p>
    </div>
    <div class="vtab-panel vtab-panel-3">
      <h3>Pricing</h3>
      <p>Free and open source. Use it in personal and commercial projects. No attribution required.</p>
    </div>
    <div class="vtab-panel vtab-panel-4">
      <h3>Support</h3>
      <p>Open an issue on GitHub or check the documentation. Community-driven support available 24/7.</p>
    </div>
  </div>
</div>

CSS

.vtab-radio {
  display: none;
}

.vtab-layout {
  display: grid;
  grid-template-columns: 160px 1fr;
  min-height: 220px;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
  overflow: hidden;
}

.vtab-nav {
  display: flex;
  flex-direction: column;
  gap: .2rem;
  padding: .5rem;
  background: #141415;
  border-right: 1px solid #2a2a2d;
}

.vtab-nav label {
  display: block;
  padding: .55rem .8rem;
  border-radius: 8px;
  color: #88888f;
  font-size: .875rem;
  font-weight: 500;
  cursor: pointer;
  user-select: none;
  transition: background .15s, color .15s;
}

.vtab-nav label:hover {
  background: rgba(184, 255, 87, 0.1);
  color: #f0f0f0;
}

.vtab-panels {
  padding: 1.25rem;
  background: #1c1c1e;
}

.vtab-panel {
  display: none;
}

.vtab-panel h3 {
  font-size: 1rem;
  margin: 0 0 .5rem;
  color: #f0f0f0;
}

.vtab-panel p {
  margin: 0;
  font-size: .875rem;
  color: #88888f;
  line-height: 1.6;
}

/* Active tab */
#vtab1:checked ~ .vtab-layout .vtab-nav label[for="vtab1"],
#vtab2:checked ~ .vtab-layout .vtab-nav label[for="vtab2"],
#vtab3:checked ~ .vtab-layout .vtab-nav label[for="vtab3"],
#vtab4:checked ~ .vtab-layout .vtab-nav label[for="vtab4"] {
  background: rgba(184, 255, 87, 0.1);
  color: #b8ff57;
}

/* Active panel */
#vtab1:checked ~ .vtab-layout .vtab-panel-1,
#vtab2:checked ~ .vtab-layout .vtab-panel-2,
#vtab3:checked ~ .vtab-layout .vtab-panel-3,
#vtab4:checked ~ .vtab-layout .vtab-panel-4 {
  display: block;
}

Other ways to build it

Sliding rail indicator

One bar runs down the left edge of the rail and moves with translateY instead of four separate backgrounds switching on and off. Each label is a fixed 40px tall, so a step is translateY(100%) of a 40px bar. That fixed height is the constraint to watch: let one label wrap onto a second line and every position below it is off by a line, with the error growing further down the list. The radios here stay focusable, so the arrow keys move the bar as well.

Profile

The bar is one element positioned at the top of the rail. Only its transform changes between tabs.

Billing

Every label is exactly 40px tall, which is what makes one step of the bar a clean 100%.

Notifications

A label that wraps to two lines would put this tab and the one below it out of alignment.

Security

The radios are hidden with opacity rather than display, so the arrow keys move the bar too.

HTML

<div class="vslide">
  <input class="vslide-radio" type="radio" name="vertical-tabs-slide" id="v1" checked>
  <input class="vslide-radio" type="radio" name="vertical-tabs-slide" id="v2">
  <input class="vslide-radio" type="radio" name="vertical-tabs-slide" id="v3">
  <input class="vslide-radio" type="radio" name="vertical-tabs-slide" id="v4">
  <div class="vslide-layout">
    <nav class="vslide-nav">
      <span class="vslide-bar"></span>
      <label for="v1">Profile</label>
      <label for="v2">Billing</label>
      <label for="v3">Notifications</label>
      <label for="v4">Security</label>
    </nav>
    <div class="vslide-panels">
      <div class="vslide-panel" id="vp1"><h3>Profile</h3><p>The bar is one element positioned at the top of the rail. Only its transform changes between tabs.</p></div>
      <div class="vslide-panel" id="vp2"><h3>Billing</h3><p>Every label is exactly 40px tall, which is what makes one step of the bar a clean 100%.</p></div>
      <div class="vslide-panel" id="vp3"><h3>Notifications</h3><p>A label that wraps to two lines would put this tab and the one below it out of alignment.</p></div>
      <div class="vslide-panel" id="vp4"><h3>Security</h3><p>The radios are hidden with opacity rather than display, so the arrow keys move the bar too.</p></div>
    </div>
  </div>
</div>

CSS

.vslide-nav {
  position: relative;
  display: flex;
  flex-direction: column;
}

/* fixed height per label: the bar steps by exactly one of these */
.vslide-nav label {
  height: 40px;
  display: flex;
  align-items: center;
  padding: 0 .9rem;
  cursor: pointer;
}

.vslide-bar {
  position: absolute;
  left: 0;
  top: 0;
  width: 3px;
  height: 40px;
  background: #b8ff57;
  transition: transform .28s cubic-bezier(.4, 0, .2, 1);
}

#v2:checked ~ .vslide-layout .vslide-bar { transform: translateY(100%); }
#v3:checked ~ .vslide-layout .vslide-bar { transform: translateY(200%); }
#v4:checked ~ .vslide-layout .vslide-bar { transform: translateY(300%); }

Linkable sections with :target

Swapping the radios for links puts the active section in the URL. Each panel is hidden until :target matches its id, and one extra rule keeps the first panel showing when there is no fragment at all, since :target matches nothing on a fresh load. The gain is that a section can be linked to and reached with the back button. The cost is that following the link scrolls the page to the panel, so scroll-margin-top goes on the panel itself, since the jump lands on whichever element the fragment names rather than on its container.

Install

This panel is the default. It shows while no fragment matches anything, and hides as soon as one of the others is targeted.

Configure

The address bar now ends in this panel's id. Reload and it is still the panel showing, which no radio can manage.

Deploy

Only one element in a document can be the target, so two of these panels can never be open at the same time.

HTML

<div class="vtarget">
  <nav class="vtarget-nav">
    <a href="#tp1">Install</a>
    <a href="#tp2">Configure</a>
    <a href="#tp3">Deploy</a>
  </nav>
  <div class="vtarget-panels">
    <div class="vtarget-panel" id="tp1">...</div>
    <div class="vtarget-panel" id="tp2">...</div>
    <div class="vtarget-panel" id="tp3">...</div>
  </div>
</div>

CSS

.vtarget {
  display: grid;
  grid-template-columns: 160px 1fr;
  min-height: 200px;
}

/* the jump lands on whatever the fragment names, so the scroll
   margin goes on the panel rather than on the wrapper */
.vtarget-panel {
  display: none;
  scroll-margin-top: 6rem;
}

.vtarget-panel:target {
  display: block;
}

/* nothing is targeted on a fresh load, so the first panel
   shows unless one of its siblings has been targeted */
.vtarget-panels:not(:has(:target)) .vtarget-panel:first-child {
  display: block;
}

/* :has() is also the only way to mark the matching rail link,
   since the link is not an ancestor or sibling of the panel */
.vtarget:has(#tp2:target) a[href="#tp2"],
.vtarget:has(#tp3:target) a[href="#tp3"] {
  color: #b8ff57;
  background: rgba(184, 255, 87, 0.1);
}

How it works

The same radio-input pattern used in horizontal tabs works just as well vertically. Hidden radio inputs manage state. CSS grid creates a two-column layout with the nav on the left and content on the right. The :checked pseudo-class highlights the active nav label and shows the matching panel using the ~ sibling combinator targeting descendant panel elements.

grid-template-columns: 160px 1fr fixes the rail and lets the panel take the rest. A fixed rail is deliberate. Sizing it with auto makes the column as wide as the longest label, so the panel shifts sideways when the copy is translated into a language with longer words, and the whole component appears to resize itself for no visible reason. A fixed track, or minmax(140px, 200px), keeps that under control.

The radios have to sit before the grid container and share its parent, because ~ only reaches forward through siblings. That puts them outside the grid entirely, which is exactly where you want them, since a display: none input inside a grid still counts as a grid item in some layouts and produces an empty implicit row. Keeping them out of the container avoids the question.

A vertical strip has a height problem a horizontal one does not. Each panel holds different content, so the component grows and shrinks as tabs are switched, and the rail changes height with it. min-height on the grid, set to roughly the tallest panel, stops the jump. Stretching is otherwise what you want here: a grid item fills its row by default, and that default is what gives the rail a background and a dividing border running the full height of the panel next to it. Set align-self: start on the nav and the rail becomes a short block with a border that stops halfway down.

The sliding indicator works the same way it does on horizontal tab navigation, with translateY in place of translateX. Equal label heights matter as much as equal widths did there. If one label wraps to two lines, every step below it is off by that difference, and the bar drifts further out of place with each tab.

For a rail that stays visible while a long panel scrolls, position: sticky on the nav is the addition, and the constraints that come with it are covered on sticky sidebar. When the panels should all be readable at once rather than one at a time, an accordion is the better fit, since tabs imply the panels are alternatives.

CSS properties used

grid-template-columns
160px 1fr splits the component into a fixed rail and a flexible panel. A fixed first track keeps the layout still when label text changes length.
:checked
Matches the active radio. Both the label highlight and the panel reveal key off it.
~
Reaches forward from the radio to the grid container, then descends into it to find the right label and panel.
min-height
On the grid container, so the component does not resize every time a shorter or taller panel is selected.
transform
translateY() slides the indicator down the rail without moving anything in the layout around it.
overflow
hidden on the wrapper keeps the rounded corners intact where the rail's background meets the panel's.

Browser support

FeatureChromeFirefoxSafariEdge
:checked and ~43.53.212
grid575210.116
:target43.53.212
:has()10512115.4105
transform43.53.112
transition455.112

Figures come from Can I Use for CSS3 selectors, CSS Grid Layout, the :has() relational pseudo-class, 2D transforms, and CSS transitions. The radio version needs none of the newer ones. :has() appears only in the :target variant, where it does two jobs nothing else can: showing a default panel when no fragment is present, and marking the rail link that matches the open panel. In a browser without it that variant loads with no panel showing until a link is clicked, so give the first panel a starting state some other way if that matters.

Accessibility notes

The same warning applies here as on any radio driven component: display: none on the inputs takes the whole strip out of the tab order. Hidden with position: absolute; opacity: 0 instead, the radios stay focusable, and a vertical strip then gets the arrow key behavior that matches its orientation, since a radio group responds to up and down as well as left and right.

Screen readers announce a radio group, not a tab list, and no amount of CSS changes that. For a settings page or a docs sidebar that announcement is reasonable, because the reader is choosing between named alternatives. For something that has to be reported as tabs, the ARIA attributes involved have to change as the selection changes, and keeping them in sync is script work.

The :target version replaces the radios with links, which are focusable by default and announced as links. That makes it the more predictable of the two for keyboard readers, at the cost of the page scrolling to the panel on activation. scroll-margin-top on the panel keeps a fixed site header from covering it after the jump.

Give the rail a visible focus indicator that survives against its own background. The rail is usually a darker or lighter surface than the page, and a focus ring tuned to the page background can vanish on it.

What you can build with it

  • Account settings. Profile, billing, notifications and security down the side, each with a panel of form fields. Long labels read better vertically than squeezed across a strip.
  • Documentation sub-navigation. Sections of one long page, where the :target version gives every section a shareable link.
  • Feature comparison. A rail of feature names with a detailed explanation for each, which keeps a marketing page from becoming a wall of text.
  • Multi step review screens. Steps listed vertically with the current one highlighted, as a read-only companion to a multi step form.
  • Admin panel sections. A dozen sections that would wrap onto three lines as a horizontal strip fit comfortably in a column.

Mistakes worth avoiding

  • Sizing the rail track with auto. The column resizes to the longest label, so translating the interface moves the panel sideways and the layout looks unstable for no reason a reader can see.
  • Adding align-self: start to the rail out of habit. A grid item stretching to its row is what draws the rail's background and dividing border down the full height of the panel. Stop the stretch and the border ends under the last label, leaving the panel apparently unbounded on its left.
  • Letting the component's height change with every panel. The rail grows and shrinks under the pointer, and the tab the reader is about to click moves. A min-height on the grid fixes it.
  • Hiding the radios with display: none. Nothing in the component is reachable by keyboard after that, and the failure is invisible in a browser test that only clicks.
  • Moving a sliding indicator by a fixed step when one label wraps to two lines. Every tab after the wrap is off by the extra line height, and the error accumulates down the rail.

Frequently asked questions

How do I build vertical tabs in CSS without JavaScript?
Put one radio per tab before a wrapper element, give the wrapper display: grid with grid-template-columns: 160px 1fr, then use #tab1:checked ~ .layout .panel-1 { display: block } for each tab. The rail is a column of labels pointing at those radios.
Should vertical tabs be a list or a set of radios?
It depends on whether the URL should change. Radios keep the state inside the page and never touch the address bar. Links plus :target put each section in the URL, which makes them shareable and lets the back button move between them.
Why does my vertical tab rail stretch down the whole panel?
Because a grid item fills its row unless told otherwise. That is usually what you want, since it is what runs the rail's background and border the full height. align-self: start on the nav ends it under the last label instead, which is only worth doing when the rail has no background of its own.
How do I keep the layout from jumping when panels have different heights?
Set a min-height on the grid container close to the tallest panel. The alternative is fixing the height and giving the panel overflow-y: auto, which keeps the outer box perfectly still at the cost of an inner scrollbar.
When should I use vertical tabs instead of horizontal ones?
When the labels are phrases rather than single words, when there are more than about five of them, or when the panel content is tall enough that a horizontal strip would scroll out of view while the reader is still in the panel.