Type something to search...
How to Create Tabs Using Only CSS

How to Create Tabs Using Only CSS

Tabs are a tidy way to put several related panels of content in the same space: product descriptions and reviews, code samples in different languages, account settings split into sections. Most tab components ship with a JavaScript controller that swaps classes and manages focus. That's often the right call, but there are plenty of situations, such as documentation sites, static pages, email-adjacent landing pages, and prototypes, where you'd rather not ship a script for something this simple.

CSS can't run event handlers, but it can react to state that HTML already manages. A checked radio button, the element targeted by the URL fragment, and an open <details> element are all forms of state that CSS selectors can see. In this guide we'll build tabs three ways, compare their trade-offs, and look closely at keyboard and screen reader behavior so the result isn't just pretty but usable.

How CSS-Only Tabs Work

Every CSS-only tab pattern follows the same idea:

  1. Some native element holds the "which tab is active" state.
  2. A selector detects that state.
  3. That selector shows the matching panel and highlights the matching tab.

The difference between techniques is which element holds the state. Radio buttons are the most robust choice, so we'll start there.

Method 1: Radio Buttons and :has()

A group of radio buttons that share a name can only have one checked option at a time, which is exactly how tabs behave. Clicking a <label> checks its associated radio, so the labels become our tab buttons.

The markup

<div class="tabs">
  <div class="tabs__list">
    <input
      class="tabs__radio"
      type="radio"
      name="product-tabs"
      id="tab-details"
      checked
    />
    <label class="tabs__tab" for="tab-details">Details</label>

    <input
      class="tabs__radio"
      type="radio"
      name="product-tabs"
      id="tab-specs"
    />
    <label class="tabs__tab" for="tab-specs">Specifications</label>

    <input
      class="tabs__radio"
      type="radio"
      name="product-tabs"
      id="tab-reviews"
    />
    <label class="tabs__tab" for="tab-reviews">Reviews</label>
  </div>

  <div class="tabs__panel" data-panel="tab-details">
    <h3>Details</h3>
    <p>
      Hand-stitched leather with a waxed cotton lining. Fits a 14-inch laptop.
    </p>
  </div>

  <div class="tabs__panel" data-panel="tab-specs">
    <h3>Specifications</h3>
    <ul>
      <li>Weight: 1.2 kg</li>
      <li>Dimensions: 38 x 28 x 9 cm</li>
    </ul>
  </div>

  <div class="tabs__panel" data-panel="tab-reviews">
    <h3>Reviews</h3>
    <p>"Still looks new after two years of daily use." - Priya</p>
  </div>
</div>

Hiding the radios without breaking them

We don't want to see the radio circles, but we must not use display: none on them. A radio with display: none can't receive keyboard focus, which would make the tabs unreachable for keyboard users. Instead, hide them visually while leaving them in the accessibility tree:

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

Styling the tab strip

.tabs {
  max-width: 44rem;
  font-family: system-ui, sans-serif;
}

.tabs__list {
  display: flex;
  gap: 0.25rem;
  border-bottom: 2px solid #e2e8f0;
}

.tabs__tab {
  padding: 0.75rem 1.1rem;
  font-weight: 600;
  color: #64748b;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  transition:
    color 0.15s ease,
    border-color 0.15s ease;
}

.tabs__tab:hover {
  color: #0f172a;
}

The negative margin-bottom pulls each tab down over the list's bottom border, so the active tab's underline sits exactly on top of the gray line instead of below it.

Showing the active tab and panel

Now for the part that does the work. The adjacent sibling combinator (+) connects each radio to the label immediately after it:

.tabs__radio:checked + .tabs__tab {
  color: #2563eb;
  border-bottom-color: #2563eb;
}

.tabs__radio:focus-visible + .tabs__tab {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
  border-radius: 4px;
}

For the panels, we use :has(). The .tabs container checks whether it contains a specific checked radio, and if so, shows the matching panel:

.tabs__panel {
  display: none;
  padding: 1.25rem 0.25rem;
}

