Component
Tabs (Underline)
Tabs with a restrained 2px underline active indicator over the shared border.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/tabs/tabs-underline/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Tabs/tabs-underline React/Components/Tabs/tabs-underline import { createContext, useContext, useId, useRef, useState } from "react";
import type { HTMLAttributes, KeyboardEvent, ReactNode } from "react";
/**
* DevSnips React Tabs — accessible tabbed navigation as a compound component.
* Treatment: underline (restrained bottom rule on the selected tab)
*
* `<Tabs>` owns the selected value (controlled via `value` + `onValueChange`,
* or uncontrolled via `defaultValue`) and the arrow-key orientation.
* `<TabsList>` renders `role="tablist"` and owns arrow-key / Home / End
* navigation with automatic activation. `<TabsTrigger>` renders a native
* `role="tab"` button with roving `tabIndex`. `<TabsContent>` renders the
* associated `role="tabpanel"`; every panel stays mounted and is toggled with
* the `hidden` attribute so panel state is preserved.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type TabsOrientation = "horizontal" | "vertical";
interface TabsContextValue {
value: string | undefined;
setValue: (next: string) => void;
orientation: TabsOrientation;
baseId: string;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs(component: string): TabsContextValue {
const context = useContext(TabsContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Tabs>.`);
}
return context;
}
const LIST_HORIZONTAL_CLASSES = "inline-flex max-w-full flex-wrap items-center gap-1 border-b border-[var(--ds-color-border)]";
const LIST_VERTICAL_CLASSES = "flex w-full flex-col items-stretch gap-1 sm:w-56 sm:shrink-0";
const TRIGGER_BASE_CLASSES =
"inline-flex h-9 shrink-0 select-none items-center gap-2 whitespace-nowrap px-3 text-sm leading-5 font-medium transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const TRIGGER_TREATMENT_CLASSES = "-mb-px rounded-none border-b-2";
const TRIGGER_SELECTED_CLASSES = "border-[var(--ds-color-primary)] text-[var(--ds-color-foreground)]";
const TRIGGER_IDLE_CLASSES = "border-transparent text-[var(--ds-color-muted-foreground)] hover:border-[var(--ds-color-border-strong)] hover:text-[var(--ds-color-foreground)]";
const ICON_CLASSES = "inline-flex shrink-0 text-[14px] [&_svg]:size-3.5";
const BADGE_CLASSES =
"rounded-[var(--ds-radius-xs)] bg-[var(--ds-color-accent-soft)] px-1.5 py-0.5 text-[11px] font-medium leading-3 text-[var(--ds-color-accent)]";
const COUNT_CLASSES =
"inline-flex min-w-5 items-center justify-center rounded-full border border-[var(--ds-color-border)] px-1.5 py-0.5 text-[11px] font-medium leading-3 tabular-nums text-[var(--ds-color-muted-foreground)]";
export interface TabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
/** Selected tab value (controlled). Omit to run uncontrolled. */
value?: string;
/** Initially selected tab value (uncontrolled). */
defaultValue?: string;
/** Called with the next value whenever the selection changes. */
onValueChange?: (value: string) => void;
/** Arrow-key navigation axis + layout. */
orientation?: TabsOrientation;
className?: string;
children?: ReactNode;
}
export function Tabs({
value,
defaultValue,
onValueChange,
orientation = "horizontal",
className,
children,
...rest
}: TabsProps) {
const baseId = useId().replace(/:/g, "");
const isControlled = value !== undefined;
const [internal, setInternal] = useState<string | undefined>(defaultValue);
const current = isControlled ? value : internal;
function setValue(next: string) {
if (!isControlled) {
setInternal(next);
}
onValueChange?.(next);
}
return (
<TabsContext.Provider value={{ value: current, setValue, orientation, baseId }}>
{orientation === "vertical" ? (
<div className={cx("flex flex-col gap-4 sm:flex-row sm:gap-6", className)} {...rest}>
{children}
</div>
) : (
<div className={className} {...rest}>
{children}
</div>
)}
</TabsContext.Provider>
);
}
export interface TabsListProps {
/** Group label for the tablist (recommended). */
"aria-label"?: string;
className?: string;
children?: ReactNode;
}
export function TabsList({ className, children, ...rest }: TabsListProps) {
const context = useTabs("TabsList");
const listRef = useRef<HTMLDivElement>(null);
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const horizontal = context.orientation === "horizontal";
const moveKeys = horizontal ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
if (!moveKeys.concat(["Home", "End"]).includes(event.key)) return;
const list = listRef.current;
if (!list) return;
const tabs = Array.from(list.querySelectorAll<HTMLButtonElement>('[role="tab"]')).filter(
(tab) => !tab.disabled,
);
if (tabs.length === 0) return;
event.preventDefault();
const currentIndex = tabs.findIndex((tab) => tab === event.target);
let nextIndex: number;
if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = tabs.length - 1;
} else if (currentIndex === -1) {
nextIndex = 0;
} else {
const delta = event.key === moveKeys[1] ? 1 : -1;
nextIndex = (currentIndex + delta + tabs.length) % tabs.length;
}
const next = tabs[nextIndex];
next.focus();
if (next.dataset.value !== undefined) {
context.setValue(next.dataset.value);
}
}
return (
<div
ref={listRef}
role="tablist"
aria-orientation={context.orientation}
onKeyDown={onKeyDown}
className={cx(
context.orientation === "vertical" ? LIST_VERTICAL_CLASSES : LIST_HORIZONTAL_CLASSES,
className,
)}
{...rest}
>
{children}
</div>
);
}
export interface TabsTriggerProps {
/** Value this trigger selects (associates it with its panel). */
value: string;
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
/** Small contextual chip after the label (e.g. "New", "Beta"). */
badge?: ReactNode;
/** Meaningful numeric count after the label (e.g. open comments). */
count?: number;
disabled?: boolean;
className?: string;
children?: ReactNode;
}
export function TabsTrigger({
value,
icon,
badge,
count,
disabled,
className,
children,
}: TabsTriggerProps) {
const context = useTabs("TabsTrigger");
const isSelected = context.value === value;
const triggerId = `${context.baseId}-tab-${value}`;
const panelId = `${context.baseId}-panel-${value}`;
return (
<button
type="button"
role="tab"
id={triggerId}
aria-selected={isSelected}
aria-controls={panelId}
tabIndex={isSelected ? 0 : -1}
disabled={disabled}
data-value={value}
onClick={() => context.setValue(value)}
className={cx(
TRIGGER_BASE_CLASSES,
context.orientation === "vertical" ? "w-full justify-start" : "justify-center",
TRIGGER_TREATMENT_CLASSES,
isSelected ? TRIGGER_SELECTED_CLASSES : TRIGGER_IDLE_CLASSES,
className,
)}
>
{icon ? (
<span aria-hidden="true" className={ICON_CLASSES}>
{icon}
</span>
) : null}
{children}
{badge !== undefined ? <span className={BADGE_CLASSES}>{badge}</span> : null}
{count !== undefined ? <span className={COUNT_CLASSES}>{count}</span> : null}
</button>
);
}
export interface TabsContentProps {
/** Value of the tab this panel belongs to. */
value: string;
className?: string;
children?: ReactNode;
}
export function TabsContent({ value, className, children }: TabsContentProps) {
const context = useTabs("TabsContent");
const isSelected = context.value === value;
const triggerId = `${context.baseId}-tab-${value}`;
const panelId = `${context.baseId}-panel-${value}`;
return (
<div
role="tabpanel"
id={panelId}
aria-labelledby={triggerId}
tabIndex={0}
hidden={!isSelected}
className={cx(
context.orientation === "vertical" ? "min-w-0 flex-1 pt-0" : "pt-4",
"rounded-[var(--ds-radius-sm)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)]",
className,
)}
>
{children}
</div>
);
}
export default Tabs; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { createContext, useContext, useId, useRef, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TabsContext = createContext(null);
function useTabs(component) {
const context = useContext(TabsContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Tabs>.`);
}
return context;
}
const LIST_HORIZONTAL_CLASSES = "inline-flex max-w-full flex-wrap items-center gap-1 border-b border-[var(--ds-color-border)]";
const LIST_VERTICAL_CLASSES = "flex w-full flex-col items-stretch gap-1 sm:w-56 sm:shrink-0";
const TRIGGER_BASE_CLASSES = "inline-flex h-9 shrink-0 select-none items-center gap-2 whitespace-nowrap px-3 text-sm leading-5 font-medium transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const TRIGGER_TREATMENT_CLASSES = "-mb-px rounded-none border-b-2";
const TRIGGER_SELECTED_CLASSES = "border-[var(--ds-color-primary)] text-[var(--ds-color-foreground)]";
const TRIGGER_IDLE_CLASSES = "border-transparent text-[var(--ds-color-muted-foreground)] hover:border-[var(--ds-color-border-strong)] hover:text-[var(--ds-color-foreground)]";
const ICON_CLASSES = "inline-flex shrink-0 text-[14px] [&_svg]:size-3.5";
const BADGE_CLASSES = "rounded-[var(--ds-radius-xs)] bg-[var(--ds-color-accent-soft)] px-1.5 py-0.5 text-[11px] font-medium leading-3 text-[var(--ds-color-accent)]";
const COUNT_CLASSES = "inline-flex min-w-5 items-center justify-center rounded-full border border-[var(--ds-color-border)] px-1.5 py-0.5 text-[11px] font-medium leading-3 tabular-nums text-[var(--ds-color-muted-foreground)]";
function Tabs({
value,
defaultValue,
onValueChange,
orientation = "horizontal",
className,
children,
...rest
}) {
const baseId = useId().replace(/:/g, "");
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const current = isControlled ? value : internal;
function setValue(next) {
if (!isControlled) {
setInternal(next);
}
onValueChange?.(next);
}
return <TabsContext.Provider value={{ value: current, setValue, orientation, baseId }}>
{orientation === "vertical" ? <div className={cx("flex flex-col gap-4 sm:flex-row sm:gap-6", className)} {...rest}>
{children}
</div> : <div className={className} {...rest}>
{children}
</div>}
</TabsContext.Provider>;
}
function TabsList({ className, children, ...rest }) {
const context = useTabs("TabsList");
const listRef = useRef(null);
function onKeyDown(event) {
const horizontal = context.orientation === "horizontal";
const moveKeys = horizontal ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
if (!moveKeys.concat(["Home", "End"]).includes(event.key)) return;
const list = listRef.current;
if (!list) return;
const tabs = Array.from(list.querySelectorAll('[role="tab"]')).filter(
(tab) => !tab.disabled
);
if (tabs.length === 0) return;
event.preventDefault();
const currentIndex = tabs.findIndex((tab) => tab === event.target);
let nextIndex;
if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = tabs.length - 1;
} else if (currentIndex === -1) {
nextIndex = 0;
} else {
const delta = event.key === moveKeys[1] ? 1 : -1;
nextIndex = (currentIndex + delta + tabs.length) % tabs.length;
}
const next = tabs[nextIndex];
next.focus();
if (next.dataset.value !== undefined) {
context.setValue(next.dataset.value);
}
}
return <div
ref={listRef}
role="tablist"
aria-orientation={context.orientation}
onKeyDown={onKeyDown}
className={cx(
context.orientation === "vertical" ? LIST_VERTICAL_CLASSES : LIST_HORIZONTAL_CLASSES,
className
)}
{...rest}
>
{children}
</div>;
}
function TabsTrigger({
value,
icon,
badge,
count,
disabled,
className,
children
}) {
const context = useTabs("TabsTrigger");
const isSelected = context.value === value;
const triggerId = `${context.baseId}-tab-${value}`;
const panelId = `${context.baseId}-panel-${value}`;
return <button
type="button"
role="tab"
id={triggerId}
aria-selected={isSelected}
aria-controls={panelId}
tabIndex={isSelected ? 0 : -1}
disabled={disabled}
data-value={value}
onClick={() => context.setValue(value)}
className={cx(
TRIGGER_BASE_CLASSES,
context.orientation === "vertical" ? "w-full justify-start" : "justify-center",
TRIGGER_TREATMENT_CLASSES,
isSelected ? TRIGGER_SELECTED_CLASSES : TRIGGER_IDLE_CLASSES,
className
)}
>
{icon ? <span aria-hidden="true" className={ICON_CLASSES}>
{icon}
</span> : null}
{children}
{badge !== undefined ? <span className={BADGE_CLASSES}>{badge}</span> : null}
{count !== undefined ? <span className={COUNT_CLASSES}>{count}</span> : null}
</button>;
}
function TabsContent({ value, className, children }) {
const context = useTabs("TabsContent");
const isSelected = context.value === value;
const triggerId = `${context.baseId}-tab-${value}`;
const panelId = `${context.baseId}-panel-${value}`;
return <div
role="tabpanel"
id={panelId}
aria-labelledby={triggerId}
tabIndex={0}
hidden={!isSelected}
className={cx(
context.orientation === "vertical" ? "min-w-0 flex-1 pt-0" : "pt-4",
"rounded-[var(--ds-radius-sm)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)]",
className
)}
>
{children}
</div>;
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
export default Tabs; # Tabs (Underline)
Tabs with a restrained 2px underline active indicator over the shared border.
## Usage
```tsx
<Tabs defaultValue="overview">
<TabsList aria-label="Workspace navigation">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="comments">Comments</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
…
</Tabs>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
<Tabs defaultValue="overview">
<TabsList aria-label="Workspace navigation">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="comments">Comments</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
…
</Tabs>
```
## Props
### `<Tabs>`
| Name | Type | Default | Description |
|---|---|---|---|
| `value` | `string` | — | Selected tab value (controlled). |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled). |
| `onValueChange` | `(value: string) => void` | — | Selection callback. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Arrow-key navigation axis + layout. |
| `className` | `string` | — | Extra classes on the root. |
| `children` | `ReactNode` | — | `TabsList` + `TabsContent` composition. |
### `<TabsList>`
| Name | Type | Default | Description |
|---|---|---|---|
| `aria-label` | `string` | — | Group label for the tablist (recommended). |
| `className` | `string` | — | Extra classes on the tablist. |
| `children` | `ReactNode` | — | `TabsTrigger` elements. |
### `<TabsTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `value` | `string` (required) | — | Value this trigger selects; associates it with its panel. |
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered `aria-hidden`). |
| `badge` | `ReactNode` | — | Small contextual chip after the label. |
| `count` | `number` | — | Numeric chip after the label. |
| `disabled` | `boolean` | `false` | Prevents activation; skipped by key navigation. |
| `className` | `string` | — | Extra classes on the trigger. |
| `children` | `ReactNode` | — | Visible label. |
### `<TabsContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `value` | `string` (required) | — | Matches the owning `TabsTrigger`. |
| `className` | `string` | — | Extra classes on the panel. |
| `children` | `ReactNode` | — | Panel content. |
## Composition
Tabs is a compound component. Four primitives compose the pattern:
```tsx
<Tabs defaultValue="overview">
<TabsList aria-label="Project navigation">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">…</TabsContent>
<TabsContent value="activity">…</TabsContent>
</Tabs>
```
- `Tabs` — the root provider. Owns the selected value (controlled via `value` + `onValueChange`, uncontrolled via `defaultValue`) and the `orientation` (`"horizontal"` | `"vertical"`).
- `TabsList` — renders `role="tablist"` and owns arrow-key / Home / End navigation with automatic activation.
- `TabsTrigger` — renders a native `<button role="tab">` with roving `tabIndex`, plus the optional `icon` / `badge` / `count` content props.
- `TabsContent` — renders the `role="tabpanel"`. Every panel stays mounted; inactive panels carry the `hidden` attribute, so form and input state inside a panel is preserved.
The underline treatment differs only in its class constants — the list carries the shared bottom border and the selected trigger overlays it with a 2px primary rule (`-mb-px` alignment).
## Keyboard Interaction
| Key | Horizontal tabs | Vertical tabs |
|---|---|---|
| `ArrowRight` | Move focus + activate the next tab | — |
| `ArrowLeft` | Move focus + activate the previous tab | — |
| `ArrowDown` | — | Move focus + activate the next tab |
| `ArrowUp` | — | Move focus + activate the previous tab |
| `Home` | Activate the first tab | Activate the first tab |
| `End` | Activate the last tab | Activate the last tab |
Navigation wraps around the ends. Automatic activation is used: focus and selection move together through the tablist. Disabled tabs are skipped by arrow keys and removed from the tab order. Only the selected tab sits in the tab order (roving `tabIndex`).
## Accessibility
The structure follows the W3C Tabs pattern: one `role="tablist"` containing native `<button role="tab">` triggers, with `role="tabpanel"` content regions.
- Trigger → panel association: `aria-controls` points at the panel id; the panel answers with `aria-labelledby` pointing back at the trigger id.
- `aria-selected` mirrors the current selection on every trigger.
- Panels stay focusable (`tabIndex={0}`) so keyboard users can read into scrollable content.
The underline is a border indicator — supported by text weight change on select, so state never depends on color alone.
## States
- **Selected** — 2px `--ds-color-primary` underline + foreground text (weight and border, not color alone).
- **Idle** — transparent underline; hover reveals `--ds-color-border-strong`.
- **Focus-visible** — `--ds-color-focus-ring` outline.
- **Disabled** — native `disabled`; reduced opacity and removed from the tab order.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-active)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This tabs variant uses the semantic color, radius, spacing, and motion tokens.
## Notes
The wrap-tolerant underline variant. Because the list border is shared, wrapped rows keep a consistent baseline. 246 lines UTF-8 · LF · Spaces: 2
Continue browsing