implement
Tabs

Tabs

Panels behind a strip of triggers, in a segmented or an underlined style.

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"),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/tabs

It installs tailwind-variants at the same time.

Usage

Two looks, chosen with variant on the list and the trigger. default is the segmented control — a filled track the triggers sit in. underline is the flatter form for prose, where a filled track would read as a component dropped into the page rather than part of it.

Set the same variant on both: they are styled as a pair.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/lib/components/ui/tabs";

Tabs(
	{ value: "account" },
	TabsList(
		TabsTrigger({ value: "account" }, "Account"),
		TabsTrigger({ value: "password" }, "Password"),
	),
	TabsContent({ value: "account" }, "Make changes to your account here."),
	TabsContent({ value: "password" }, "Change your password here."),
);

The underline variant

Tabs(
	{ value: "cli" },
	TabsList(
		{ variant: "underline" },
		TabsTrigger({ variant: "underline", value: "cli" }, "CLI"),
		TabsTrigger({ variant: "underline", value: "manual" }, "Manual"),
	),
	TabsContent({ value: "cli" } /* ... */),
);

This is the variant the installation tabs on this page use.

Vertical

orientation: "vertical" on the root turns the strip into a column and swaps the arrow keys over. Both variants follow it — the underline moves to the trailing edge.

About the dark theme

The segmented variant departs from shadcn's dark recipe on purpose. That recipe puts bg-input/30 on a bg-muted track, and in this palette --input and --muted are the same grey, so the selected tab would vanish. The active trigger lifts off the track with bg-foreground/10 instead. If you re-theme those two tokens apart, that is the line to revisit.

API Reference

Every prop the styling does not consume is forwarded to the Tabs primitive, so the tables below are the whole surface — the behavior props and the styling ones together.

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.

PropTypeDefaultDescription
variant"default" | "underline""default"default is the segmented control, a filled track the triggers sit in. underline is the flatter form for prose.
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
variant"default" | "underline""default"Match the list's variant — the two are styled as a pair.
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"