Tabs

Hidden-radio pattern. Each tab is a flat input → label → panel triplet inside .tabs; the visible panel is the one immediately following the checked input's label. No JS, no index cap, any number of tabs.

Live

JS-free (radio-input pattern, framework default)

Krysalicss is a SCSS-native, modular CSS framework.

Install via pnpm add @adnap/krysalicss.

Override variables at @use time. See the override example below.

<div class="tabs">
<input class="tabs__input" type="radio" name="demo-tabs" id="demo-tabs-1" checked />
<label class="tabs__tab" for="demo-tabs-1">Overview</label>
<div class="tabs__panel">
  <p>Krysalicss is a SCSS-native, modular CSS framework.</p>
</div>

<input class="tabs__input" type="radio" name="demo-tabs" id="demo-tabs-2" />
<label class="tabs__tab" for="demo-tabs-2">Install</label>
<div class="tabs__panel">
  <p>Install via <code>pnpm add @adnap/krysalicss</code>.</p>
</div>

<input class="tabs__input" type="radio" name="demo-tabs" id="demo-tabs-3" />
<label class="tabs__tab" for="demo-tabs-3">Configure</label>
<div class="tabs__panel">
  <p>Override variables at <code>@use</code> time. See the override example below.</p>
</div>
</div>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-tabs')
);
Playground

JS-enhanced (WAI-ARIA tablist with vanilla JS)

Same content as the JS-free version: same component, different state mechanism. Try the arrow keys, Home, and End.

<div class="tabs" role="tablist" aria-label="Demo tabs (JS-enhanced)" data-aria-tabs>
<button class="tabs__tab" type="button" role="tab" id="aria-tab-1" aria-controls="aria-panel-1" aria-selected="true" tabindex="0" style="background: var(--kc-default-bg); color: var(--kc-default-fg); border-block-end-color: currentColor;">Overview</button>
<button class="tabs__tab" type="button" role="tab" id="aria-tab-2" aria-controls="aria-panel-2" aria-selected="false" tabindex="-1">Install</button>
<button class="tabs__tab" type="button" role="tab" id="aria-tab-3" aria-controls="aria-panel-3" aria-selected="false" tabindex="-1">Configure</button>
<div role="tabpanel" id="aria-panel-1" aria-labelledby="aria-tab-1" style="flex: 1 1 100%; order: 999; padding: 1rem;">
  <p>Same content as the JS-free version: same component, different state mechanism. Try the arrow keys, Home, and End.</p>
</div>
<div role="tabpanel" id="aria-panel-2" aria-labelledby="aria-tab-2" hidden style="flex: 1 1 100%; order: 999; padding: 1rem;">
  <p>Install via <code>pnpm add @adnap/krysalicss</code>.</p>
</div>
<div role="tabpanel" id="aria-panel-3" aria-labelledby="aria-tab-3" hidden style="flex: 1 1 100%; order: 999; padding: 1rem;">
  <p>Override variables at <code>@use</code> time. See the override example below.</p>
</div>
</div>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-tabs')
);
Playground

Trade-off matrix: JS-free vs JS-enhanced

CapabilityJS-free (framework default)JS-enhanced (consumer wires)
JS requiredNone~25 lines of vanilla JS (see demo source)
Native role announced by screen readersradio / radiogroup ("radio button, N of M")tab / tablist / tabpanel ("selected, tab N of M")
Click-to-activateYes (label for input)Yes (click handler)
Arrow keys move selectionYes (native radio behaviour)Yes (script handles ArrowLeft/Right + Home/End)
Roving tabindexn/a (inputs are in tab order)Yes (only the active tab is in tab order)
aria-selected on active tabNoYes
aria-controls linking tab to panelNo (relies on positional sibling)Yes
Reveal mechanismCSS adjacent-sibling chainJS toggles hidden attribute
Tab inputs submit as form dataYes (radio inputs)No (buttons)
Index ceilingNoneNone

Pick deliberately. The JS-free pattern uses native radio semantics: it works without JavaScript and is announced as a radio group, which is a different interaction model than a tablist. The JS-enhanced pattern is announced as a tablist (matching most users' expectation of "tabs"), moves focus on arrow-key navigation, and uses aria-controls to link each tab to its panel: the WAI-ARIA Authoring Practices model. Choose JS-free for non-critical progressive-enhancement contexts; choose JS-enhanced when a screen reader user must hear "tab N of M" specifically.

For the rationale behind the radio-group default, see Design philosophy.

Markup

JS-free markup
<div class="tabs">
  <input class="tabs__input" type="radio" name="t" id="t-1" checked>
  <label class="tabs__tab" for="t-1">Overview</label>
  <div class="tabs__panel">Overview content…</div>

  <input class="tabs__input" type="radio" name="t" id="t-2">
  <label class="tabs__tab" for="t-2">Install</label>
  <div class="tabs__panel">Install content…</div>

  <input class="tabs__input" type="radio" name="t" id="t-3">
  <label class="tabs__tab" for="t-3">Configure</label>
  <div class="tabs__panel">Configure content…</div>
</div>

Constraints

  • All tab inputs share a single name and live inside the same form scope: embedding inside a parent <form> submits them as form data.
  • The markup contract is positional: each panel must be the immediate next sibling of its label, which must be the immediate next sibling of its input. Wrapping any of the three in another element (e.g. an <li>) breaks the adjacent-sibling chain.

Variables

VariableDefaultNotes
$selector'.tabs'Root.
$input-selector'.tabs__input'Visually hidden radio.
$tab-selector'.tabs__tab'Visible <label>.
$panel-selector'.tabs__panel'The element immediately following the active label.
$gap$size-extra-smallInter-tab spacing in the strip.
$padding-y$size-smallTab vertical padding.
$padding-x$size-smallTab horizontal padding.
$border-radius$global-border-radiusTop corners of each tab.
$panel-padding$size-normalInside the panel.
$active-combination$color-combination-defaultActive-tab (bg, fg) tuple. Inactive tabs stay transparent so the row reads as an underline list.

Override example

app.scss
@use '@adnap/krysalicss/module/tabs' with (
  $panel-padding: 1.5rem,
);

Tokens consumed

TokenUsed for
--kc-tabs-active-bg / --kc-tabs-active-fgCurrently checked tab.