Tabs show one panel at a time, chosen from a row of tabs that the arrow keys move along, built on Radix Tabs with Material Design 3 styling.

pnpm add @behivetech/atoms.tabs

You are viewing Tabs v0.1.0, snapshotted 2026-11-09. The latest is v0.1.1. Go to the latest →

registry/behivetech/atoms/tabs at v0.1.0, snapshotted 2026-11-09

tab-list.tsx
1import * as RadixTabs from "@radix-ui/react-tabs";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./tab-list.module.scss";
4
5export interface TabListProps extends Omit<
6 RadixTabs.TabsListProps,
7 "className"
8> {
9 /** Additional class names to merge with the component root element */
10 className?: string;
11}
12
13/** The row of `Tab` buttons. Give it an `aria-label` that names what the tabs switch. */
14export const TabList = ({ className, ...rest }: TabListProps) => {
15 const [rootClass] = getClassName({
16 className,
17 rootClass: "BHT__TabList",
18 styles,
19 });
20
21 return <RadixTabs.List {...rest} className={rootClass} />;
22};
tab-panel.tsx
1import * as RadixTabs from "@radix-ui/react-tabs";
2import { useSelectedTab } from "./tabs-context.js";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./tab-panel.module.scss";
5
6export interface TabPanelProps extends Omit<
7 RadixTabs.TabsContentProps,
8 "className"
9> {
10 /** Additional class names to merge with the component root element */
11 className?: string;
12 /** The `value` of the `Tab` that shows this panel */
13 value: string;
14}
15
16/** The content of one tab. Only the selected panel is mounted, unless `forceMount` keeps every panel mounted (the others are hidden) so their state survives a tab switch. */
17export const TabPanel = ({ className, value, ...rest }: TabPanelProps) => {
18 const selected = useSelectedTab();
19 const [rootClass] = getClassName({
20 className,
21 rootClass: "BHT__TabPanel",
22 styles,
23 });
24
25 return (
26 <RadixTabs.Content
27 {...rest}
28 value={value}
29 hidden={selected !== value}
30 className={rootClass}
31 />
32 );
33};
tab.tsx
1import * as RadixTabs from "@radix-ui/react-tabs";
2import { getClassName } from "@bht-component-registry/class-names";
3import { useSelectedTab } from "./tabs-context.js";
4import styles from "./tab.module.scss";
5
6export interface TabProps extends Omit<
7 RadixTabs.TabsTriggerProps,
8 "className"
9> {
10 /** Additional class names to merge with the component root element */
11 className?: string;
12 /** Identifies the tab and the `TabPanel` it shows */
13 value: string;
14}
15
16/** One tab button. It shows its `TabPanel` of the same `value` when selected. */
17export const Tab = ({ className, value, ...rest }: TabProps) => {
18 const selected = useSelectedTab();
19 const [rootClass] = getClassName({
20 className,
21 modifiers: { selected: selected === value },
22 rootClass: "BHT__Tab",
23 styles,
24 });
25
26 return <RadixTabs.Trigger {...rest} value={value} className={rootClass} />;
27};
tabs-context.ts
1import { createContext, useContext } from "react";
2
3/** What `Tabs` shares with its `Tab` children: the selected value. */
4export const TabsContext = createContext<string | undefined>(undefined);
5
6/** The value of the selected tab, read by `Tab` to set its `--selected` modifier. */
7export const useSelectedTab = () => useContext(TabsContext);
tabs.tsx
1import * as RadixTabs from "@radix-ui/react-tabs";
2import { useState } from "react";
3import { getClassName } from "@bht-component-registry/class-names";
4import { TabsContext } from "./tabs-context.js";
5import styles from "./tabs.module.scss";
6
7export interface TabsProps extends Omit<
8 RadixTabs.TabsProps,
9 "className" | "value" | "defaultValue" | "onValueChange"
10> {
11 /** Additional class names to merge with the component root element */
12 className?: string;
13 /** The selected tab's value when controlled */
14 value?: string;
15 /** The tab selected first when uncontrolled */
16 defaultValue?: string;
17 /** Called with the value of the tab the user selected */
18 onValueChange?: (value: string) => void;
19}
20
21/**
22 * A set of panels where one shows at a time, chosen from a row of tabs. Compose
23 * it with `TabList`, `Tab` and `TabPanel`. The arrow keys move between tabs,
24 * Home and End jump to the first and last, and the selected tab is the one tab
25 * stop of the row.
26 */
27export const Tabs = ({
28 className,
29 children,
30 value,
31 defaultValue = "",
32 onValueChange,
33 ...rest
34}: TabsProps) => {
35 const [internalValue, setInternalValue] = useState(defaultValue);
36 const selected = value ?? internalValue;
37 const [rootClass] = getClassName({
38 className,
39 rootClass: "BHT__Tabs",
40 styles,
41 });
42 const handleValueChange = (next: string) => {
43 setInternalValue(next);
44 onValueChange?.(next);
45 };
46
47 return (
48 <RadixTabs.Root
49 {...rest}
50 className={rootClass}
51 value={selected}
52 onValueChange={handleValueChange}
53 >
54 <TabsContext.Provider value={selected}>{children}</TabsContext.Provider>
55 </RadixTabs.Root>
56 );
57};
tab-list.module.scss
1.BHT__TabList {
2 display: flex;
3 border-bottom: 1px solid var(--md-sys-color-outline-variant);
4}
tab-panel.module.scss
1.BHT__TabPanel {
2 &:focus-visible {
3 outline: 3px solid var(--md-sys-color-secondary);
4 outline-offset: 2px;
5 }
6}
tab.module.scss
1// MD3 primary tab: label with an indicator under the selected one.
2.BHT__Tab {
3 position: relative;
4 flex: 1 1 0;
5 min-height: 3rem;
6 padding: 0 var(--spacing-4);
7 border: 0;
8 background: transparent;
9 color: var(--md-sys-color-on-surface-variant);
10 font-family: var(--md-sys-typescale-title-small-font);
11 font-size: var(--md-sys-typescale-title-small-size);
12 font-weight: var(--md-sys-typescale-title-small-weight);
13 line-height: var(--md-sys-typescale-title-small-line-height);
14 letter-spacing: var(--md-sys-typescale-title-small-tracking);
15 cursor: pointer;
16
17 &::after {
18 content: "";
19 position: absolute;
20 inset: auto 0 0;
21 height: 3px;
22 border-radius: 3px 3px 0 0;
23 background: transparent;
24 }
25
26 &:hover {
27 background: color-mix(
28 in srgb,
29 var(--md-sys-color-on-surface) 8%,
30 transparent
31 );
32 }
33
34 &:focus-visible {
35 outline: 3px solid var(--md-sys-color-secondary);
36 outline-offset: 2px;
37 }
38
39 // The label stays on-surface: the primary tint is under 4.5:1 on the dark
40 // Modal surface, so only the indicator carries it.
41 &--selected {
42 color: var(--md-sys-color-on-surface);
43
44 &::after {
45 background: var(--md-sys-color-primary);
46 }
47 }
48}
tabs.module.scss
1// Material Design 3 primary tabs: the root only lays out the row and panel.
2.BHT__Tabs {
3 display: flex;
4 flex-direction: column;
5 gap: var(--spacing-4);
6 color: var(--md-sys-color-on-surface);
7}
index.ts
1export { Tabs } from "./tabs.js";
2export type { TabsProps } from "./tabs.js";
3export { TabList } from "./tab-list.js";
4export type { TabListProps } from "./tab-list.js";
5export { Tab } from "./tab.js";
6export type { TabProps } from "./tab.js";
7export { TabPanel } from "./tab-panel.js";
8export type { TabPanelProps } from "./tab-panel.js";
tabs.example.tsx
1import { Tab, TabList, TabPanel, Tabs } from "./index.js";
2
3/** Two tabs, each showing its own panel. The arrow keys move between the tabs. */
4export const BasicTabs = () => (
5 <Tabs defaultValue="choose">
6 <TabList aria-label="Add an image">
7 <Tab value="choose">Choose</Tab>
8 <Tab value="upload">Upload</Tab>
9 </TabList>
10 <TabPanel value="choose">Pick an image from the library.</TabPanel>
11 <TabPanel value="upload">Upload a new image.</TabPanel>
12 </Tabs>
13);