Tabs
Underline and pill tab variants built on React Aria.
Underline
Animated underline with icons and counts.
Overview
Files12
Settings
Traffic, conversion, and revenue for the last 30 days.
function TabsUnderline() {
return (
<Tabs defaultSelectedKey="overview" className="w-full max-w-lg">
<TabList aria-label="Project sections">
<Tab id="overview" icon={RiBarChartLine}>
Overview
</Tab>
<Tab id="files" icon={RiFolderLine} count={12}>
Files
</Tab>
<Tab id="settings" icon={RiSettings3Line}>
Settings
</Tab>
</TabList>
<TabPanel id="overview" className="text-body-regular text-text-secondary">
Traffic, conversion, and revenue for the last 30 days.
</TabPanel>
<TabPanel id="files" className="text-body-regular text-text-secondary">
Twelve files across three folders.
</TabPanel>
<TabPanel id="settings" className="text-body-regular text-text-secondary">
Manage members, billing, and integrations.
</TabPanel>
</Tabs>
);
}function TabsUnderline() {
return (
<Tabs defaultSelectedKey="overview" className="w-full max-w-lg">
<TabList aria-label="Project sections">
<Tab id="overview" icon={RiBarChartLine}>
Overview
</Tab>
<Tab id="files" icon={RiFolderLine} count={12}>
Files
</Tab>
<Tab id="settings" icon={RiSettings3Line}>
Settings
</Tab>
</TabList>
<TabPanel id="overview" className="text-body-regular text-text-secondary">
Traffic, conversion, and revenue for the last 30 days.
</TabPanel>
<TabPanel id="files" className="text-body-regular text-text-secondary">
Twelve files across three folders.
</TabPanel>
<TabPanel id="settings" className="text-body-regular text-text-secondary">
Manage members, billing, and integrations.
</TabPanel>
</Tabs>
);
}Pills
The pill variant for compact switchers.
Seven-day rolling window.
/**
* PillTab is a controlled button rather than a React Aria tab, so the selection
* lives here instead of in a Tabs provider.
*/
export function TabsPill() {
const [selected, setSelected] = useState<string>("week");
const active = PILL_RANGES.find((range) => range.id === selected) ?? PILL_RANGES[1];
return (
<div className="flex w-full max-w-lg flex-col gap-4">
<PillTabList>
{PILL_RANGES.map((range) => (
<PillTab
key={range.id}
isSelected={selected === range.id}
onSelect={() => setSelected(range.id)}
>
{range.label}
</PillTab>
))}
</PillTabList>
<p className="text-body-regular text-text-secondary">{active.body}</p>
</div>
);
}/**
* PillTab is a controlled button rather than a React Aria tab, so the selection
* lives here instead of in a Tabs provider.
*/
export function TabsPill() {
const [selected, setSelected] = useState<string>("week");
const active = PILL_RANGES.find((range) => range.id === selected) ?? PILL_RANGES[1];
return (
<div className="flex w-full max-w-lg flex-col gap-4">
<PillTabList>
{PILL_RANGES.map((range) => (
<PillTab
key={range.id}
isSelected={selected === range.id}
onSelect={() => setSelected(range.id)}
>
{range.label}
</PillTab>
))}
</PillTabList>
<p className="text-body-regular text-text-secondary">{active.body}</p>
</div>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/tabs.jsonnpx shadcn@latest add https://boardcn.dev/r/tabs.jsonnpm packages
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The 2 files the CLI copies into your project.
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ComponentType, ReactNode, Ref } from "react";
import {
Tab as AriaTab,
TabList as AriaTabList,
TabPanel as AriaTabPanel,
Tabs as AriaTabs,
} from "react-aria-components";
import type {
TabListProps as AriaTabListProps,
TabPanelProps as AriaTabPanelProps,
TabProps as AriaTabProps,
TabsProps as AriaTabsProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export interface TabsProps extends AriaTabsProps {
ref?: Ref<HTMLDivElement>;
}
export function Tabs({ className, ref, ...props }: TabsProps) {
return (
<AriaTabs
ref={ref}
{...props}
className={(state) =>
cx(
"flex w-full flex-col gap-4",
state.orientation === "vertical" && "flex-row",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
export interface TabListProps<T extends object> extends AriaTabListProps<T> {
ref?: Ref<HTMLDivElement>;
}
type Underline = { left: number; width: number };
export function TabList<T extends object>({ className, ref, ...props }: TabListProps<T>) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [underline, setUnderline] = useState<Underline | null>(null);
useIsomorphicLayoutEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const measure = () => {
const selected = el.querySelector<HTMLElement>("[role='tab'][data-selected]");
if (selected) {
setUnderline({ left: selected.offsetLeft, width: selected.offsetWidth });
}
};
measure();
// Re-measure when selection flips (data-selected toggles) or size changes.
const mo = new MutationObserver(measure);
mo.observe(el, { attributes: true, subtree: true, attributeFilter: ["data-selected"] });
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
mo.disconnect();
ro.disconnect();
};
}, []);
return (
<div ref={wrapperRef} className="relative w-full">
<AriaTabList
ref={ref}
{...props}
className={(state) =>
cx(
"flex w-full items-center gap-1 border-b border-separator-border",
typeof className === "function" ? className(state) : className,
)
}
/>
{underline && (
<span
aria-hidden
className="pointer-events-none absolute bottom-0 left-0 h-0.5 bg-accent-600 transition-[transform,width] duration-200 ease"
style={{
transform: `translateX(${underline.left}px)`,
width: underline.width,
}}
/>
)}
</div>
);
}
export interface TabProps extends Omit<AriaTabProps, "children"> {
children?: ReactNode;
/** Optional leading icon (16px). Inherits the label color. */
icon?: IconComponent;
/** Optional trailing count badge. */
count?: ReactNode;
ref?: Ref<HTMLDivElement>;
}
export function Tab({ className, children, icon: Icon, count, ref, ...props }: TabProps) {
return (
<AriaTab
ref={ref}
{...props}
className={(state) =>
cx(
"relative inline-flex cursor-pointer items-center gap-2.5 px-2.5 py-2 whitespace-nowrap",
"outline-none transition-colors duration-150 ease",
"focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-border-focus-ring",
state.isDisabled && "cursor-not-allowed opacity-50",
typeof className === "function" ? className(state) : className,
)
}
>
{({ isSelected }) => (
<>
<span
className={cx(
"inline-flex items-center gap-1.5",
isSelected
? "text-body-medium text-accent-600"
: "text-body-regular text-text-primary",
)}
>
{Icon && <Icon className="size-4 shrink-0" aria-hidden />}
{children}
</span>
{count != null && (
<span
className={cx(
"inline-flex items-center justify-center rounded-sm px-1 py-px text-caption-1-medium whitespace-nowrap",
isSelected
? "bg-tab-count-selected-background text-accent-600"
: "bg-black/10 text-text-primary opacity-50",
)}
>
{count}
</span>
)}
</>
)}
</AriaTab>
);
}
export interface TabPanelProps extends AriaTabPanelProps {
ref?: Ref<HTMLDivElement>;
}
export function TabPanel({ className, ref, ...props }: TabPanelProps) {
return (
<AriaTabPanel
ref={ref}
{...props}
className={(state) =>
cx(
"outline-none focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-border-focus-ring",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ComponentType, ReactNode, Ref } from "react";
import {
Tab as AriaTab,
TabList as AriaTabList,
TabPanel as AriaTabPanel,
Tabs as AriaTabs,
} from "react-aria-components";
import type {
TabListProps as AriaTabListProps,
TabPanelProps as AriaTabPanelProps,
TabProps as AriaTabProps,
TabsProps as AriaTabsProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export interface TabsProps extends AriaTabsProps {
ref?: Ref<HTMLDivElement>;
}
export function Tabs({ className, ref, ...props }: TabsProps) {
return (
<AriaTabs
ref={ref}
{...props}
className={(state) =>
cx(
"flex w-full flex-col gap-4",
state.orientation === "vertical" && "flex-row",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
export interface TabListProps<T extends object> extends AriaTabListProps<T> {
ref?: Ref<HTMLDivElement>;
}
type Underline = { left: number; width: number };
export function TabList<T extends object>({ className, ref, ...props }: TabListProps<T>) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [underline, setUnderline] = useState<Underline | null>(null);
useIsomorphicLayoutEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const measure = () => {
const selected = el.querySelector<HTMLElement>("[role='tab'][data-selected]");
if (selected) {
setUnderline({ left: selected.offsetLeft, width: selected.offsetWidth });
}
};
measure();
// Re-measure when selection flips (data-selected toggles) or size changes.
const mo = new MutationObserver(measure);
mo.observe(el, { attributes: true, subtree: true, attributeFilter: ["data-selected"] });
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
mo.disconnect();
ro.disconnect();
};
}, []);
return (
<div ref={wrapperRef} className="relative w-full">
<AriaTabList
ref={ref}
{...props}
className={(state) =>
cx(
"flex w-full items-center gap-1 border-b border-separator-border",
typeof className === "function" ? className(state) : className,
)
}
/>
{underline && (
<span
aria-hidden
className="pointer-events-none absolute bottom-0 left-0 h-0.5 bg-accent-600 transition-[transform,width] duration-200 ease"
style={{
transform: `translateX(${underline.left}px)`,
width: underline.width,
}}
/>
)}
</div>
);
}
export interface TabProps extends Omit<AriaTabProps, "children"> {
children?: ReactNode;
/** Optional leading icon (16px). Inherits the label color. */
icon?: IconComponent;
/** Optional trailing count badge. */
count?: ReactNode;
ref?: Ref<HTMLDivElement>;
}
export function Tab({ className, children, icon: Icon, count, ref, ...props }: TabProps) {
return (
<AriaTab
ref={ref}
{...props}
className={(state) =>
cx(
"relative inline-flex cursor-pointer items-center gap-2.5 px-2.5 py-2 whitespace-nowrap",
"outline-none transition-colors duration-150 ease",
"focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-border-focus-ring",
state.isDisabled && "cursor-not-allowed opacity-50",
typeof className === "function" ? className(state) : className,
)
}
>
{({ isSelected }) => (
<>
<span
className={cx(
"inline-flex items-center gap-1.5",
isSelected
? "text-body-medium text-accent-600"
: "text-body-regular text-text-primary",
)}
>
{Icon && <Icon className="size-4 shrink-0" aria-hidden />}
{children}
</span>
{count != null && (
<span
className={cx(
"inline-flex items-center justify-center rounded-sm px-1 py-px text-caption-1-medium whitespace-nowrap",
isSelected
? "bg-tab-count-selected-background text-accent-600"
: "bg-black/10 text-text-primary opacity-50",
)}
>
{count}
</span>
)}
</>
)}
</AriaTab>
);
}
export interface TabPanelProps extends AriaTabPanelProps {
ref?: Ref<HTMLDivElement>;
}
export function TabPanel({ className, ref, ...props }: TabPanelProps) {
return (
<AriaTabPanel
ref={ref}
{...props}
className={(state) =>
cx(
"outline-none focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-border-focus-ring",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ComponentType, HTMLAttributes, ReactNode, Ref } from "react";
import { cx, sortCx } from "@/utils/cx";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export type PillTabVariant = "blue" | "gray";
const styles = sortCx({
radius: {
blue: "rounded-full",
gray: "rounded-2lg",
},
thumb: {
blue: "bg-pill-tab-blue-selected-background",
gray: "bg-background-tertiary-default",
},
hover: {
blue: "bg-pill-tab-blue-hover-background",
gray: "bg-background-primary-hover",
},
selectedIcon: {
blue: "text-accent-500",
gray: "text-foreground-icon-primary",
},
selectedLabel: {
blue: "text-accent-500",
gray: "text-text-primary",
},
});
type Thumb = {
left: number;
top: number;
width: number;
height: number;
variant: PillTabVariant;
};
export interface PillTabListProps extends HTMLAttributes<HTMLDivElement> {
ref?: Ref<HTMLDivElement>;
}
export function PillTabList({ children, className, ref, ...props }: PillTabListProps) {
const innerRef = useRef<HTMLDivElement>(null);
const [thumb, setThumb] = useState<Thumb | null>(null);
useIsomorphicLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const measure = () => {
const selected = el.querySelector<HTMLElement>("[data-pill-selected]");
if (!selected) return;
setThumb({
left: selected.offsetLeft,
top: selected.offsetTop,
width: selected.offsetWidth,
height: selected.offsetHeight,
variant: selected.dataset.pillVariant === "gray" ? "gray" : "blue",
});
};
measure();
const mo = new MutationObserver(measure);
mo.observe(el, {
attributes: true,
subtree: true,
attributeFilter: ["data-pill-selected"],
});
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
mo.disconnect();
ro.disconnect();
};
}, []);
const setRefs = (node: HTMLDivElement | null) => {
innerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
};
return (
<div
ref={setRefs}
role="group"
className={cx("relative inline-flex items-center gap-1", className)}
{...props}
>
{thumb && (
<span
aria-hidden
className={cx(
"pointer-events-none absolute left-0 top-0",
"transition-[transform,width,height] duration-300",
"[transition-timing-function:cubic-bezier(0.34,1.2,0.64,1)]",
styles.radius[thumb.variant],
styles.thumb[thumb.variant],
)}
style={{
transform: `translate(${thumb.left}px, ${thumb.top}px)`,
width: thumb.width,
height: thumb.height,
}}
/>
)}
{children}
</div>
);
}
export function PillTab({
variant = "blue",
icon: Icon,
isSelected,
onSelect,
children,
className,
}: {
variant?: PillTabVariant;
icon?: IconComponent;
isSelected: boolean;
onSelect: () => void;
children: ReactNode;
className?: string;
}) {
return (
<button
type="button"
aria-pressed={isSelected}
data-pill-selected={isSelected ? "" : undefined}
data-pill-variant={variant}
onClick={onSelect}
className={cx(
"group relative z-10 flex shrink-0 cursor-pointer items-center gap-1 px-2 py-[5px]",
styles.radius[variant],
"outline-none transition-colors duration-150 ease",
"focus-visible:ring-2 focus-visible:ring-border-focus-ring",
className,
)}
>
<span
aria-hidden
className={cx(
"pointer-events-none absolute inset-0 opacity-0",
"transition-opacity duration-200 ease-out",
styles.radius[variant],
styles.hover[variant],
!isSelected && "group-hover:opacity-100",
)}
/>
{Icon && (
<Icon
className={cx(
"relative z-10 size-5 shrink-0",
isSelected ? styles.selectedIcon[variant] : "text-foreground-icon-secondary",
)}
aria-hidden
/>
)}
<span
className={cx(
"relative z-10 text-body-medium whitespace-nowrap",
isSelected ? styles.selectedLabel[variant] : "text-text-secondary",
)}
>
{children}
</span>
</button>
);
}"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ComponentType, HTMLAttributes, ReactNode, Ref } from "react";
import { cx, sortCx } from "@/utils/cx";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export type PillTabVariant = "blue" | "gray";
const styles = sortCx({
radius: {
blue: "rounded-full",
gray: "rounded-2lg",
},
thumb: {
blue: "bg-pill-tab-blue-selected-background",
gray: "bg-background-tertiary-default",
},
hover: {
blue: "bg-pill-tab-blue-hover-background",
gray: "bg-background-primary-hover",
},
selectedIcon: {
blue: "text-accent-500",
gray: "text-foreground-icon-primary",
},
selectedLabel: {
blue: "text-accent-500",
gray: "text-text-primary",
},
});
type Thumb = {
left: number;
top: number;
width: number;
height: number;
variant: PillTabVariant;
};
export interface PillTabListProps extends HTMLAttributes<HTMLDivElement> {
ref?: Ref<HTMLDivElement>;
}
export function PillTabList({ children, className, ref, ...props }: PillTabListProps) {
const innerRef = useRef<HTMLDivElement>(null);
const [thumb, setThumb] = useState<Thumb | null>(null);
useIsomorphicLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const measure = () => {
const selected = el.querySelector<HTMLElement>("[data-pill-selected]");
if (!selected) return;
setThumb({
left: selected.offsetLeft,
top: selected.offsetTop,
width: selected.offsetWidth,
height: selected.offsetHeight,
variant: selected.dataset.pillVariant === "gray" ? "gray" : "blue",
});
};
measure();
const mo = new MutationObserver(measure);
mo.observe(el, {
attributes: true,
subtree: true,
attributeFilter: ["data-pill-selected"],
});
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
mo.disconnect();
ro.disconnect();
};
}, []);
const setRefs = (node: HTMLDivElement | null) => {
innerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
};
return (
<div
ref={setRefs}
role="group"
className={cx("relative inline-flex items-center gap-1", className)}
{...props}
>
{thumb && (
<span
aria-hidden
className={cx(
"pointer-events-none absolute left-0 top-0",
"transition-[transform,width,height] duration-300",
"[transition-timing-function:cubic-bezier(0.34,1.2,0.64,1)]",
styles.radius[thumb.variant],
styles.thumb[thumb.variant],
)}
style={{
transform: `translate(${thumb.left}px, ${thumb.top}px)`,
width: thumb.width,
height: thumb.height,
}}
/>
)}
{children}
</div>
);
}
export function PillTab({
variant = "blue",
icon: Icon,
isSelected,
onSelect,
children,
className,
}: {
variant?: PillTabVariant;
icon?: IconComponent;
isSelected: boolean;
onSelect: () => void;
children: ReactNode;
className?: string;
}) {
return (
<button
type="button"
aria-pressed={isSelected}
data-pill-selected={isSelected ? "" : undefined}
data-pill-variant={variant}
onClick={onSelect}
className={cx(
"group relative z-10 flex shrink-0 cursor-pointer items-center gap-1 px-2 py-[5px]",
styles.radius[variant],
"outline-none transition-colors duration-150 ease",
"focus-visible:ring-2 focus-visible:ring-border-focus-ring",
className,
)}
>
<span
aria-hidden
className={cx(
"pointer-events-none absolute inset-0 opacity-0",
"transition-opacity duration-200 ease-out",
styles.radius[variant],
styles.hover[variant],
!isSelected && "group-hover:opacity-100",
)}
/>
{Icon && (
<Icon
className={cx(
"relative z-10 size-5 shrink-0",
isSelected ? styles.selectedIcon[variant] : "text-foreground-icon-secondary",
)}
aria-hidden
/>
)}
<span
className={cx(
"relative z-10 text-body-medium whitespace-nowrap",
isSelected ? styles.selectedLabel[variant] : "text-text-secondary",
)}
>
{children}
</span>
</button>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
PillTab
| Prop | Type | Default | Description |
|---|---|---|---|
| isSelectedrequired | boolean | — | — |
| onSelectrequired | () => void | — | — |
| className | string | — | — |
| icon | IconComponent | — | — |
| variant | "blue" | "gray" | blue | — |
Tab
| Prop | Type | Default | Description |
|---|---|---|---|
| count | ReactNode | — | Optional trailing count badge. |
| icon | IconComponent | — | Optional leading icon (16px). Inherits the label color. |