Tabs
Panels behind a strip of triggers, in a segmented or an underlined style.
Change your name here. You're done when you save.
Pick a new password. You'll be signed out elsewhere.
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.
Copy the file below to src/lib/components/ui/tabs.ts. It imports cn, so copy utils.ts to src/lib/utils.ts too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install tailwind-variants
import { type Child, type ComponentProps } from "@implementjs/core";
import {
Tabs as TabsPrimitive,
TabsContent as TabsContentPrimitive,
TabsList as TabsListPrimitive,
TabsTrigger as TabsTriggerPrimitive,
} from "@implementjs/primitives";
import { createComponent } from "@implementjs/primitives";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "@/lib/utils";
/**
* `default` is the segmented control — triggers sit in a filled track. `underline`
* is the flatter form for prose, where a filled track would read as a component
* rather than as part of the page.
*/
export const tabsListVariants = tv({
base: "inline-flex items-center justify-center text-muted-foreground data-[orientation=vertical]:h-fit data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
variants: {
variant: {
default: "h-9 w-fit rounded-lg bg-muted p-[3px]",
underline:
"h-auto w-full justify-start gap-4 rounded-none border-b border-border bg-transparent p-0 data-[orientation=vertical]:w-fit data-[orientation=vertical]:border-b-0 data-[orientation=vertical]:border-r",
},
},
defaultVariants: { variant: "default" },
});
export const tabsTriggerVariants = tv({
base: [
"inline-flex items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color,background-color,box-shadow,border-color] outline-none",
"hover:text-foreground",
"focus-visible:ring-[3px] focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:text-foreground",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
],
variants: {
variant: {
// shadcn's dark recipe (bg-input/30 on a bg-muted track) has no
// contrast in this theme, where --input and --muted are both
// #222 — so the selected tab lifts off the track instead
default: [
"h-[calc(100%-1px)] flex-1 rounded-md border border-transparent px-2 py-1",
"focus-visible:border-ring",
"data-[state=active]:bg-foreground/10 data-[state=active]:shadow-sm",
"data-[orientation=vertical]:h-auto data-[orientation=vertical]:justify-start",
],
// the trigger's own border sits on top of the list's, so the active
// one reads as a continuation of the rule rather than a second line
underline: [
"-mb-px rounded-none border-b-2 border-transparent px-1 pt-1 pb-2",
"data-[state=active]:border-foreground",
"data-[orientation=vertical]:-mr-px data-[orientation=vertical]:justify-start data-[orientation=vertical]:border-r-2 data-[orientation=vertical]:border-b-0 data-[orientation=vertical]:pr-2 data-[orientation=vertical]:pb-1",
],
},
},
defaultVariants: { variant: "default" },
});
export type TabsVariant = VariantProps<typeof tabsListVariants>["variant"];
export type TabsProps = ComponentProps<typeof TabsPrimitive>;
export type TabsListProps = ComponentProps<typeof TabsListPrimitive> & { variant?: TabsVariant };
export type TabsTriggerProps = ComponentProps<typeof TabsTriggerPrimitive> & {
variant?: TabsVariant;
};
export type TabsContentProps = ComponentProps<typeof TabsContentPrimitive>;
export const Tabs = createComponent(function Tabs(
{ class: className, ...props }: TabsProps,
...children: Child[]
) {
return TabsPrimitive(
{
...props,
"data-slot": "tabs",
class: cn("flex flex-col gap-2 data-[orientation=vertical]:flex-row", className),
},
...children,
);
});
export const TabsList = createComponent(function TabsList(
{ class: className, variant, ...props }: TabsListProps,
...children: Child[]
) {
return TabsListPrimitive(
{
...props,
"data-slot": "tabs-list",
class: cn(tabsListVariants({ variant }), className),
},
...children,
);
});
export const TabsTrigger = createComponent(function TabsTrigger(
{ class: className, variant, ...props }: TabsTriggerProps,
...children: Child[]
) {
return TabsTriggerPrimitive(
{
...props,
"data-slot": "tabs-trigger",
class: cn(tabsTriggerVariants({ variant }), className),
},
...children,
);
});
export const TabsContent = createComponent(function TabsContent(
{ class: className, ...props }: TabsContentProps,
...children: Child[]
) {
return TabsContentPrimitive(
{
...props,
"data-slot": "tabs-content",
class: cn(
"flex-1 rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
className,
),
},
...children,
);
});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.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<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. |
loop | boolean | true | Whether 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. |
disabled | Signal<boolean> | boolean | false | Disables every trigger. |
| Data attribute | Value |
|---|---|
[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.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "underline" | "default" | default is the segmented control, a filled track the triggers sit in. underline is the flatter form for prose. |
| Data attribute | Value |
|---|---|
[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.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "underline" | "default" | Match the list's variant — the two are styled as a pair. |
value* | string | — | Identifies the tab. Must match the TabsContent it opens. |
id | string | — | Defaults to a generated id. The matching panel points back at it with aria-labelledby. |
disabled | Signal<boolean> | boolean | false | Prevents selecting the tab. Sets disabled and data-disabled. |
| Data attribute | Value |
|---|---|
[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.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | — | Identifies the panel. Must match the TabsTrigger that opens it. |
id | string | — | Defaults to a generated id. The matching trigger points at it with aria-controls. |
| Data attribute | Value |
|---|---|
[data-tabs-content] | Present |
[data-state] | "active" | "inactive" |
[data-value] | The tab's value |
[data-orientation] | "horizontal" | "vertical" |