Tabs
Switch between views in place. A strip of tabs sits over swappable panels, and a single highlight slides to the active tab as you move between them.
Variants
Three looks for three contexts. line underlines the active tab against a bottom rule — the quiet default for page-level sections. pill floats a filled neutral pill on bare ground. segmented seats the tabs in a sunken track where the active one rides up as a solid chip — the boxed control for compact toggles. The active chip fills with the bold primary key by default.
"use client";
import { Tabs } from "@stridge/noctis";
const VARIANTS: Tabs.Variant[] = ["line", "pill", "segmented"];
export default function TabsVariants() {
return (
<div className="flex flex-col gap-8">
{VARIANTS.map((variant) => (
<Tabs.Root key={variant} variant={variant} defaultValue="overview">
<Tabs.List aria-label="Project sections">
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="activity">Activity</Tabs.Tab>
<Tabs.Tab value="issues">Issues</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Panel value="overview" className="text-small text-muted">
Project overview and current status.
</Tabs.Panel>
<Tabs.Panel value="activity" className="text-small text-muted">
Recent activity across the project.
</Tabs.Panel>
<Tabs.Panel value="issues" className="text-small text-muted">
Open issues and their owners.
</Tabs.Panel>
</Tabs.Root>
))}
</div>
);
}
Accent color
The active chip defaults to the neutral-on-dark primary key. When a tab strip is itself the page's headline control and you want it to carry the brand, set color="accent" on a segmented strip to fill the active chip with the accent instead. Reach for it sparingly — primary is the right default almost everywhere.
"use client";
import { Tabs } from "@stridge/noctis";
export default function TabsAccent() {
return (
<Tabs.Root variant="segmented" color="accent" defaultValue="overview">
<Tabs.List aria-label="Project sections">
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="activity">Activity</Tabs.Tab>
<Tabs.Tab value="issues">Issues</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Panel value="overview" className="text-small text-muted">
Project overview and current status.
</Tabs.Panel>
<Tabs.Panel value="activity" className="text-small text-muted">
Recent activity across the project.
</Tabs.Panel>
<Tabs.Panel value="issues" className="text-small text-muted">
Open issues and their owners.
</Tabs.Panel>
</Tabs.Root>
);
}
Sizes
Two heights — small and medium — sharing the control type and spacing rhythm.
"use client";
import { Tabs } from "@stridge/noctis";
const SIZES: Tabs.Size[] = ["sm", "md"];
export default function TabsSizes() {
return (
<div className="flex flex-col gap-8">
{SIZES.map((size) => (
<Tabs.Root key={size} variant="segmented" size={size} defaultValue="comment">
<Tabs.List aria-label="Composer mode">
<Tabs.Tab value="comment">Comment</Tabs.Tab>
<Tabs.Tab value="update">Update</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
))}
</div>
);
}
With icons
Pair a leading glyph with the label to speed recognition. Icons are decorative; the label names the tab.
"use client";
import { Tabs } from "@stridge/noctis";
import { Activity, CircleDot, LayoutGrid } from "lucide-react";
export default function TabsWithIcons() {
return (
<Tabs.Root variant="pill" defaultValue="overview">
<Tabs.List aria-label="Project sections">
<Tabs.Tab value="overview" icon={LayoutGrid}>
Overview
</Tabs.Tab>
<Tabs.Tab value="activity" icon={Activity}>
Activity
</Tabs.Tab>
<Tabs.Tab value="issues" icon={CircleDot}>
Issues
</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
);
}
Controlled
Drive the active tab yourself with value and onValueChange on Tabs.Root — for syncing the selection to the URL, persisting it, or reacting to a change. Omit them (use defaultValue) to let the component own its state.
Active value: activity
"use client";
import { Tabs } from "@stridge/noctis";
import { useState } from "react";
export default function TabsControlled() {
const [value, setValue] = useState("activity");
return (
<div className="flex flex-col gap-3">
<Tabs.Root value={value} onValueChange={(next) => setValue(String(next))}>
<Tabs.List aria-label="Project sections">
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="activity">Activity</Tabs.Tab>
<Tabs.Tab value="issues">Issues</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
<p className="text-small text-muted">
Active value: <span className="font-medium text-foreground">{value}</span>
</p>
</div>
);
}
Anatomy
Compose tabs from their parts. Tabs.Root owns the active value (controlled via value / onValueChange, or uncontrolled via defaultValue) and the shared variant and size.
Tabs.Root— the container. Props:variant(defaultline),size(defaultmd),color(thesegmentedactive chip's fill —primarydefault, oraccent), plus the Base UITabs.Rootprops (value,defaultValue,onValueChange).Tabs.List— the row of tabs. Give it anaria-labelso the strip is named for assistive tech.Tabs.Tab— one tab. Pass avalueto match aPanel; takes an optional leadingiconand can bedisabled.Tabs.Indicator— the single sliding highlight. Render it once insideTabs.List; it follows the active tab and respectsprefers-reduced-motion.Tabs.Panel— the content for onevalue. Only the active panel is shown.
Every part carries a data-slot (tabs, tabs-list, tabs-tab, tabs-indicator, tabs-panel) for host-side styling — pair it with the Base UI state attributes (data-active, data-highlighted, data-disabled, data-orientation). The strip is fully keyboard-operable — arrow keys move between tabs, Home/End jump to the ends — and RTL-aware.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every tab strip in that region retunes — e.g. .brand { --noctis-tabs-indicator-background-color: var(--noctis-color-accent); } recolors the active indicator. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI (List, Indicator, Panel) list just the props they pass through. Expand a row for the full type and description.