Source Code

<div class="demo">
  <div class="tabs">
    <input type="radio" name="tabs" id="tab1" class="tab-input" checked />
    <input type="radio" name="tabs" id="tab2" class="tab-input" />
    <input type="radio" name="tabs" id="tab3" class="tab-input" />

    <div class="tab-list" role="tablist">
      <label for="tab1" class="tab-label">Overview</label>
      <label for="tab2" class="tab-label">Pricing</label>
      <label for="tab3" class="tab-label">FAQ</label>
      <span class="tab-indicator"></span>
    </div>

    <div class="panel panel1">
      <h3>Overview</h3>
      <p>A quick summary of the product, its core features, and who it's built for. This panel is shown by default because its radio input carries the checked attribute.</p>
    </div>
    <div class="panel panel2">
      <h3>Pricing</h3>
      <p>Three simple plans: Free, Pro, and Team. Every plan includes unlimited projects and community support, with Pro and Team adding priority support and SSO.</p>
    </div>
    <div class="panel panel3">
      <h3>FAQ</h3>
      <p>Common questions about billing, cancellation, and data export are answered here, all switched with zero JavaScript.</p>
    </div>
  </div>
</div>

CSS Only Tab Switcher — Radio Button Hack, No JavaScript

Tab Switcher — CSS Only Radio Hack (No JavaScript) · Navigation · Plain HTML & CSS · Live preview

What's included

Features

Mutual exclusivity guaranteed natively by grouping radios under a shared name attribute — no manual state coordination
Sliding .tab-indicator pill uses pre-computed calc()/translateX() values, no JavaScript measurement needed
Panels switch via #tabN: checked ~ .panelN sibling selectors, exactly one visible at all times
Native keyboard support — Tab and Arrow keys move between tabs using the browser's built-in radio-group behavior
Radio inputs hidden with opacity: 0/pointer-events:none, preserving label click-through and focus semantics
:focus-visible ring surfaced via :has() on the label for visible keyboard focus feedback
fadeIn keyframe animation plays automatically on the newly-shown panel each time the radio state changes
Zero JavaScript — functions identically in sanitized CMS blocks, Markdown-rendered docs, and script-stripped embeds

About this UI Snippet

Tab Switcher Built on the Radio Input Hack — Zero JavaScript

Screenshot of the Tab Switcher — CSS Only Radio Hack (No JavaScript) snippet rendered live

Tabs are, structurally, a single-choice input: exactly one panel is visible at a time, which maps directly onto <input type="radio">'s native "only one option in a group can be checked" behavior. This snippet uses that mapping directly instead of simulating it with JavaScript.

Why radio, not checkbox

A checkbox hack works well for independent booleans (open/closed), but tabs are mutually exclusive — selecting one must deselect the others. Grouping several <input type="radio" name="tabs"> elements under the same name attribute gives the browser this mutual-exclusivity behavior for free: checking one radio in the group automatically unchecks every other radio sharing that name, with no JavaScript coordinating it. This is exactly the guarantee tabs need, which is why the radio hack — not the checkbox hack — is the correct primitive here.

Routing state to panels with sibling selectors

Each panel (.panel1, .panel2, .panel3) sits as a general sibling after all three radios and the .tab-list in the DOM. The rule #tab1:checked ~ .panel1 { display: block } reads as "when the radio with id tab1 is checked, show the sibling with class panel1." Because only one radio in the named group can be checked at a time, exactly one of the three ~ .panelN rules ever matches, which is what guarantees exactly one panel is visible — the same guarantee a JS tab implementation enforces manually by toggling classes, except here it falls out of native form semantics.

The sliding indicator without measuring anything

The active-tab background pill is a single absolutely-positioned .tab-indicator element, not a background color on each label. Its width is a fixed calc(33.333% - 5.333px) (accounting for the grid gaps), and each #tabN:checked ~ .tab-list .tab-indicator rule sets a different transform: translateX() value corresponding to that tab's grid column. Because transform is used instead of animating left, the indicator slides using a GPU-accelerated compositor property rather than triggering layout on every frame, and because all three possible positions are pre-computed as static calc() expressions, no JavaScript measurement of the clicked label's actual pixel position is ever needed — the layout is a fixed 3-column grid, so the three positions are knowable in advance.

Label as the click target, not the panel

Every <label for="tabN"> is bound to its radio via the for/id pair, so clicking anywhere on the visible tab button activates the correct radio through standard label-to-form-control association — the same mechanism a real <button> click would use, just routed through a hidden native input instead of a JS event handler.

Keyboard behavior comes from the browser, not from added code