.tabs:has(#tab-details:checked) [data-panel="tab-details"],
.tabs:has(#tab-specs:checked) [data-panel="tab-specs"],
.tabs:has(#tab-reviews:checked) [data-panel="tab-reviews"] {
  display: block;
}

That's a complete, working tab component. Click a label and the radio checks, :has() matches, and the right panel appears.

The :has() selector is supported in all current major browsers. It's what makes this layout flexible, because the panels don't need to be siblings of the radios.

Why :has() makes this easier

Before :has(), CSS could only select elements that come after the state-holding element, using ~ or +. That forced awkward markup where each radio had to sit before all the panels at the same nesting level:

<div class="tabs-legacy">
  <input type="radio" name="t" id="t1" checked />
  <input type="radio" name="t" id="t2" />
  <nav>
    <label for="t1">One</label>
    <label for="t2">Two</label>
  </nav>
  <div class="panel p1">Panel one</div>
  <div class="panel p2">Panel two</div>
</div>
.tabs-legacy .panel {
  display: none;
}

#t1:checked ~ .p1,
#t2:checked ~ .p2 {
  display: block;
}

#t1:checked ~ nav [for="t1"],
#t2:checked ~ nav [for="t2"] {
  color: #2563eb;
}

It works, and it's a decent fallback if you must support very old browsers, but the markup is harder to reason about. With :has(), you can structure the HTML the way it reads.

Method 2: The :target Pseudo-Class

The :target pseudo-class matches the element whose id matches the URL fragment. If the URL ends with #reviews, then #reviews:target matches. Links become the tabs.

<div class="tabs-target">
  <nav class="tabs-target__list">
    <a href="#overview">Overview</a>
    <a href="#pricing">Pricing</a>
    <a href="#faq">FAQ</a>
  </nav>

  <section id="overview" class="tabs-target__panel">
    Overview content...
  </section>
  <section id="pricing" class="tabs-target__panel">Pricing content...</section>
  <section id="faq" class="tabs-target__panel">FAQ content...</section>
</div>
.tabs-target__panel {
  display: none;
}

.tabs-target__panel:target {
  display: block;
}

/* Show the first panel when no panel is targeted */
.tabs-target:not(:has(.tabs-target__panel:target))
  .tabs-target__panel:first-of-type {
  display: block;
}

