HTML tabs without JavaScript

Three CSS techniques give you working tabs with no script. They differ in whether the state lands in the URL, and in how much of the expected keyboard behaviour you lose.

You can build HTML tabs without JavaScript using radio inputs and CSS sibling selectors, and the result works with scripting turned off entirely.

Three techniques exist. The radio one is the default choice, :target puts the state in the URL, and :has() produces the cleanest markup.

A three-tab strip with the second tab active and its panel showing below.
A three-tab strip with the second tab active and its panel showing below.

Three ways to build HTML tabs without JavaScript

Method State in the URL Keyboard Markup cost
Radio inputs No Arrow keys move tabs Hidden inputs before the panels
:target links Yes, as a fragment Tab and Enter Ids on every panel
:has() Depends on the trigger Same as the trigger Lowest

If the choice needs to be linkable, use :target. Otherwise use radios.

The radio method

One name shared by every input makes the group exclusive, which is the behaviour tabs want.

<div class="tabs">
  <input type="radio" name="t" id="t1" checked>
  <label for="t1">Overview</label>

  <input type="radio" name="t" id="t2">
  <label for="t2">Pricing</label>

  <input type="radio" name="t" id="t3">
  <label for="t3">Support</label>

  <div class="panel" data-for="t1">Overview content.</div>
  <div class="panel" data-for="t2">Pricing content.</div>
  <div class="panel" data-for="t3">Support content.</div>
</div>

The inputs come before the panels because the general sibling combinator only looks forwards.

.tabs { display: flex; flex-wrap: wrap; }
.tabs input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.tabs label { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
.tabs input:checked + label { border-bottom-color: #2563eb; font-weight: 600; }
.tabs input:focus-visible + label { outline: 2px solid #2563eb; outline-offset: 2px; }
.panel { display: none; width: 100%; order: 99; }

#t1:checked ~ [data-for="t1"],
#t2:checked ~ [data-for="t2"],
#t3:checked ~ [data-for="t3"] { display: block; }

Two details carry weight. The inputs are positioned off-screen rather than set to display: none, because a hidden input cannot receive focus, and that would remove keyboard access.

order: 99 pushes the panels below the label row inside the flex container, so the tab strip stays on one line.

The tab strip with a focus ring on the third label, reached with the arrow keys.
The tab strip with a focus ring on the third label, reached with the arrow keys.

The :target method

Each tab is an ordinary link to an id in the same document.

<nav class="tabnav">
  <a href="#overview">Overview</a>
  <a href="#pricing">Pricing</a>
</nav>

<section id="overview" class="panel">Overview content.</section>
<section id="pricing" class="panel">Pricing content.</section>
.panel { display: none; }
.panel:target { display: block; }
.panel:first-of-type { display: block; }      /* default */
.panel:first-of-type:not(:target) { display: block; }

The state ends up in the address, so a colleague can be sent straight to the pricing tab. That is the one thing the radio method cannot do.

The cost is that the browser scrolls to the targeted element, which on a long page moves the view. Nothing in CSS stops that, so keep the tabs near the top.

The :has() method. Modern selectors let a parent respond to a child's state, which removes the need to order the markup around the combinator.

.tabs:has(#t2:checked) [data-for="t2"] { display: block; }

Same behaviour, and panels no longer have to be siblings placed after the inputs. Check your support baseline before relying on it, and keep the sibling version as the fallback if the page has to work in older browsers.

Making the strip work on a narrow screen

A tab strip is the layout most likely to break on a phone, because the labels have a fixed width and the screen does not.

Three workable responses, in order of how well they hold up:

  1. Wrap the labels. Let the flex container wrap and accept two rows of tabs. Plain, predictable, and nothing is hidden.
  2. Scroll the strip horizontally. Set overflow-x: auto on the label row and add scroll-snap-type: x mandatory. Keep a visible edge fade so it is obvious there is more.
  3. Collapse to an accordion below a breakpoint. The cleanest result and the most work, because the markup has to suit both forms.
@media (max-width: 40rem) {
  .tabs { flex-wrap: nowrap; overflow-x: auto; }
  .tabs label { white-space: nowrap; }
}

The one to avoid is shrinking the text until the labels fit. Four tabs at nine pixels is a strip nobody can use, and it looks like an accident rather than a decision.

What you give up on accessibility

Be straightforward about this rather than claiming the pattern is complete.

  • Radio tabs are operable. They are in the tab order, arrow keys move the selection, and that is roughly the expected behaviour for a tab strip.
  • The ARIA tab pattern is not achievable. role="tab", aria-selected and aria-controls need to change as the selection changes, and CSS cannot change attributes.
  • :target tabs announce as links, because that is what they are. A screen reader user hears a set of links, not a tab group.
  • Hidden panels are hidden from everyone. display: none removes them from the accessibility tree, which is correct here.

For an internal page, a report, or a set of product notes, the radio version is honest and usable. For a product interface where people expect standard tab semantics, script the real pattern.

Applying role="list" habits from unmarked lists and the rest of semantic markup still helps around the edges.

A default panel that always shows. The radio version needs one input marked checked, or the page loads with an empty space where the content should be.

The :target version has the opposite problem. Nothing is targeted on first load, so every panel is hidden until the reader clicks something.

.panel:first-of-type { display: block; }
.panel:first-of-type:target ~ .panel { display: none; }

Rules like that get fragile quickly. The practical alternative is to link to the first tab from wherever the page is referenced, so the fragment is present from the start.

Either way, decide what the page looks like with no interaction at all. That is the state search engines index, and the state a reader sees for the first second.

A blank rectangle there reads as a broken page rather than as a waiting one.

Checking it outside your folder

The tab page in the HTML file opener with a panel switched and the styling intact.
The tab page in the HTML file opener with a panel switched and the styling intact.

Open the file in the HTML file opener and click through every tab. If the panels all show at once, the CSS is in a separate file that did not travel, so move it into a <style> block.

Then press Tab from the address bar and use the arrow keys. If focus never reaches the strip, the inputs are hidden with display: none rather than positioned off-screen.

Sending the page to a reader

An .html file with tabs in it is still an .html file, so it gets filtered, opens in a code editor, or stalls on a phone.

Paste the HTML into a NOS document. It renders as written, tabs and CSS included, at its own address. Share, then Share link, then Create link, and send the link.

The panels stay interactive for the reader, which a PDF or a screenshot cannot manage. An accordion without JavaScript is the better structure if the sections are long rather than parallel.

Questions people ask

Can you make tabs in HTML without JavaScript?

Yes. The usual method is a set of radio inputs sharing one name, with labels styled as the tab strip and CSS sibling selectors revealing the matching panel. No script runs and the behaviour survives with scripting disabled.

Which method puts the selected tab in the URL?

The :target method. Each tab is a link to an id on the same page, so the address ends in a fragment and the chosen tab can be linked to or bookmarked. Radio-based tabs keep the state entirely in the DOM and reset on reload.

Are CSS-only tabs accessible?

Partly. Radio tabs are reachable and operable from the keyboard, with arrow keys moving between them, which is close to what people expect. What you cannot do without script is apply the full tab ARIA pattern, because those attributes have to change as the selection changes.

Why does clicking a tab jump the page down?

That is the :target method scrolling to the linked element. Keeping the panel container at a fixed position, or using the radio method instead, avoids it. Scroll anchoring cannot be fully disabled for fragment navigation.

Keep reading