Composite

Tabs

Tabs accepts a `tabs` array of title/content items with optional icons and badges. Supports controlled index selection, scroll buttons, first/last navigation, and animated panel height on switch.

Installation

npx shadcn add https://rck.vibeboxph.com//r/tabs.json

Live preview & controls

Overview panel content.
Controls
<Tabs
  tabs={[
    { title: "Overview", content: "Overview panel content." },
    { title: "Usage", content: "Usage panel content." },
  ]}
/>

Anatomy

  • TabsRoot component rendering the tab strip and active panel.
  • tabs[].title / contentTab label and panel body for each item.
  • tabs[].icon / badgeOptional leading icon and count badge on a tab.

Props

PropTypeDefaultDescription
tabsTabItem[]Tab definitions with title, content, disabled, icon, and badge.
activeTab / defaultActiveTabnumberControlled / uncontrolled active tab index.
onTabChange(index: number) => voidFires when the active tab changes.
variant"underline" | "pill" | "outline"pillVisual style for the tab strip.
orientation"horizontal" | "vertical"horizontalTab list layout; switches arrow-key axis.
fullHeaderbooleanfalseStretch tabs to fill the header width.
firstLastNavControlbooleanfalseShow jump-to-first/last tab buttons.
classNamestringMerged onto the root tabs container.
id (TabItem)stringOptional stable id for the tab panel.
iconClassName (TabItem)stringExtra classes on the tab icon.
badgeVariant (TabItem)Badge variantVisual style for the tab count badge.

Accessibility

  • Implements the WAI-ARIA Tabs pattern: `role="tablist"`/`"tab"`/`"tabpanel"` with `aria-selected` and `aria-controls`.
  • Arrow Left/Right (horizontal) or Up/Down (vertical) move focus; Enter/Space activates the focused tab.
  • Home/End jump to the first or last enabled tab. Disabled tabs are skipped when navigating.

Source

registry/rck/ui/tabs.tsx
"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { Badge, type BadgeProps } from "@/registry/rck/ui/badge";
import { Icon, type IconName } from "@/registry/rck/ui/icon";

export type TabItem = {
  id?: string;
  title: React.ReactNode;
  content: React.ReactNode;
  disabled?: boolean;
  icon?: IconName;
  iconClassName?: string;
  badge?: number;
  badgeVariant?: BadgeProps["variant"];
};

export interface TabsProps {
  tabs: TabItem[];
  activeTab?: number;
  defaultActiveTab?: number;
  onTabChange?: (index: number) => void;
  variant?: "underline" | "pill" | "outline";
  orientation?: "horizontal" | "vertical";
  fullHeader?: boolean;
  firstLastNavControl?: boolean;
  className?: string;
}

type TabsVariant = NonNullable<TabsProps["variant"]>;
type TabsOrientation = NonNullable<TabsProps["orientation"]>;

function getInitialTab(tabs: TabItem[], preferred?: number) {
  if (preferred != null && tabs[preferred] && !tabs[preferred].disabled) return preferred;
  const firstEnabled = tabs.findIndex((tab) => !tab.disabled);
  return firstEnabled >= 0 ? firstEnabled : 0;
}

function tabListClasses(
  variant: TabsVariant,
  orientation: TabsOrientation,
  fullHeader?: boolean,
) {
  return cn(
    "inline-flex min-h-9 items-center",
    fullHeader && orientation === "horizontal" && "w-full",
    orientation === "vertical" && "h-fit flex-col items-stretch",
    variant === "underline" &&
      cn(
        orientation === "horizontal"
          ? "gap-1 border-b border-border px-1"
          : "gap-1 border-r border-border pr-3",
      ),
    variant === "pill" && "gap-1 rounded-lg bg-surface-2 p-1",
    variant === "outline" && "gap-1 rounded-lg border border-border bg-card p-1",
  );
}

