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.
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";45export 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}1213/** 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 });2021 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";56export 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}1516/** 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 });2425 return (26 <RadixTabs.Content27 {...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";56export 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}1516/** 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 });2526 return <RadixTabs.Trigger {...rest} value={value} className={rootClass} />;27};
tabs-context.ts
1import { createContext, useContext } from "react";23/** What `Tabs` shares with its `Tab` children: the selected value. */4export const TabsContext = createContext<string | undefined>(undefined);56/** 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";67export 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}2021/**22 * A set of panels where one shows at a time, chosen from a row of tabs. Compose23 * 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 tab25 * stop of the row.26 */27export const Tabs = ({28 className,29 children,30 value,31 defaultValue = "",32 onValueChange,33 ...rest34}: 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 };4647 return (48 <RadixTabs.Root49 {...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;1617 &::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 }2526 &:hover {27 background: color-mix(28 in srgb,29 var(--md-sys-color-on-surface) 8%,30 transparent31 );32 }3334 &:focus-visible {35 outline: 3px solid var(--md-sys-color-secondary);36 outline-offset: 2px;37 }3839 // The label stays on-surface: the primary tint is under 4.5:1 on the dark40 // Modal surface, so only the indicator carries it.41 &--selected {42 color: var(--md-sys-color-on-surface);4344 &::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";23/** 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);