Tabs new
import { useTabs } from "@katanakit/ui";
const tabs = useTabs({
label: "Account settings",
tabs: [
{ id: "profile", label: "Profile", content: "…" },
{ id: "billing", label: "Billing" },
],
});
document.body.append(tabs.root);Built on the WAI-ARIA tabs pattern: role="tablist" over role="tab" buttons, each owning a role="tabpanel".
Why roving tabindex
A tablist is one composite widget, not a list of independent controls. The user tabs into it once and then uses ←/→ to move between tabs. Giving every tab tabindex="0" would put N stops in the page's tab order for what is conceptually a single control — and a keyboard user would have to press Tab N times just to get past the group.
So the selected tab carries tabindex="0" and the rest carry tabindex="-1". Pressing Tab once leaves the whole widget.
Panels are always keyboard reachable
Every panel gets tabindex="0". A panel containing nothing focusable would otherwise be unreachable — the user selects a tab, focus stays on the tab, and they can never read what it revealed.
Activation
activation: "automatic" (the default) selects a tab the moment it receives focus. That is what most users expect from tabs.
activation: "manual" moves focus with the arrows but requires Enter or Space to commit. Use it when selecting a tab is expensive, or when it would change the URL.
| Key | Action |
|---|---|
| ← / → | Previous / next enabled tab, wrapping around. |
| Home / End | First / last enabled tab. |
| Enter / Space | Commit, in manual mode only. |
| Tab | Leave the whole widget. |
Disabled tabs are skipped in both directions and ignored on click.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
tabs | TabDefinition[] | — | The tabs, in order. |
label | string | "Tabs" | Accessible name for the tablist. |
active | string | first enabled tab | Id of the initially selected tab. |
activation | "automatic" | "manual" | "automatic" | Selection follows focus, or not. |
onChange | (id: string) => void | — | Called when the selected tab changes. |
A TabDefinition is { id, label, content?, disabled? }. content accepts a string (inserted with textContent, never parsed as HTML) or a Node; omit it to render the panel yourself.
The returned elements
const { root, list, buttons, panels, select } = useTabs({ tabs, label: "Sections" });
document.body.append(root);
select("billing"); // select without moving focusSSR
useTabs needs a DOM. tabsClass() is pure and safe to call on the server.
Contract
Every component in the kit follows one shape. Pure builders return a class string and touch nothing; factories build a DOM element and need a browser.
| Surface | Import | Needs a DOM |
|---|---|---|
| Pure class builder | tabsClass | No — safe during SSR |
| Element factory | useTabs | Yes — throws with a pointer to the pure surface |
// Server and templates — pure
<div class={ tabsClass({ /* … */ }) }></div>
// Browser — imperative
document.body.append(useTabs({ /* … */ }));Class names are unprefixed. The root class is tabs, not kk-tabs, and every component rule is wrapped in @layer katana.
That matters because these names collide 1:1 with daisyUI. Unlayered CSS always beats layered CSS regardless of specificity or import order, so loading both is deterministic instead of "whoever came last". See why.