Browsers already keep track of which radio input is checked and which details element is open, and the URL fragment works the same way. CSS can select on all of it, which is enough to show one panel at a time without a click handler.

Product spec panels and pricing toggles are the usual places you see this, along with settings screens. The content is already on the page, so only its visibility changes.

The newest trigger is the name attribute on details elements, which became Baseline newly available on 3 September 2024 (web-platform-dx, 2024).

All three methods share one limit. The WAI-ARIA tabs pattern needs aria-selected to change on every activation, and no CSS selector can change an attribute.

Which CSS tabs method should you use?

See the Pen
Tabbed Content Section
by katiekatsup (@katiekatsup)
on CodePen.

Radio inputs cover plain content switching. :target is the one to pick when each panel needs its own URL. A details group gives you an exclusive accordion dressed up as tabs. Once the tabs have to follow the full WAI-ARIA tabs pattern, you need JavaScript and ARIA.

None of the first three needs a click handler written in JavaScript, since the browser does the bookkeeping. The fourth keeps CSS for the visuals and adds a short script that updates aria-selected and moves focus.

MethodState held byDeep linkKeyboard behavior
Radio inputChecked radio (:checked)NoArrow keys, while inputs stay focusable
:targetURL fragmentYesTab stop per link, Enter
details groupOpen attribute, grouped by nameNoTab stop per summary, Space or Enter
CSS plus JavaScriptaria-selectedOnly if scriptedArrow keys, optional Home and End

If readers need to bookmark or share one panel, use :target.

If a screen reader has to announce the widget as tabs, write the script.

Anything else, pricing toggles and product spec panels included, is a job for radio inputs. That’s also the method most ranking tutorials teach.

How to build CSS tabs with radio inputs

The whole method rests on one rule. A checked radio input shows the panel that follows its label, and the steps below just set that up.

  1. Add one radio input per tab inside a wrapper, give every input the same name, and put the checked attribute on the first.
  2. Follow each input with a label whose for value matches the input id.
  3. Follow each label with its panel, a div with the class panel.
  4. Set the wrapper to display: flex with flex-wrap: wrap, and give each panel order: 1 and width: 100% so the labels form a row above the panels.
  5. Hide every panel with display: none, then show the active one with a sibling selector.
  6. Hide the radio inputs visually and style the checked label as the active tab.

There’s no click handler to write or debug, and the tabs still work with scripts blocked. Panels stay in the DOM, so switching has no loading delay.

The downsides are real, though. The selected tab isn’t stored in the URL, so a shared link always opens the default tab. Assistive technology announces radio buttons, not tabs. Panels must follow their labels in source order, and the panel count is fixed in the markup.

Markup structure

See the Pen
Pure CSS Tabs
by Álvaro (@alvarotrigo)
on CodePen.

Sibling order is the rule that cannot bend. Each input comes before its label, and each label before its panel.

<div class="tabs">
<input type="radio" name="tabs" id="tab-1" checked>
<label class="tab-label" for="tab-1">Specs</label>
<div class="panel">...</div>

<input type="radio" name="tabs" id="tab-2">
<label class="tab-label" for="tab-2">Reviews</label>
<div class="panel">...</div>
</div>

Move a panel before its label and the selector no longer reaches it, so that tab opens an empty area.

Showing the active panel

The next-sibling combinator (+) ties a panel to the checked input and label placed directly before it, as in input:checked + .tab-label + .panel. The subsequent-sibling combinator (~) reaches any later sibling, which suits layouts with all inputs first and all panels last, though it needs one rule per input, such as #tab-1:checked ~ #panel-1.

.tabs { position: relative; display: flex; flex-wrap: wrap; }
.tabs input { position: absolute; opacity: 0; }
.panel { display: none; order: 1; width: 100%; }
.tabs input:checked + .tab-label { border-bottom: 3px solid currentColor; }
.tabs input:checked + .tab-label + .panel { display: block; }

Hide the inputs with position: absolute and opacity: 0, never with display: none. An element set to display: none leaves the tab order, so keyboard users lose the radio group.

Simplifying selectors with :has()

MDN Web Docs lists :has() as Baseline widely available, and as available across browsers since December 2023 (checked October 2026).

