Tabs

Underline and pill tab variants built on React Aria.

Underline

Animated underline with icons and counts.

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.json
npx shadcn@latest add https://boardcn.dev/r/tabs.json

npm 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.

components/base/tabs/tabs.tsx
"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,
        )
      }
    />
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

PillTab

PropTypeDefaultDescription
isSelectedrequiredboolean
onSelectrequired() => void
classNamestring
iconIconComponent
variant"blue" | "gray"blue

Tab

PropTypeDefaultDescription
countReactNodeOptional trailing count badge.
iconIconComponentOptional leading icon (16px). Inherits the label color.