/* Highlight the active link */
.tabs-target:has(#overview:target) a[href="#overview"],
.tabs-target:has(#pricing:target) a[href="#pricing"],
.tabs-target:has(#faq:target) a[href="#faq"] {
  color: #2563eb;
  border-bottom-color: #2563eb;
}

The :not(:has(...)) rule solves the classic :target problem: when the page loads without a fragment, no panel is targeted, and nothing shows. This rule displays the first panel as a default.

The :target approach has one real strength: every tab has a shareable URL. Sending someone a link to /product#pricing opens the Pricing tab directly.

It also has notable drawbacks:

  • The page jumps. Navigating to a fragment scrolls the target into view. If your tabs are halfway down the page, clicking one scrolls the page so the panel sits at the top, which feels jarring.
  • History clutter. Each click adds a history entry, so the Back button steps through tabs before leaving the page. Some users like that; many don't.
  • Only one target per page. If you have two tab groups on the same page, activating a tab in one group un-targets the other group, which then snaps back to its default.

You can soften the scroll jump by adding scroll-margin-top to the panels, but you can't eliminate it entirely without JavaScript. Use :target tabs when shareable URLs matter more than smooth interaction, such as documentation pages.

Method 3: Exclusive <details> Elements

Grouping several <details> elements with the same name attribute makes them mutually exclusive, so opening one closes the others. That's close to tab behavior, and on narrow screens it naturally becomes an accordion.

<div class="tabs-details">
  <details name="plan" open>
    <summary>Monthly</summary>
    <div class="panel">$12 per month, cancel anytime.</div>
  </details>
  <details name="plan">
    <summary>Yearly</summary>
    <div class="panel">$120 per year, two months free.</div>
  </details>
</div>

To lay the summaries out in a row with the panel underneath, you can use CSS Grid on the container and place each summary in the first row. The panel content is harder to position this way because it lives inside each <details>, and a closed <details> element hides its content. Making this look exactly like classic tabs takes more layout work than the radio method, and screen readers will announce these as disclosure buttons rather than tabs. I recommend this approach when an accordion-like fallback is desirable, not as a general tab solution.

Adding a Sliding Indicator

A sliding underline that moves between tabs is a nice finishing touch. With the radio method and a fixed number of equal-width tabs, you can compute the position from which radio is checked:

.tabs__list {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

.tabs__list::after {
  content: "";
  position: absolute;
  bottom: -2px;
  left: 0;
  width: calc(100% / 3);
  height: 2px;
  background: #2563eb;
  transition: translate 0.25s ease;
}

.tabs:has(#tab-specs:checked) .tabs__list::after {
  translate: 100% 0;
}

.tabs:has(#tab-reviews:checked) .tabs__list::after {
  translate: 200% 0;
}

.tabs__radio:checked + .tabs__tab {
  border-bottom-color: transparent;
}

.tabs__tab {
  text-align: center;
}

Because translate percentages are relative to the element's own width, 100% moves the indicator exactly one tab to the right. Remember to remove the per-tab underline so you don't get two.

For tabs of different widths, the pure CSS version gets complicated. Anchor positioning can tether the indicator to whichever tab is active, but at the time of writing it isn't supported in every browser, so a fixed-width layout is the safer choice for production.

Making the Tabs Responsive

A row of tabs can overflow on small screens. There are two common fixes.

Let the tab list scroll horizontally:

.tabs__list {
  overflow-x: auto;
  scrollbar-width: thin;
}

.tabs__tab {
  white-space: nowrap;
  flex-shrink: 0;
}

Or stack them vertically on narrow screens:

@media (width < 36rem) {
  .tabs__list {
    flex-direction: column;
    border-bottom: 0;
  }

  .tabs__tab {
    border-bottom: 0;
    border-left: 3px solid transparent;
    margin-bottom: 0;
  }

  .tabs__radio:checked + .tabs__tab {
    border-left-color: #2563eb;
  }
}

The width < 36rem form is the media query range syntax, which is supported in all current major browsers. If you need to support older ones, write it as max-width: 35.99rem.

Accessibility: What CSS-Only Tabs Can and Can't Do

This part deserves honesty. The WAI-ARIA tabs pattern expects elements with role="tablist", role="tab", and role="tabpanel", aria-selected that updates as the user switches tabs, and arrow-key navigation between tabs. CSS can't update ARIA attributes, so a pure CSS component can't fully implement that pattern.

What you get with the radio approach is still quite reasonable:

  • Keyboard access : Tab moves focus into the radio group, and the arrow keys move between radios, checking each one and revealing its panel. That's very close to how ARIA tabs are expected to behave.
  • Clear state : Screen readers announce each option as a radio button with its label and checked state, for example "Specifications, radio button, 2 of 3, checked." Users understand what's selected even though the control is called a radio rather than a tab.
  • Hidden panels stay hidden : Using display: none on inactive panels removes them from the accessibility tree, so screen readers don't read content from tabs that aren't visible.

Things to avoid:

  • Don't add role="tab" to the labels. Declaring tab semantics without updating aria-selected is worse than using honest radio semantics, because it makes promises the component can't keep.
  • Wrap the group in a <fieldset> with a <legend> if context helps, or add an aria-label to the list container, so users know what the radios control.
  • Make the focus indicator obvious. Because the actual radio is invisible, the label must show focus, which is what the :focus-visible + .tabs__tab rule does.

If your tabs are central to the application, such as an editor with many panes, use a JavaScript implementation of the full ARIA pattern. For content-oriented tabs, the radio method is a solid, honest choice.

Common Pitfalls

  1. Using display: none on the radios : This silently breaks keyboard access. Hide them visually with opacity and positioning instead.
  2. Forgetting the default state : Always mark one radio as checked in the HTML, otherwise every panel is hidden on load.
  3. Duplicate ids with multiple tab groups : Each tab group needs its own name and unique ids. Reusing IDs across groups makes labels check the wrong radio.
  4. Panels with visibility: hidden : Invisible panels still take up space and can leave a large empty gap. Use display: none, or stack panels in the same grid cell if you want to animate between them.
  5. Printing : When a user prints the page, hidden panels don't print. Consider a print stylesheet that shows every panel:
@media print {
  .tabs__panel {
    display: block;
  }

  .tabs__list {
    display: none;
  }
}

Conclusion

CSS-only tabs are a great example of letting HTML handle state and CSS handle presentation. The radio button method, combined with :has(), is the most robust: it supports keyboard navigation, lets you structure the markup naturally, and works in every current browser. The :target method trades smooth interaction for shareable URLs, and exclusive <details> elements are a useful option when you want the component to double as an accordion.

Pick the technique that matches your content, keep the focus styles visible, be honest about the semantics, and reach for JavaScript only when your tabs need the full ARIA pattern. For a lot of content on the web, a few lines of CSS are all it takes.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading