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')
); 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.
Install via pnpm add @adnap/krysalicss.
Override variables at @use time. See the override example below.
<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')
); Trade-off matrix: JS-free vs JS-enhanced
| Capability | JS-free (framework default) | JS-enhanced (consumer wires) |
|---|---|---|
| JS required | None | ~25 lines of vanilla JS (see demo source) |
| Native role announced by screen readers | radio / radiogroup ("radio button, N of M") | tab / tablist / tabpanel ("selected, tab N of M") |
| Click-to-activate | Yes (label for input) | Yes (click handler) |
| Arrow keys move selection | Yes (native radio behaviour) | Yes (script handles ArrowLeft/Right + Home/End) |
Roving tabindex | n/a (inputs are in tab order) | Yes (only the active tab is in tab order) |
aria-selected on active tab | No | Yes |
aria-controls linking tab to panel | No (relies on positional sibling) | Yes |
| Reveal mechanism | CSS adjacent-sibling chain | JS toggles hidden attribute |
| Tab inputs submit as form data | Yes (radio inputs) | No (buttons) |
| Index ceiling | None | None |
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
<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
nameand 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
| Variable | Default | Notes |
|---|---|---|
$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-small | Inter-tab spacing in the strip. |
$padding-y | $size-small | Tab vertical padding. |
$padding-x | $size-small | Tab horizontal padding. |
$border-radius | $global-border-radius | Top corners of each tab. |
$panel-padding | $size-normal | Inside the panel. |
$active-combination | $color-combination-default | Active-tab (bg, fg) tuple. Inactive tabs stay transparent so the row reads as an underline list. |
Override example
@use '@adnap/krysalicss/module/tabs' with (
$panel-padding: 1.5rem,
); Tokens consumed
| Token | Used for |
|---|---|
--kc-tabs-active-bg / --kc-tabs-active-fg | Currently checked tab. |