implement
Tabs

Tabs

A set of panels where one shows at a time.

Change your name here. You're done when you save.

import { Div, Label, Input, P } from "@implementjs/core";
import { Button } from "@/lib/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/lib/components/ui/tabs";

function Field(id: string, label: string, value: string) {
	return Div(
		{ class: "grid gap-1.5" },
		Label({ for: id, class: "text-sm font-medium" }, label),
		Input({
			id,
			value,
			class:
				"h-8 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
		}),
	);
}

export default function TabsDemo() {
	return Tabs(
		{ value: "account", class: "w-full max-w-sm" },
		TabsList(
			{ class: "w-full" },
			TabsTrigger({ value: "account" }, "Account"),
			TabsTrigger({ value: "password" }, "Password"),
		),
		TabsContent(
			{ value: "account", class: "grid gap-3 rounded-lg border p-4" },
			P(
				{ class: "text-sm text-muted-foreground" },
				"Change your name here. You're done when you save.",
			),
			Field("tabs-demo-name", "Name", "Aidan Bleser"),
			Button({ size: "sm", class: "w-fit" }, "Save changes"),
		),
		TabsContent(
			{ value: "password", class: "grid gap-3 rounded-lg border p-4" },
			P(
				{ class: "text-sm text-muted-foreground" },
				"Pick a new password. You'll be signed out elsewhere.",
			),
			Field("tabs-demo-password", "New password", ""),
			Button({ size: "sm", class: "w-fit" }, "Save password"),
		),
	);
}

Tabs split one region into layers the reader switches between — settings sections, a preview next to its source. Tabs is the root and owns the selected value, TabsList holds the triggers, and each TabsContent is the panel for one value.

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@implementjs/primitives";

Tabs(
	{ value: "account" },
	TabsList(
		TabsTrigger({ value: "account" }, "Account"),
		TabsTrigger({ value: "password" }, "Password"),
	),
	TabsContent({ value: "account" }, "Change your name here."),
	TabsContent({ value: "password" }, "Pick a new password here."),
);

Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props are forwarded onto the underlying Div or Button.

A trigger and its panel are paired by value, and the pairing is what wires aria-controls and aria-labelledby between them — so the two must match exactly.

Value

value is the selected tab. Pass a signal to control it from outside — reading it tells you which tab is showing, and setting it switches tabs without a click:

const tab = signal("account");

Tabs(
	{ value: tab },
	TabsList(TabsTrigger({ value: "account" }, "Account")),
	TabsContent({ value: "account" }, "Change your name here."),
);

A plain string seeds uncontrolled state instead. Omitting it starts with nothing selected: every panel is hidden and every trigger is reachable with Tab until one is picked.

Activation

activationMode (default "automatic") selects a tab as soon as its trigger is focused, so arrowing through the list swaps panels as you go. Use "manual" when a panel is expensive to show — arrow keys then only move focus, and Space, Enter, or a click selects.

Tabs({ value: "preview", activationMode: "manual" } /* ... */);

Keyboard and focus

The list is one Tab stop: arrow keys move between the triggers, loop (default true) wraps at the ends, and Home and End jump to them. orientation (default "horizontal") picks which arrows move — Left/Right when horizontal, Up/Down when vertical. Disabled triggers are skipped.

Each panel is itself focusable (tabindex="0"), so Tab out of the list lands on the content even when it holds no focusable elements.

Disabled

Pass disabled on the root to disable every trigger, or on one trigger to disable just it. Both accept a signal, set the native disabled attribute, and add data-disabled for styling.

Accessibility

The list is role="tablist" with aria-orientation, each trigger is role="tab" with aria-selected and aria-controls, and each panel is role="tabpanel" labelled by its trigger. Name the list with aria-label or aria-labelledby when the page has more than one.

Hidden panels use the hidden attribute, so their content stays out of the accessibility tree and out of find-in-page.

Styling

The parts set data-tabs-root, data-tabs-list, data-tabs-trigger, and data-tabs-content. Triggers and panels carry data-state as "active" or "inactive", plus data-value, data-orientation, and data-disabled:

TabsTrigger({
	value: "account",
	class: "rounded-md px-2 py-1 text-sm data-[state=active]:bg-background",
});

API Reference

Tabs

The root. Owns the selected value, the activation mode, and the arrow-key focus movement. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
valueSignal<string> | string""The selected tab. Pass a signal to control it from outside; a string seeds uncontrolled state. Empty means nothing is selected.
orientation"horizontal" | "vertical""horizontal"Which arrow keys move focus, and the data-orientation attributes.
loopbooleantrueWhether arrow keys wrap from the last trigger back to the first.
activationMode"automatic" | "manual""automatic"Whether focusing a trigger selects its tab, or selection waits for a click or Space/Enter.
disabledSignal<boolean> | booleanfalseDisables every trigger.
Data attributeValue
[data-tabs-root]Present
[data-orientation]"horizontal" | "vertical"
[data-disabled]Present when disabled

TabsList

Holds the triggers. Sets role="tablist" and aria-orientation. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-tabs-list]Present
[data-orientation]"horizontal" | "vertical"
[data-disabled]Present when the root is disabled

TabsTrigger

One tab. Sets role="tab", aria-selected, and aria-controls pointing at the matching content. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
value*stringIdentifies the tab. Must match the TabsContent it opens.
idstringDefaults to a generated id. The matching panel points back at it with aria-labelledby.
disabledSignal<boolean> | booleanfalsePrevents selecting the tab. Sets disabled and data-disabled.
Data attributeValue
[data-tabs-trigger]Present
[data-state]"active" | "inactive"
[data-value]The tab's value
[data-orientation]"horizontal" | "vertical"
[data-disabled]Present when disabled

TabsContent

The panel for one tab. Sets role="tabpanel", is labelled by its trigger, and is hidden unless selected. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
value*stringIdentifies the panel. Must match the TabsTrigger that opens it.
idstringDefaults to a generated id. The matching trigger points at it with aria-controls.
Data attributeValue
[data-tabs-content]Present
[data-state]"active" | "inactive"
[data-value]The tab's value
[data-orientation]"horizontal" | "vertical"