← Visual Library / Effects

JS-free radio tab-set

Tabs that swap content panels using only hidden <input type="radio"> + sibling-combinator CSS. Zero JavaScript, works in iOS Quick Look, keyboard-accessible by default. The canonical "swap content without a framework" pattern.

Effects Built
Use when — you have 2–8 mutually exclusive content panels that share the same visual frame: product tech specs, "what's in the box" breakdowns, FAQ categories, comparison tables, billing-cycle toggles (monthly / annual). Each pill swaps the visible panel; only one is active at a time. The browser handles state, focus, and keyboard navigation for free.

Live demo

Click any pill. The active state is held by a hidden radio input.

Manguera extensible 15m

Construcción multicapa, tejido exterior flexible, color negro mate. Resiste hasta 12 bares de presión.

Cabezal con FlowLock

Pistola multifunción con 10 modos de riego. Activación con un solo dedo, empuñadura ergonómica antideslizante.

Conectores metalizados

Acabado anodizado mate, no se oxidan. Sellado anti-fugas en cada conexión. Roscado europeo estándar.

Adaptador rápido

Sistema click-and-go, conexión en 2 segundos. Compatible con cualquier marca. Junta tórica reemplazable.

HTML

<div class="tabset">
  <!-- Hidden radios — these hold the state -->
  <input type="radio" name="tabs" id="t1" checked>
  <input type="radio" name="tabs" id="t2">
  <input type="radio" name="tabs" id="t3">

  <!-- Pills — each <label> targets a radio via for= -->
  <div class="pills">
    <label class="pill" for="t1">Spec A</label>
    <label class="pill" for="t2">Spec B</label>
    <label class="pill" for="t3">Spec C</label>
  </div>

  <!-- Panels — sibling-combinator CSS shows/hides based on :checked -->
  <div class="panels">
    <div class="panel" data-t="1">...content for tab 1...</div>
    <div class="panel" data-t="2">...content for tab 2...</div>
    <div class="panel" data-t="3">...content for tab 3...</div>
  </div>
</div>

CSS — the whole pattern

.tabset { position: relative; }

/* Hide the radios visually but keep them accessible to keyboard + screen readers */
.tabset input[type="radio"] {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  pointer-events: none;
  clip: rect(0 0 0 0);
}

/* Pills — clickable labels */
.pill {
  padding: 8px 14px;
  border: 1px solid var(--border);
  border-radius: 999px;
  cursor: pointer;
  min-height: 36px;          /* touch target ≥ 44px ideally */
  display: inline-flex; align-items: center;
  transition: all 0.25s ease;
}

/* Panels — hidden by default */
.panel { display: none; }

/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   THE TRICK — radio :checked + sibling combinator ~
   targets labels and panels anywhere AFTER the radio in DOM order
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */

/* Active pill (visual highlight) */
#t1:checked ~ .pills .pill[for="t1"],
#t2:checked ~ .pills .pill[for="t2"],
#t3:checked ~ .pills .pill[for="t3"] {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

/* Active panel (visible) */
#t1:checked ~ .panels .panel[data-t="1"],
#t2:checked ~ .panels .panel[data-t="2"],
#t3:checked ~ .panels .panel[data-t="3"] {
  display: block;
}

Why this works

Radios = state holder. Browsers natively maintain "which radio in a group is checked." Clicking a <label for="t2"> toggles #t2 on, which un-checks #t1. We never touch this state with JS.

Sibling combinator ~. The CSS rule #t1:checked ~ .panels .panel[data-t="1"] reads: "when the element with id t1 is checked, find any later sibling that matches .panels, then within that find .panel[data-t="1"]." The pills and panels must appear AFTER the radios in DOM order for this to work.

Hidden radios are still accessible. The clip + opacity + position:absolute recipe is the canonical "visually hide but keep accessible" pattern (vs. display: none, which removes from the tab order entirely). Keyboard users can Tab to the radios and arrow-key through tabs.

Dials

Default tab. The radio you mark checked is the initial active tab. Conventionally the first one, but you can default to whichever makes sense (e.g. "Most popular" tier in pricing).

Pill placement. Pills can sit above the panels (most common), inside the same visual frame as the content (overlay style, e.g. on top of an image), or stacked vertically as a sidebar — the sibling combinator doesn't care about layout, only DOM order.

Number of tabs. The pattern scales to 7+ tabs but the CSS rules grow linearly — one selector per tab. For 10+ tabs, consider a JS-driven solution to avoid 30+ lines of repetitive selectors.

Transitions between panels. Because panels switch via display: none/block, you can't animate the transition (no transition on display). Workaround: use opacity + position layering (all panels stacked absolutely, only one fully opaque).

Gotchas worth knowing

DOM order matters. The pills and panels must come AFTER the radios in the DOM. If a pill is rendered BEFORE its radio, the sibling selector can't reach it.

Don't nest tabsets. If you have a tabset inside another tabset, the inner radios' name attribute must differ from the outer's, or you'll get cross-group state contamination.

iOS Safari tap delay. Add -webkit-tap-highlight-color: transparent to the pills to suppress the blue tap flash.

Animating the active state. You CAN transition properties like background, border-color, color on the pill — they animate smoothly between checked states. Only display doesn't animate.

Real-world uses on Mithjem

Source: Mithjem product pages + homepage tech/spray sections (2026-05-22+). Pattern works flawlessly in iOS Quick Look + Safari Reader + Files preview thanks to its zero-JS nature.