function tabTriggerClasses(
  variant: TabsVariant,
  orientation: TabsOrientation,
  active: boolean,
  fullHeader?: boolean,
) {
  return cn(
    "inline-flex min-h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium",
    "outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
    fullHeader && orientation === "horizontal" && "flex-1",
    variant === "underline" &&
      cn(
        orientation === "horizontal"
          ? cn(
              "rounded-none border-b-2 border-transparent px-3 pb-2.5 text-muted-foreground",
              active && "border-primary text-foreground",
              !active && "hover:bg-surface-2/60 hover:text-foreground",
            )
          : cn(
              "rounded-none border-r-2 border-transparent px-3 py-2 text-left text-muted-foreground",
              active && "border-primary text-foreground",
              !active && "hover:bg-surface-2/60 hover:text-foreground",
            ),
      ),
    variant === "pill" &&
      cn(
        "text-muted-foreground",
        active && "bg-background text-foreground shadow-sm",
        !active && "hover:bg-background/60 hover:text-foreground",
      ),
    variant === "outline" &&
      cn(
        "text-muted-foreground",
        active && "bg-primary text-primary-foreground shadow-sm",
        !active && "hover:bg-surface-2 hover:text-foreground",
      ),
  );
}

export function Tabs({
  tabs,
  activeTab,
  defaultActiveTab,
  onTabChange,
  variant = "pill",
  orientation = "horizontal",
  fullHeader = false,
  firstLastNavControl = false,
  className,
}: TabsProps) {
  const baseId = React.useId();
  const tabListRef = React.useRef<HTMLDivElement>(null);
  const panelOuterRef = React.useRef<HTMLDivElement>(null);
  const panelInnerRef = React.useRef<HTMLDivElement>(null);
  const lockedRef = React.useRef<number | null>(null);
  const firstRevealRef = React.useRef(true);
  const heightUnlockTimerRef = React.useRef<number | null>(null);
  const roRef = React.useRef<ResizeObserver | null>(null);

  const [focusedTab, setFocusedTab] = React.useState(() =>
    getInitialTab(tabs, defaultActiveTab),
  );
  const [isScrollable, setIsScrollable] = React.useState(false);
  const [lockedHeight, setLockedHeight] = React.useState<number | null>(null);

  const [selectedTab, setSelectedTab] = useControllableState<number>({
    prop: activeTab,
    defaultProp: getInitialTab(tabs, defaultActiveTab),
    onChange: onTabChange,
  });

  const checkScrollable = React.useCallback(() => {
    const list = tabListRef.current;
    if (!list) return;
    if (orientation === "horizontal") {
      setIsScrollable(list.scrollWidth > list.clientWidth + 1);
    } else {
      setIsScrollable(list.scrollHeight > list.clientHeight + 1);
    }
  }, [orientation]);

  React.useEffect(() => {
    lockedRef.current = lockedHeight;
  }, [lockedHeight]);

  const clearHeightTimer = React.useCallback(() => {
    if (heightUnlockTimerRef.current != null) {
      window.clearTimeout(heightUnlockTimerRef.current);
      heightUnlockTimerRef.current = null;
    }
  }, []);

  const unlockHeightSoon = React.useCallback(
    (ms = 240) => {
      clearHeightTimer();
      heightUnlockTimerRef.current = window.setTimeout(() => {
        setLockedHeight(null);
        heightUnlockTimerRef.current = null;
      }, ms);
    },
    [clearHeightTimer],
  );

  const getWrapperPaddingY = React.useCallback((outer: HTMLElement) => {
    const cs = getComputedStyle(outer);
    return parseFloat(cs.paddingTop || "0") + parseFloat(cs.paddingBottom || "0");
  }, []);

  const measurePrevHeight = React.useCallback((): number => {
    const outer = panelOuterRef.current;
    if (outer) return outer.offsetHeight;
    const inner = panelInnerRef.current;
    return inner ? inner.offsetHeight : 0;
  }, []);

  const measureNaturalHeight = React.useCallback((): number => {
    const outer = panelOuterRef.current;
    const inner = panelInnerRef.current;
    if (!outer) return 0;

    const scrollH = outer.scrollHeight;
    if (scrollH > 0) return scrollH;

    if (inner) {
      const padY = getWrapperPaddingY(outer);
      return inner.offsetHeight + padY;
    }

    return 0;
  }, [getWrapperPaddingY]);

  React.useEffect(() => {
    return () => {
      clearHeightTimer();
      roRef.current?.disconnect();
    };
  }, [clearHeightTimer]);

  React.useEffect(() => {
    if (activeTab !== undefined) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- lock height during tab cross-fade
      setLockedHeight(measurePrevHeight());
      setFocusedTab(activeTab);
    }
  }, [activeTab, measurePrevHeight]);

  React.useEffect(() => {
    checkScrollable();
    window.addEventListener("resize", checkScrollable);

    const list = tabListRef.current;
    let listObserver: ResizeObserver | undefined;
    if (list && typeof ResizeObserver !== "undefined") {
      listObserver = new ResizeObserver(checkScrollable);
      listObserver.observe(list);
    }

    return () => {
      window.removeEventListener("resize", checkScrollable);
      listObserver?.disconnect();
    };
  }, [tabs, orientation, checkScrollable]);

  React.useEffect(() => {
    let frame = 0;
    frame = requestAnimationFrame(() => {
      const targetH = measureNaturalHeight();
      if (!targetH || targetH <= 0) return;

      if (firstRevealRef.current) {
        setLockedHeight(targetH);
        unlockHeightSoon(0);
        firstRevealRef.current = false;
        return;
      }

      if (lockedRef.current == null) {
        setLockedHeight(measurePrevHeight());
      }

      if (lockedRef.current !== targetH) {
        setLockedHeight(targetH);
        unlockHeightSoon(320);
      }
    });

    return () => cancelAnimationFrame(frame);
  }, [selectedTab, measureNaturalHeight, measurePrevHeight, unlockHeightSoon]);

  React.useEffect(() => {
    const inner = panelInnerRef.current;
    if (!inner || typeof ResizeObserver === "undefined") return;

    const ro = new ResizeObserver(() => {
      if (lockedRef.current == null) return;
      const nextH = measureNaturalHeight();
      if (!nextH || nextH <= 0) return;
      if (nextH !== lockedRef.current) setLockedHeight(nextH);
      unlockHeightSoon(360);
    });

    ro.observe(inner);
    roRef.current = ro;

    return () => {
      ro.disconnect();
      roRef.current = null;
    };
  }, [selectedTab, measureNaturalHeight, unlockHeightSoon]);

  if (!tabs.length) return null;

  const getTabId = (index: number) => `${baseId}-tab-${index}`;
  const getPanelId = (index: number) => `${baseId}-panel-${index}`;

  const ensureTabVisible = (index: number) => {
    const el = tabListRef.current?.children[index] as HTMLElement | undefined;
    el?.scrollIntoView({
      behavior: "smooth",
      block: "nearest",
      inline: orientation === "horizontal" ? "center" : "nearest",
    });
  };

  const handleTabChange = (index: number) => {
    if (!tabs[index] || tabs[index].disabled || selectedTab === index) return;
    setLockedHeight(measurePrevHeight());
    setSelectedTab(index);
    setFocusedTab(index);
    ensureTabVisible(index);
  };

  const findNextEnabled = (start: number, direction: 1 | -1) => {
    let index = start;
    do {
      index += direction;
    } while (index >= 0 && index < tabs.length && tabs[index]?.disabled);
    return index >= 0 && index < tabs.length ? index : null;
  };

  const moveFocus = (direction: 1 | -1) => {
    const next = findNextEnabled(focusedTab, direction);
    if (next != null) {
      setFocusedTab(next);
      ensureTabVisible(next);
      (tabListRef.current?.children[next] as HTMLElement | undefined)?.focus();
    }
  };

  const handleKeyDown = (event: React.KeyboardEvent) => {
    const isHorizontal = orientation === "horizontal";
    const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp";
    const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown";

    if (event.key === prevKey) {
      event.preventDefault();
      moveFocus(-1);
    } else if (event.key === nextKey) {
      event.preventDefault();
      moveFocus(1);
    } else if (event.key === "Home") {
      event.preventDefault();
      const first = getInitialTab(tabs, 0);
      setFocusedTab(first);
      ensureTabVisible(first);
    } else if (event.key === "End") {
      event.preventDefault();
      for (let i = tabs.length - 1; i >= 0; i -= 1) {
        if (!tabs[i]?.disabled) {
          setFocusedTab(i);
          ensureTabVisible(i);
          break;
        }
      }
    } else if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      handleTabChange(focusedTab);
    }
  };

  const scrollTabs = (direction: 1 | -1) => {
    const list = tabListRef.current;
    if (!list) return;
    const delta =
      orientation === "horizontal" ? list.clientWidth * 0.6 : list.clientHeight * 0.6;
    if (orientation === "horizontal") {
      list.scrollBy({ left: direction * delta, behavior: "smooth" });
    } else {
      list.scrollBy({ top: direction * delta, behavior: "smooth" });
    }
  };

  const navButtonClass = cn(
    "inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card",
    "text-muted-foreground shadow-sm transition-colors",
    "hover:bg-surface-2 hover:text-foreground",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
  );

  const showHeaderChrome = variant !== "underline";

  return (
    <div
      data-slot="tabs"
      className={cn(
        orientation === "vertical" && "flex gap-4",
        orientation === "horizontal" && "w-full",
        className,
      )}
    >
      <div
        className={cn(
          "relative flex items-center gap-1.5",
          orientation === "vertical" && "flex-col",
          orientation === "horizontal" && "w-full",
          showHeaderChrome && "border-border bg-card rounded-lg border p-1.5",
        )}
      >
        {firstLastNavControl ? (
          <button
            type="button"
            className={navButtonClass}
            aria-label="First tab"
            onClick={() => handleTabChange(getInitialTab(tabs, 0))}
          >
            <ChevronsLeft className="size-4" aria-hidden="true" />
          </button>
        ) : null}

        {isScrollable ? (
          <button
            type="button"
            className={navButtonClass}
            aria-label="Scroll tabs backward"
            onClick={() => scrollTabs(-1)}
          >
            <ChevronLeft className="size-4" aria-hidden="true" />
          </button>
        ) : null}

        <div className="relative min-w-0 flex-1">
          {isScrollable && orientation === "horizontal" ? (
            <>
              <div
                aria-hidden="true"
                className="from-background pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r to-transparent"
              />
              <div
                aria-hidden="true"
                className="from-background pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l to-transparent"
              />
            </>
          ) : null}

          <div
            ref={tabListRef}
            role="tablist"
            aria-orientation={orientation}
            className={cn(
              tabListClasses(variant, orientation, fullHeader),
              isScrollable &&
                cn(
                  "scrollbar-none overflow-auto",
                  orientation === "horizontal" ? "w-full flex-nowrap" : "max-h-48",
                ),
            )}
            onKeyDown={handleKeyDown}
          >
            {tabs.map((tab, index) => {
              const active = selectedTab === index;
              const tabId = tab.id ?? getTabId(index);

              return (
                <button
                  key={tabId}
                  type="button"
                  role="tab"
                  id={getTabId(index)}
                  aria-selected={active}
                  aria-controls={getPanelId(index)}
                  aria-disabled={tab.disabled || undefined}
                  tabIndex={active ? 0 : -1}
                  disabled={tab.disabled}
                  className={tabTriggerClasses(variant, orientation, active, fullHeader)}
                  onFocus={() => !tab.disabled && setFocusedTab(index)}
                  onClick={() => handleTabChange(index)}
                  onKeyDown={handleKeyDown}
                >
                  {tab.icon ? (
                    <Icon name={tab.icon} size="sm" className={tab.iconClassName} />
                  ) : null}
                  <span>{tab.title}</span>
                  {tab.badge != null ? (
                    <Badge variant={tab.badgeVariant ?? "destructive"} size="xs">
                      {tab.badge}
                    </Badge>
                  ) : null}
                </button>
              );
            })}
          </div>
        </div>

        {isScrollable ? (
          <button
            type="button"
            className={navButtonClass}
            aria-label="Scroll tabs forward"
            onClick={() => scrollTabs(1)}
          >
            <ChevronRight className="size-4" aria-hidden="true" />
          </button>
        ) : null}

        {firstLastNavControl ? (
          <button
            type="button"
            className={navButtonClass}
            aria-label="Last tab"
            onClick={() => {
              for (let i = tabs.length - 1; i >= 0; i -= 1) {
                if (!tabs[i]?.disabled) {
                  handleTabChange(i);
                  break;
                }
              }
            }}
          >
            <ChevronsRight className="size-4" aria-hidden="true" />
          </button>
        ) : null}
      </div>

      <div
        ref={panelOuterRef}
        className={cn(
          "overflow-hidden transition-[height] duration-300 ease-out",
          orientation === "horizontal" ? "mt-4 w-full" : "min-w-0 flex-1 pt-1",
        )}
        style={{ height: lockedHeight != null ? lockedHeight : undefined }}
        onTransitionEnd={(event) => {
          if (event.propertyName === "height") setLockedHeight(null);
        }}
      >
        <div
          ref={panelInnerRef}
          role="tabpanel"
          id={getPanelId(selectedTab)}
          aria-labelledby={getTabId(selectedTab)}
          className="focus-visible:ring-ring/50 rounded-md outline-none focus-visible:ring-2"
        >
          {tabs[selectedTab]?.content ?? null}
        </div>
      </div>
    </div>
  );
}

Related examples

Browse the full examples gallery