With it, the wrapper reacts to the checked input directly: .tabs:has(#tab-2:checked) #panel-2 { display: block; }. Inputs and labels no longer need to sit before the panels, so the tab bar and the panels can live in separate containers.

The failure mode is all-or-nothing. Per the same MDN page, a browser without :has() drops the entire selector block, so every panel stays hidden.

If the audience includes older browsers, stay with the sibling selectors, which carry no such gap. Anchor :has() to the .tabs wrapper rather than body, because MDN’s performance guidance says the browser re-checks the condition on every DOM change inside the anchor.

How do :target and details elements work as CSS tabs?

With :target, the URL hash decides which panel shows. Details elements work differently, since the name attribute keeps only one of them open at a time. Neither method uses a form control, so neither gets the arrow-key behavior of a radio group for free.

Tabs with :target

Each tab is a link to a panel id, such as href="#specs", and the panel shows only while it is the document’s target element.

The markup is a row of anchor links followed by panels whose ids match the hash values. Panels start at display: none, and .panel:target sets display: block. Without a hash nothing is targeted, so the first panel needs its own fallback rule.

As of October 2026, MDN Web Docs lists :target as Baseline widely available since July 2015. The browser sets the target element at document load and on history.back(), history.forward() and history.go() calls, but not on pushState() or replaceState().

Because the hash lives in the URL, every tab is deep-linkable and the Back button moves between earlier tabs.

One documented limit: :target does not work inside a web component, because the shadow root does not pass the target element down to the shadow tree.

Every panel gets its own URL and Back works. There’s no form control in the markup either. The catch is that each click jumps the page toward the target panel, and every tab adds a history entry that Back has to step through.

Tabs with details elements

The web-platform-dx web-features data lists the details name attribute as Baseline newly available since 3 September 2024. Chrome 120 and Safari 17.2 shipped it in December 2023, and Firefox 130 completed the set in September 2024.

Give every details element the same name and the browser closes the open one when another opens. The elements do not have to be siblings, because the name groups them, not the DOM order.

MDN’s styling example gives a details element a look similar to a tabbed interface, with the summary acting as the tab. Placing all summaries in one row above a shared panel still takes extra layout CSS, because each panel stays inside its own details element.

If several grouped elements carry open, only the first in source order renders open. Clicking the open summary closes it, so a group can end up with no panel visible.

Engines without name support can use a short polyfill that listens for the toggle event and closes the other open elements with the same name. Where the toggle event does not fire, the polyfill does nothing, which Chrome for Developers calls acceptable progressive enhancement.

Because this method is an exclusive accordion underneath, CSS accordion techniques for styling and animation carry over.

On the plus side, opening is exclusive without a script, and each summary is focusable and toggles with Space or Enter. On the minus side, MDN lists the implicit role as group and permits no role attribute, so the tab role cannot be added. A tab set should always show one panel, and this group can show none. The tab-bar layout needs extra CSS too.

Are CSS tabs accessible to keyboard and screen reader users?

See the Pen
Pure CSS Accessible Tabs
by Louis Lazaris (@impressivewebs)
on CodePen.

Only partly. CSS selectors show and hide panels, but they cannot write aria-selected, so a CSS-only tab set meets part of the keyboard model and misses the state half of the WAI-ARIA tabs pattern.

The W3C ARIA Authoring Practices Guide (APG) defines the target behavior. Its tabs pattern is built from the tablist, tab and tabpanel ARIA roles.

Roles and states

The tablist is the container for the tabs, labeled with aria-label or aria-labelledby. Each tab points to its panel through aria-controls, and the active tab has aria-selected set to true while every other tab has false. Each tabpanel points back to its tab through aria-labelledby. On a vertical tablist, set aria-orientation to vertical, since horizontal is the default.

Roles and the aria-controls and aria-labelledby references are static, so markup can carry them wherever the element permits a role. The aria-selected state changes on every activation, and no CSS selector can change an attribute.

In the radio method the input keeps its radio role, so assistive technology announces radio buttons and not tabs. The details method has a harder limit, because the MDN reference permits no role attribute on details.

Keyboard interaction by method

The APG expects fairly specific behavior. Tab moves focus into the active tab, and the next Tab press leaves the tablist for the panel. Left and Right Arrow move between tabs in a horizontal list, Up and Down in a vertical one. Space or Enter activates a tab that did not activate on focus. Home and End jump to the first and last tab, which is optional.

The CSS methods deliver less. With radio inputs, Tab lands on the checked radio, and arrow keys move focus and selection together, but only while the input stays focusable. :target gives each link its own Tab stop, and arrow keys scroll the page as usual. Details elements are similar, with each summary as a Tab stop that Space or Enter toggles.

Read side by side, the radio method lands closest to the APG, because arrow keys move focus and select in one step, which matches the automatic-activation variant. The APG recommends that variant when panels display without noticeable latency, and CSS panels already sit in the DOM, so no latency exists.

Beyond the missing tab role, one gap remains: Tab leaves the radio group for the next focusable element, which is the panel only when the panel carries tabindex="0". The APG asks for that attribute whenever a panel holds no focusable content.

The invisible input cannot show focus, so draw the ring on its label: .tabs input:focus-visible + .tab-label { outline: 2px solid; }. The :focus-visible pseudo-class matches when the browser decides focus should be visible, which is the case for keyboard use.

How to style the active tab and animate the indicator

See the Pen
Pure CSS Tabs
by Álvaro (@alvarotrigo)
on CodePen.

Active styling should come from the same selector that shows the panel. Animate the indicator with transform on a pseudo-element and fade the panel in with opacity. Keep the selected, hover and focus states visually distinct so none reads as another.

Replace the border rule from the radio build with an underline that scales out from the center. A transform animation stays smooth because it does not trigger layout.

.tab-label { position: relative; }
.tab-label::after {
content: "";
position: absolute;
inset: auto 0 0 0;
height: 3px;
background: currentColor;
transform: scaleX(0);
transition: transform 0.2s ease;
}
.tabs input:checked + .tab-label::after { transform: scaleX(1); }

A single bar that slides between tabs works only when the tabs have equal widths, because a translateX percentage is relative to the bar’s own width.

Tune duration and easing with a CSS transition generator, and keep the indicator short enough that it never delays the panel.

Fading the panel runs into a limit: display: none cannot transition, because display is a discrete property. Two features fix it. The value transition-behavior: allow-discrete lets display take part in a transition, and @starting-style supplies the before-open state (web.dev).

.tabs input:checked + .tab-label + .panel {
display: block;
opacity: 1;
transition: opacity 0.25s ease, display 0.25s allow-discrete;
}
@starting-style {
.tabs input:checked + .tab-label + .panel { opacity: 0; }
}

Put the transition on the checked rule only. A fade-out on the leaving panel would keep two panels in the flow for the length of the transition and shift the page height.

Engines without the two features skip the effect and show the panel at once, which is an acceptable fallback.

For the states themselves, selected gets a thicker border and a heavier weight, so the cue survives high contrast settings that drop color (the APG tabs example makes the active tab taller, with a thicker border). Hover gets a background tint and nothing more, lighter than the selected cue. Focus gets a ring separated from the selection border by a small gap, so the two stay separately visible.

MDN Web Docs notes that motion-based animation can trigger discomfort for people with vestibular motion disorders. Switch the transitions off for anyone who asks for reduced motion.

@media (prefers-reduced-motion: reduce) {
.tab-label::after,
.tabs input:checked + .tab-label + .panel { transition: none; }
}

How to make CSS tabs responsive

See the Pen
Responsive pure CSS tabs & accordion
by Álvaro (@alvarotrigo)
on CodePen.

Pick the width where the longest label stops fitting, then either stack the tabs into an accordion or keep one row that scrolls sideways. Panel count and panel length decide which one fits.

Nielsen Norman Group’s guidance is that tabs suit a few long sections, while accordions fit many short ones.

LayoutFitsExtra CSS cost
Stacked accordionMany short panelsReset order, switch to a column
Scrollable tab barFew long panelsLabels in their own container
Details group left stackedMany short panels, exclusive openingDrop the row layout

The radio markup makes the stacked layout cheap, because each panel already follows its own label. Set the breakpoint with a media query tied to the width where the longest label stops fitting, not to a device size.

.tabs { display: flex; flex-direction: column; }

@media (min-width: 40em) {
.tabs { flex-direction: row; flex-wrap: wrap; }
.panel { order: 1; width: 100%; }
}

Written this way, the stacked layout is the base and the tab row is the enhancement, which is the mobile-first order.

A scrolling bar needs the labels in a container of their own, with overflow-x: auto on it, so it cannot use the input-label-panel triplet. Use the :has() layout from the radio section instead, where the tab bar and the panels live in separate containers.

Size container queries respond to the space a component receives, not to the viewport. Put container-type: inline-size on the parent of .tabs, because a container cannot style itself, then swap @media for @container (min-width: 30em) in the rule above.

When labels shrink to fit a narrow row, each one still needs a pointer target of at least 24 by 24 CSS pixels, or enough spacing that a 24-pixel circle centered on it touches no other target (W3C, WCAG 2.2, success criterion 2.5.8, level AA).

Where CSS-only tabs stop working

CSS-only tabs stop working when the tab set needs behavior that static markup cannot hold, such as panels loaded on demand or a tab count that changes at runtime. Manual activation is another case.

CSS cannot fetch content, and the APG recommends automatic activation only when all panel content already sits in the DOM. On-demand panels therefore need a script and manual activation.

The radio method selects on every arrow press, so a keyboard user cannot pass over a tab without opening it.

Tabs that open or close at runtime are out too. The APG lists the Delete key for closable tabs as optional behavior, and only a script can remove a tab together with its panel.

Find in page is a smaller annoyance. Text inside a panel set to display: none is not found, while web.dev’s What’s new in web review from Google I/O 2025 notes that browsers now auto-expand details elements that contain a find-in-page match.

Every feature used so far has its own support history, so cross-browser compatibility becomes a per-feature question. The fallback differs too. Engines without the animation features show the panel at once, which costs nothing. For the focus ring, MDN Web Docs documents a fallback that repeats the ring on :focus inside @supports not selector(:focus-visible).

As of October 2026, the details element has been available across browsers since January 2020 (MDN Web Docs). Size container queries became Baseline newly available in February 2023 and Widely available in August 2025 (web.dev). @starting-style and transition-behavior: allow-discrete became Baseline newly available with Firefox 129 in August 2024 (web.dev). :focus-visible has been available across browsers since March 2022 (MDN Web Docs), and its Widely available label dates from 14 September 2024 (web-platform-dx web-features).

The two dates for :focus-visible look like a conflict, but they measure different events. MDN dates the point where every core browser shipped the feature, and web-features dates the Widely available label, which Baseline grants 30 months later (web.dev).

The :has() date quoted earlier follows the same pattern: it marks interoperability, and the Widely available label arrived 30 months later, in June 2026.

Add the script when any failure above applies, or when the design needs aria-selected and a roving tabindex. The APG’s automatic-activation example sets tabindex="-1" on every unselected tab, so only the selected tab sits in the Tab sequence.

A script writes aria-selected and the roving tabindex that the CSS methods cannot. It also supports manual activation, closable tabs and panels fetched on demand.

The cost is more code to ship, test and maintain. The APG labels its own example as not intended for production and warns of support gaps in some browser and assistive technology pairs, especially on mobile and touch devices. And half-finished ARIA does more harm than none.

Skip role="tab" on a CSS-only set. The APG puts it bluntly: “No ARIA is better than Bad ARIA.”

Tab roles without the matching keyboard model promise behavior the widget does not deliver.

CSS tabs examples

Texts & Icons Fusion

See the Pen
Material Tabs & Pages
by Reza (@reza-h)
on CodePen.

Close ‘Em Up Tabs

See the Pen
Exploding CSS Tabs
by Álvaro (@alvarotrigo)
on CodePen.

Outside-the-Box CSS Tab

See the Pen
II just another Css tabs (:checked)
by Kseso (@Kseso)
on CodePen.

Moody CSS Tabs

See the Pen
CSS Tabs Revisited
by Eric Sadowski (@ejsado)
on CodePen.

Icon-Loaded Tabs

See the Pen
Pure CSS Tabs With Indicator
by Álvaro (@alvarotrigo)
on CodePen.

The Understated Tab

See the Pen
Simple CSS tabs with shadow
by Nazar Ilkiv (@ekscentrysytet)
on CodePen.

Hue-Shift Tabs

See the Pen
Pure CSS Color Tabs (no label)
by ari (@tari)
on CodePen.

The Folder-Inspired Tab

See the Pen
css tabs
by massimo (@_massimo)
on CodePen.

Zoom In, Zoom Out Tabs

See the Pen
Tabby Tabs
by Álvaro (@alvarotrigo)
on CodePen.

The Chic Tab

See the Pen
Pure CSS Tabs Widget
by SitePoint (@SitePoint)
on CodePen.

The Foodie’s Delight

See the Pen
Recipe card with css tabs
by Gisete Kindahl (@gk)
on CodePen.

Tab Dance

See the Pen
Adaptive tabs
by Lewi Hussey (@Lewitje)
on CodePen.

The Animated Artist

See the Pen
Animated Tab Bar
by abxlfazl khxrshidi (@abxlfazl)
on CodePen.

Center Stage Tabs

See the Pen
Pure CSS Tabs
by Álvaro (@alvarotrigo)
on CodePen.

Designer’s Delight

See the Pen
Tab bar active animation – CSS only
by Heiko Bublitz (@he1ko)
on CodePen.

Elastic Fantastic

See the Pen
Tabs with elastic border
by Irem Lopsum (@iremlopsum)
on CodePen.

The Showstopper

See the Pen
Add file tab bar animation
by Live_Fast_Die_Last (@codepen_codepen)
on CodePen.

The Iconic Choice

See the Pen
responsive tabs
by Álvaro (@alvarotrigo)
on CodePen.

Fluid Flow

See the Pen
Fluid tab bar
by Shyam (@Shtam3x)
on CodePen.

Pure Pizzazz

See the Pen
CSS3 Tabs by Sorax
by YozhEzhi (@YozhEzhi)
on CodePen.

Beaming Tabs

See the Pen
Light & Sexy Tabs
by Alex Lime (@alexlime)
on CodePen.

Rainbow Rider

See the Pen
css tabs clip-path
by Álvaro (@alvarotrigo)
on CodePen.

The Top Tier

See the Pen
Material Expanding Overlay – Top-Aligned Tabs
by Tony Tomlinson (@ibb)
on CodePen.

Simple Elegance

See the Pen
Simple pure css tabs
by Sean B (@seanysean)
on CodePen.

Tab Tumble

See the Pen
Pure CSS Tabs that scale down to menu
by Álvaro (@alvarotrigo)
on CodePen.

Snapshot Sensation

See the Pen
Tab bar photo/video switch
by Aaron Iker (@aaroniker)
on CodePen.

High & Mighty

See the Pen
Transitioning Between Variable Heights with CSS Tabs
by Joseph Fusco (@fusco)
on CodePen.

Android-esque Elegance

See the Pen
Android Tab Bar in CSS
by Niall Paterson (@paterson)
on CodePen.

Tab it Right

See the Pen
NoScript Tabs Menu with Images
by Michael Rosata (@mrosata)
on CodePen.

Map it Out

See the Pen
Materialize-Tab/Googlemap
by Ian (@zzzian)
on CodePen.

CSS Tabs FAQ

Can CSS tabs be vertical or styled as pills?

See the Pen
Vertical tabs with smooth animation
by Benjamin Koehler (@codesuey)
on CodePen.

Yes, and both are layout and styling changes only. For vertical tabs, place the labels in a left column with grid or flex and keep the panels beside them. Pills need just a border-radius and a background on the checked label.

How do you add icons to CSS tabs?

See the Pen
Featured Tabs
by Richard Gonyeau (@RGonyeau)
on CodePen.

Put an inline SVG inside the label, next to the text. For icon-only tabs, keep the text in the label and hide it visually, so the tab still has an accessible name.

Do CSS tabs keep the selected tab after a page reload?

Only the :target method does so reliably, because the URL fragment survives a reload.

Radio state is form state, which browsers restore inconsistently, and the open state of details elements is not stored in the URL, so a shared link always opens the default tab.

Will search engines index content in hidden tab panels?

Yes, when the panels are in the HTML source. A crawler that reads the markup sees every panel, even those hidden with display: none.

Panels fetched by a script after a click do not exist until that click, so avoid that pattern for content that must rank.

Does Bootstrap or Tailwind need JavaScript for tabs?

Bootstrap does. Its tab component runs on its own JavaScript plugin and data attributes. Tailwind ships no tab component, so you build CSS tabs yourself, typically with the peer and peer-checked utilities on radio inputs.

Why does the page jump when panels have different heights?

The container resizes to fit whichever panel is visible, because hidden panels are removed from layout. To stop the jump, stack all panels in one grid cell and hide inactive ones with visibility: hidden, so the container always matches the tallest panel.

How to Test CSS Tabs Before Shipping

Start with the keyboard alone. A screen reader announces whatever receives focus, so a broken focus path corrupts every result after it.

Run the screen reader pass second, and leave the narrow viewport for last, because layout changes leave the semantics untouched.

Verified on 13 October 2026, the details name attribute reaches Widely available status in March 2027, 30 months after its September 2024 Baseline date.

From that month, a details-based tab set carries no support caveat for core browsers. Recheck the support figures at that date before changing the recommended method.

Bogdan Sandu
Latest posts by Bogdan Sandu (see all)