Navigation
Tabs
Switches between panels of related content in one place, one panel visible at a time.
- Built on
<button>hidden attributeanchor positioning- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.1 kB
- Dependencies
- React
Compiled 214 modules in 38s on node 22.
1,284 tests passed, 3 skipped.
Waiting for approval from #platform.
Try its options
Loading its options.
Code
import { Tab, TabList, TabPanel, Tabs } from "@/components/equip/tabs";
export default function Demo() {
return (
<Tabs defaultValue="build" style={{ width: 208 }}>
<TabList aria-label="Deploy stages">
<Tab value="build">Build</Tab>
<Tab value="test">Test</Tab>
<Tab value="release">Release</Tab>
</TabList>
<TabPanel value="build">Compiled 214 modules in 38s on node 22.</TabPanel>
<TabPanel value="test">1,284 tests passed, 3 skipped.</TabPanel>
<TabPanel value="release">Waiting for approval from #platform.</TabPanel>
</Tabs>
);
}Your versionchange its options in Look, This part
Examples
Controlled
Hold the value yourself when the selection belongs in the URL or has to survive a reload.
INV-2041, 1 Nov, $480.00, paid.
Visa ending 4242, expires 08/27.
612,000 of 1,000,000 API calls this cycle.
Selected: invoices
Code
"use client";
import { useState } from "react";
import { Tab, TabList, TabPanel, Tabs } from "@/components/equip/tabs";
export default function Example() {
const [view, setView] = useState("invoices");
return (
<Tabs value={view} onValueChange={setView} style={{ maxWidth: 360 }}>
<TabList aria-label="Billing">
<Tab value="invoices">Invoices</Tab>
<Tab value="payment">Payment method</Tab>
<Tab value="usage">Usage</Tab>
</TabList>
<TabPanel value="invoices">INV-2041, 1 Nov, $480.00, paid.</TabPanel>
<TabPanel value="payment">Visa ending 4242, expires 08/27.</TabPanel>
<TabPanel value="usage">612,000 of 1,000,000 API calls this cycle.</TabPanel>
<p style={{ margin: 0, fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>
Selected: {view}
</p>
</Tabs>
);
}Disabled tab
Arrow keys step over a disabled tab, and Home and End land on the first and last enabled ones.
equip-ui, 42 contributors, last push 2 hours ago.
Insights need a Team plan.
Default branch: main. Merge queue on.
Code
import { Tab, TabList, TabPanel, Tabs } from "@/components/equip/tabs";
export default function Example() {
return (
<Tabs defaultValue="overview" style={{ maxWidth: 360 }}>
<TabList aria-label="Repository">
<Tab value="overview">Overview</Tab>
<Tab value="insights" disabled>
Insights
</Tab>
<Tab value="settings">Settings</Tab>
</TabList>
<TabPanel value="overview">equip-ui, 42 contributors, last push 2 hours ago.</TabPanel>
<TabPanel value="insights">Insights need a Team plan.</TabPanel>
<TabPanel value="settings">Default branch: main. Merge queue on.</TabPanel>
</Tabs>
);
}Panels with controls
A panel that holds a link or control drops out of the tab order, so Tab goes straight to its first control.
app.example.com is the primary domain.
Code
import { Tab, TabList, TabPanel, Tabs } from "@/components/equip/tabs";
export default function Example() {
return (
<Tabs defaultValue="general" style={{ maxWidth: 360 }}>
<TabList aria-label="Project settings">
<Tab value="general">General</Tab>
<Tab value="domains">Domains</Tab>
</TabList>
<TabPanel value="general">
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="checkbox" defaultChecked /> Preview deployments for every branch
</label>
</TabPanel>
<TabPanel value="domains">
<a href="#domains">app.example.com</a> is the primary domain.
</TabPanel>
</Tabs>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
value | string | none | On Tabs: the selected tab, when you control it; pair with onValueChange. On Tab and TabPanel (required): pairs a tab with its panel. Characters outside letters, digits, _ and - are replaced in the generated ids. |
defaultValue | string | none | Tabs: the tab selected on first render, when Tabs keeps its own state. One of value or defaultValue is required. |
onValueChange | (value: string) => void | none | Tabs: called with the new value whenever the selection changes. |
...rest | ComponentProps<"div"> | ComponentProps<"button"> | none | Tabs, TabList and TabPanel take every div attribute; Tab takes every button attribute, including disabled. |
Anatomy
| root | .eq-tabs | |
|---|---|---|
| list | .eq-tabs-list | role=tablist. Give it an aria-label. Where anchor positioning is supported it draws one underline that slides to the selected tab. |
| tab | .eq-tabs-tab | A button with role=tab. Without anchor positioning, the selected one draws its own underline. |
| panel | .eq-tabs-panel | role=tabpanel. Unselected panels stay rendered but hidden. |
Keyboard
| Tab | Moves focus to the selected tab, then into the visible panel. |
|---|---|
| ArrowRight | Selects and focuses the next tab, wrapping from last to first. |
| ArrowLeft | Selects and focuses the previous tab, wrapping from first to last. |
| Home | Selects and focuses the first tab. |
| End | Selects and focuses the last tab. |
Notes
- Tabs activate as they receive focus, so keep panels cheap to show; every panel is rendered up front and hidden.
- Only the selected tab is in the tab order; the others are reached with the arrow keys.
- A panel is focusable only while it contains nothing focusable of its own, so keyboard users can always reach its content.
- Arrow keys skip disabled tabs and follow reading direction in right-to-left text.
- If the selected value matches no enabled tab, no tab is in the tab order, so keep value pointed at a real tab.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/tabs.tsx177 lines
"use client";
import {
createContext,
useContext,
useEffect,
useId,
useState,
type ComponentProps,
type KeyboardEvent,
type MouseEvent,
} from "react";
import "./tabs.css";
type TabsContextValue = {
value: string;
select: (value: string) => void;
idFor: (kind: "tab" | "panel", value: string) => string;
};
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs(component: string): TabsContextValue {
const context = useContext(TabsContext);
if (!context) throw new Error(`<${component}> must be inside <Tabs>.`);
return context;
}
export type TabsProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> &
(
| {
/** The selected tab's value. Pair with onValueChange. */
value: string;
defaultValue?: never;
onValueChange: (value: string) => void;
}
| {
value?: never;
/** The tab selected on first render, when Tabs keeps its own state. */
defaultValue: string;
/** Called with the new value whenever the selection changes. */
onValueChange?: (value: string) => void;
}
);
export function Tabs({ value, defaultValue, onValueChange, className, children, ...rest }: TabsProps) {
const baseId = useId();
const [own, setOwn] = useState(defaultValue ?? "");
const selected = value ?? own;
const context: TabsContextValue = {
value: selected,
select(next) {
if (next === selected) return;
if (value === undefined) setOwn(next);
onValueChange?.(next);
},
// aria-controls is a space-separated id list, so anything but [\w-] is replaced.
idFor: (kind, v) => `${baseId}-${kind}-${v.replace(/[^\w-]/g, "_")}`,
};
return (
<TabsContext.Provider value={context}>
<div {...rest} className={["eq-tabs", className].filter(Boolean).join(" ")}>
{children}
</div>
</TabsContext.Provider>
);
}
export type TabListProps = ComponentProps<"div">;
export function TabList({ className, onKeyDown, ...rest }: TabListProps) {
const { select } = useTabs("TabList");
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
const list = event.currentTarget;
// Only this list's own tabs, so a Tabs nested inside a panel is left alone.
const tabs = Array.from(list.querySelectorAll<HTMLButtonElement>('[role="tab"]')).filter(
(tab) => tab.closest('[role="tablist"]') === list && !tab.disabled,
);
const current = tabs.findIndex((tab) => tab === document.activeElement);
if (current === -1 || tabs.length === 0) return;
// In right-to-left text the arrows follow what the reader sees.
const forward = getComputedStyle(list).direction === "rtl" ? "ArrowLeft" : "ArrowRight";
const back = forward === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
let next: number;
if (event.key === forward) next = (current + 1) % tabs.length;
else if (event.key === back) next = (current - 1 + tabs.length) % tabs.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = tabs.length - 1;
else return;
event.preventDefault();
const tab = tabs[next];
tab.focus();
const v = tab.dataset.value;
if (v !== undefined) select(v);
}
return (
<div
{...rest}
role="tablist"
className={["eq-tabs-list", className].filter(Boolean).join(" ")}
onKeyDown={handleKeyDown}
/>
);
}
export type TabProps = Omit<ComponentProps<"button">, "value"> & {
/** Matches the value of the TabPanel this tab shows. */
value: string;
};
export function Tab({ value, className, onClick, ...rest }: TabProps) {
const tabs = useTabs("Tab");
const selected = tabs.value === value;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (!event.defaultPrevented) tabs.select(value);
}
return (
<button
type="button"
{...rest}
role="tab"
id={tabs.idFor("tab", value)}
className={["eq-tabs-tab", className].filter(Boolean).join(" ")}
data-value={value}
aria-selected={selected}
aria-controls={tabs.idFor("panel", value)}
tabIndex={selected ? 0 : -1}
onClick={handleClick}
/>
);
}
export type TabPanelProps = ComponentProps<"div"> & {
/** Matches the value of the Tab that shows this panel. */
value: string;
};
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"], summary';
export function TabPanel({ value, className, tabIndex, ...rest }: TabPanelProps) {
const tabs = useTabs("TabPanel");
const id = tabs.idFor("panel", value);
const selected = tabs.value === value;
// Focusable on the server; after mount, only while it holds nothing focusable itself.
const [hasFocusable, setHasFocusable] = useState(false);
useEffect(() => {
const panel = document.getElementById(id);
const found = panel?.querySelector(FOCUSABLE) != null;
if (found !== hasFocusable) setHasFocusable(found);
});
return (
<div
role="tabpanel"
{...rest}
id={id}
className={["eq-tabs-panel", className].filter(Boolean).join(" ")}
aria-labelledby={tabs.idFor("tab", value)}
tabIndex={tabIndex ?? (hasFocusable ? undefined : 0)}
hidden={!selected}
/>
);
}components/equip/tabs.css139 lines
@layer equip {
.eq-tabs {
display: flex;
flex-direction: column;
gap: var(--eq-space-3);
min-width: 0;
font-family: var(--eq-font);
}
/* No overflow here: it would clip the focus ring on the tabs. A list too wide for its
column wraps onto a second row instead of pushing the column wider. */
.eq-tabs-list {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-1);
min-width: 0;
border-bottom: 1px solid var(--eq-line);
}
.eq-tabs-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding: 0 var(--eq-space-2);
margin-bottom: -1px;
border: 0;
border-radius: var(--eq-radius-s) var(--eq-radius-s) 0 0;
background: transparent;
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
color var(--eq-dur) var(--eq-ease),
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* The indicator: a 2px ink underline that sits over the list's own line. */
.eq-tabs-tab::after {
content: "";
position: absolute;
inset: auto var(--eq-space-2) 0;
height: 2px;
border-radius: 1px;
background: var(--eq-ink);
scale: 0 1;
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-tabs-tab[aria-selected="true"] {
color: var(--eq-ink);
}
.eq-tabs-tab[aria-selected="true"]::after {
scale: 1 1;
}
@media (hover: hover) {
.eq-tabs-tab:hover:not(:disabled) {
color: var(--eq-ink);
background: var(--eq-tint);
}
}
.eq-tabs-tab:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-tabs-tab:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-tabs-tab:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-tabs-tab > svg {
flex: none;
width: 16px;
height: 16px;
}
.eq-tabs-panel {
color: var(--eq-ink);
font-size: var(--eq-text-s);
line-height: 1.5;
border-radius: var(--eq-radius-s);
}
.eq-tabs-panel:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/*
* Where the engine has anchor positioning, one indicator on the list slides to the selected
* tab instead of each tab drawing its own. anchor-scope keeps a Tabs nested in a panel from
* pulling the outer indicator. Elsewhere the per-tab underline above grows in place.
*/
@supports (anchor-scope: --a) and (left: anchor(--a left)) {
.eq-tabs-list {
position: relative;
anchor-scope: --eq-tabs-selected;
}
.eq-tabs-tab[aria-selected="true"] {
anchor-name: --eq-tabs-selected;
}
.eq-tabs-tab::after {
display: none;
}
.eq-tabs-list::after {
content: "";
position: absolute;
position-anchor: --eq-tabs-selected;
/* Same inset as the per-tab underline, on the selected tab's own bottom edge: over the
list's line, or under the tab in its row when the list has wrapped. */
top: calc(anchor(bottom) - 2px);
left: calc(anchor(left) + var(--eq-space-2));
right: calc(anchor(right) + var(--eq-space-2));
height: 2px;
border-radius: 1px;
background: var(--eq-ink);
pointer-events: none;
transition:
top var(--eq-dur-move) var(--eq-spring),
left var(--eq-dur-move) var(--eq-spring),
right var(--eq-dur-move) var(--eq-spring);
}
}
}