Skip to content
0

Tabs new ​

ts
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.

KeyAction
← / →Previous / next enabled tab, wrapping around.
Home / EndFirst / last enabled tab.
Enter / SpaceCommit, in manual mode only.
TabLeave the whole widget.

Disabled tabs are skipped in both directions and ignored on click.

Options ​

OptionTypeDefaultNotes
tabsTabDefinition[]—The tabs, in order.
labelstring"Tabs"Accessible name for the tablist.
activestringfirst enabled tabId 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 ​

ts
const { root, list, buttons, panels, select } = useTabs({ tabs, label: "Sections" });

document.body.append(root);
select("billing"); // select without moving focus

SSR ​

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.

SurfaceImportNeeds a DOM
Pure class buildertabsClassNo — safe during SSR
Element factoryuseTabsYes — 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.

Released under the MIT License.