Radio groups have long-standing native keyboard behavior: Tab moves focus into the group (landing on the checked radio, or the first one if none is checked), and the Arrow keys move the checked state between radios in the same name group. This snippet gets that keyboard-driven tab switching automatically, without a single keydown listener — something a from-scratch JS tab implementation has to deliberately reimplement (usually incorrectly) to match native role="tablist" behavior.

Where JavaScript genuinely cannot run

Tab interfaces are common in generated documentation (code-example tabs for different languages) and in CMS-authored marketing pages — both frequently rendered through pipelines (Markdown-to-HTML converters, sanitizing CMS renderers) that strip <script> tags for security. Because this tab switcher's entire state machine is native radio-input behavior plus CSS sibling selectors, it continues to function unchanged in exactly those stripped-script contexts, where a JS-driven tab component would render as static markup with no way to switch panels at all.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Ask an AI assistant to walk through why grouping the radios under a shared name attribute is the key mechanism that guarantees exactly one panel is ever visible, and why that guarantee doesn't hold if you accidentally give two radios different name values. It's also a good prompt for adding a :target-based variant that syncs the active tab to a URL fragment for deep-linking, or for extending the sliding indicator math to a variable, rather than fixed, number of tabs using CSS custom properties and the sibling-index counting trick.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a tabbed interface with at least three tabs using only HTML and CSS — no JavaScript, no onclick attributes, no <script> tags.

Requirements:
- Use hidden native <input type="radio"> elements, all sharing the same name attribute, as the sole source of which tab is active — rely on the browser's native radio-group mutual exclusivity rather than any manually toggled class.
- Each tab must be a <label> bound via for/id to its radio, acting as the clickable tab button.
- Each content panel must be shown or hidden using only a CSS sibling combinator selector keyed on that panel's corresponding radio's :checked state, ensuring exactly one panel is visible at any time.
- Include a sliding active-tab background indicator that moves to the correct position using CSS transform and transition, with the position for each tab pre-computed in CSS rather than measured at runtime.
- One radio must be checked by default so a panel is visible on first load.
- Ensure keyboard users can switch tabs using only Tab and Arrow keys via the radio group's native behavior, and that the currently focused tab shows a visible focus outline.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Give all tab radios the same nameAll <input type="radio"> tab controls must share name="tabs" so the browser enforces that only one can be checked at a time.
  2. 2
    Match each label's for to its radio idlabel[for="tab1"] must reference the id of its corresponding radio exactly, or clicking the label will not select that tab.
  3. 3
    Keep radios before the panels in markup orderThe #tabN:checked ~ .panelN sibling selector only looks forward in the DOM, so radios and .tab-list must appear before all .panel elements.
  4. 4
    Set one radio as checked by defaultAdd the checked attribute to exactly one radio input so a panel is visible on first render instead of showing nothing.
  5. 5
    Adjust indicator math for a different tab countUpdate the grid-template-columns count and each #tabN:checked translateX() percentage if you add or remove tabs.

Real-world uses

Common Use Cases

Multi-Language Code Examples
Switch between curl/JS/Python code blocks in documentation rendered through a script-stripping static site generator
Pricing Plan Comparisons
Toggle between Monthly/Yearly billing views or plan tiers on a marketing page authored inside a CMS content block
Dashboard Widget Panels
Lightweight tabbed widgets where you want to avoid a JS tab-state dependency for a purely presentational panel switch
SHOP
Product Detail Tabs
Description/Specs/Reviews tabs on a product page rendered inside a sandboxed preview iframe without allow-scripts
Learning the Radio Hack Pattern
A clean reference implementation for understanding how mutually-exclusive UI state maps onto native radio groups
Related: File Tree Explorer
See the File Tree Explorer for a related navigation pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Not with radio inputs alone, since :checked state isn't tied to the URL. For URL-fragment-driven tabs, use the :target pseudo-class pattern instead (see the accordion or a dedicated :target-based tabs variant), which does read the URL hash directly.

transform: translateX() is handled by the compositor and does not trigger layout recalculation on every animation frame, unlike animating left, making the slide smoother and cheaper, especially on lower-powered devices.

As many as you add radio inputs, labels, and panels for — just remember to update the .tab-indicator width calc() and each #tabN:checked translateX() value to match the new column count and index.

The :has()-based focus rule is a progressive enhancement; the adjacent-sibling :focus-visible + .tab-label rule alongside it already covers the common case, so a browser without :has() support simply loses one redundant selector, not the whole focus indicator.

Yes — the same radio-group markup works unmodified as a component; if you need JS-driven behavior like syncing the active tab to a route, swap the native :checked state for a controlled radio group bound to component state.