Tabs
Tabs: anatomy, keyboard behavior, state, and theming in Askr.
Example
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@askrjs/themes/components';
<Tabs value={tab()} onValueChange={setTab}>
<TabsList aria-label="Project sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview"><ProjectOverview /></TabsContent>
<TabsContent value="activity"><ProjectActivity /></TabsContent>
</Tabs>Switch between panels without losing the active tab.
Purpose
This page is themes-only, since @askrjs/ui has no tabs primitive to pair it with — and "tabs" here actually names two unrelated things. Tabs/Tab is a router-aware navigation bar built on Link, for switching between routes (a settings page with URL-backed sections is the canonical case). TabsList/TabsTrigger/TabsContent, by contrast, are presentational parts shaped for the classic tabbed-panel pattern; they carry no state or panel-switching logic of their own, so wiring that up is left to you.
Install and import
Both flavors come from the same place: import Tabs and Tab, or TabsList, TabsTrigger, and TabsContent, from @askrjs/themes/tabs (which, like every themes subpath, is really just the shared components barrel). Tab's props extend your router's LinkProps, so it needs to run inside your app's router context to navigate correctly. TabsList and friends have no such dependency — they're plain elements you can drop anywhere.
Live examples
For route-based tabs, wrap a Tabs (a styled nav element) around a Tab per destination, passing `active` or letting `match="prefix"` decide highlighting based on the current URL. For a panel-switcher, render TabsList containing one TabsTrigger per option, and pair each with a TabsContent — but you're responsible for tracking which one is selected and swapping data-state or conditionally rendering the matching panel yourself.
Anatomy
Tabs/Tab is flat: a nav-like Tabs wrapper around any number of Tab links, no content panels involved because it's driving navigation rather than showing/hiding content. TabsList/TabsTrigger/TabsContent looks like the ARIA tabs pattern, but the role coverage is partial: `TabsList` gets `role="tablist"` and `TabsContent` gets `role="tabpanel"`, but `TabsTrigger` itself sets no `role` at all — you need to add `role="tab"` yourself if you want the full pattern. There's no hidden Context tracking a current tab or associating a trigger with its panel either way.
State model
For Tab, "selected" state is really route state — but Tab computes it for you: it compares the current URL against its own destination (respecting the `match` prop, `"prefix"` or `"exact"`) and sets `aria-current="page"` automatically when active, the same way the rest of the shared nav-link machinery does. You only need to pass `active` yourself if you want to override that computed value. For TabsTrigger/TabsContent, by contrast, state really is entirely your responsibility — store the selected tab id in your own component state and use it to set data-state="active" on the current trigger and conditionally render (or hide) the matching content.
Keyboard and accessibility
Tab renders as a real link, so keyboard behavior — Tab key to move focus, Enter to navigate — comes for free from the browser and your router, and `aria-current="page"` on the active link is what screen readers announce as the current section; that active state is computed automatically off the current route (unless you pass `active` to override it), not something you have to track yourself. TabsList and TabsContent apply `tablist`/`tabpanel` roles, but TabsTrigger gets no role from the component — add `role="tab"` yourself, along with `aria-selected` on the active trigger and `aria-controls`/`id` pairing between a trigger and its panel, if you want full tab-pattern accessibility. Arrow-key navigation between triggers isn't provided either — you'd need to add that yourself if the panel pattern requires it.
Styling and tokens
Tab and Tabs style through .tab/.tabs classes (with [data-slot="tab"]/[data-slot="tabs"] as a fallback), using data-active and aria-current="page" as the hooks for the active-state look. TabsList, TabsTrigger, and TabsContent live in a separate stylesheet keyed off [data-slot="tabs-list"], [data-slot="tabs-trigger"], and [data-slot="tabs-content"], with the active trigger styled off either data-state="active" or data-active="true" — pick whichever attribute your own state logic sets. Both sets read from the same shared --ak-* tokens as the rest of the theme, so they stay visually consistent with everything else.
API
Tab's props are LinkProps merged with `active` and `match` ("prefix" | "exact"), so anything your router's Link accepts is fair game here too. Tabs itself takes just `children`, plus an `asChild` escape hatch if you need to render a different wrapper element. TabsList, TabsTrigger, and TabsContent are effectively untyped pass-throughs — they accept your usual element props (as, asChild, class, children) with no tabs-specific props at all, which is the clearest signal that the state logic isn't included.
Edge cases
Because TabsTrigger/TabsContent don't manage state, if you forget to update data-state or conditionally render the panels, clicking a trigger will change its visual styling only in whatever way your click handler drives — nothing switches automatically. If you need a fully accessible, self-contained tabbed-panel widget with keyboard arrow navigation between tabs, you'll need to build that state and interaction logic on top of these parts yourself; it isn't shipped. For route-driven tabs, make sure `match` is set correctly ("exact" vs "prefix") or a parent route's tab will stay highlighted on child routes you didn't intend.
Related pages
If you actually want route-scoped navigation without the tab-strip visual, Navbar and Navigation Menu and the plain NavLink/NavItem primitives are worth a look — Tab is really a styled variant of that same navigation family. For grouping content sections that don't need URL-backed navigation, Accordion and Collapsible cover the show/hide interaction with real built-in state, which Tabs here does not.