Tabs

A tab navigation component with smooth animations and keyboard support.

Dependencies

Source Code

"use client";
 
import { motion } from "motion/react";
import {
  Children,
  createContext,
  useState,
  useLayoutEffect,
  useMemo,
  useCallback,
  useId,
  use,
} from "react";
import { Slot } from "@/components/slot";
 
import { cn } from "@/lib/utils";
 
interface TabsContextValue {
  tabs: string[];
  selectedTab: string;
  setSelectedTab: (id: string) => void;
  next: () => void;
  previous: () => void;
  orientation: "horizontal" | "vertical";
  registerTab: (id: string) => () => void;
}
 
const TabsContext = createContext<TabsContextValue | null>(null);
 
const useTabsContext = () => {
  const context = use(TabsContext);
  if (!context)
    throw new Error("TabsContext must be used within a Tabs component");
  return context;
};
 
interface TabsProps
  extends Omit<React.ComponentPropsWithRef<"div">, "onChange"> {
  defaultIndex?: number;
  selectedIndex?: number;
  onChange?: (index: number) => void;
  orientation?: TabsContextValue["orientation"];
  children: React.ReactNode;
}
 
const Tabs = ({
  defaultIndex,
  selectedIndex: selectedIndexProp,
  onChange: onChangeProp,
  orientation = "horizontal",
  children,
  ...props
}: TabsProps) => {
  const [internalSelectedIndex, setInternalSelectedIndex] = useState(
    defaultIndex ?? 0
  );
  const [tabs, setTabs] = useState<string[]>([]);
 
  const selectedIndex = selectedIndexProp ?? internalSelectedIndex;
 
  const setSelectedIndex = useCallback(
    (index: number) => {
      setInternalSelectedIndex(index);
      onChangeProp?.(index);
 
      // focus on the selected tab when changing index
      const selectedTab = document.getElementById(getItemId(tabs[index]));
      selectedTab?.focus();
    },
    [onChangeProp, tabs]
  );
 
  const next = useCallback(() => {
    setSelectedIndex((selectedIndex + 1) % tabs.length);
  }, [tabs.length, selectedIndex, setSelectedIndex]);
 
  const previous = useCallback(() => {
    setSelectedIndex(selectedIndex <= 0 ? tabs.length - 1 : selectedIndex - 1);
  }, [tabs.length, selectedIndex, setSelectedIndex]);
 
  const registerTab = useCallback((id: string) => {
    setTabs((prev) => [...prev, id]);
 
    return () => {
      setTabs((prev) => prev.filter((prevId) => prevId !== id));
    };
  }, []);
 
  const selectedTab = useMemo(() => tabs[selectedIndex], [tabs, selectedIndex]);
 
  const setSelectedTab = useCallback(
    (id: string) => {
      setSelectedIndex(tabs.indexOf(id));
    },
    [tabs, setSelectedIndex]
  );
 
  const ctx = useMemo(
    () => ({
      tabs,
      selectedTab,
      setSelectedTab,
      next,
      previous,
      orientation,
      registerTab,
    }),
    [
      tabs,
      selectedTab,
      setSelectedTab,
      next,
      previous,
      orientation,
      registerTab,
    ]
  );
 
  return (
    <TabsContext value={ctx}>
      <div {...props}>{children}</div>
    </TabsContext>
  );
};
 
interface TabsItemsProps
  extends Omit<React.ComponentPropsWithRef<"div">, "role"> {
  children: React.ReactNode;
}
 
const TabsItems = ({ children, className, ...props }: TabsItemsProps) => {
  const { orientation } = useTabsContext();
 
  return (
    <div
      role="tablist"
      aria-orientation={orientation}
      className={cn(
        "flex gap-1.5",
        orientation === "horizontal"
          ? "items-center pb-4"
          : "flex-col items-start pr-4",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
};
 
interface TabsItemProps
  extends Omit<
    React.ComponentPropsWithRef<"button">,
    "id" | "type" | "disabled"
  > {
  children: React.ReactNode;
  asChild?: boolean;
}
 
const getItemId = (id: string) => `tab${id}`;
 
const TabsItem = ({
  children,
  asChild,
  onClick,
  onKeyDown,
  className,
  ...props
}: TabsItemProps) => {
  const id = useId();
  const {
    selectedTab,
    setSelectedTab,
    registerTab,
    orientation,
    next,
    previous,
  } = useTabsContext();
 
  const Comp = asChild ? Slot : "button";
 
  useLayoutEffect(() => {
    registerTab(id);
  }, [id, registerTab]);
 
  const isSelected = selectedTab === id;
 
  const handleKeyboardNavigation = (
    e: React.KeyboardEvent<HTMLButtonElement>
  ) => {
    if (e.key === "Enter" || e.key === " ") {
      setSelectedTab(id);
    }
 
    const keyOrientationMap = {
      horizontal: {
        next: "ArrowRight",
        prev: "ArrowLeft",
      },
      vertical: {
        next: "ArrowDown",
        prev: "ArrowUp",
      },
    };
 
    if (keyOrientationMap[orientation].next === e.key) {
      e.preventDefault();
      next();
    }
 
    if (keyOrientationMap[orientation].prev === e.key) {
      e.preventDefault();
      previous();
    }
  };
 
  return (
    <Comp
      id={getItemId(id)}
      type="button"
      className={cn(
        "ring-ring text-foreground/50 hover:text-foreground data-selected:text-foreground relative flex cursor-pointer items-center justify-center gap-1.5 rounded-xl px-4 py-2 transition outline-none focus-visible:ring-4",
        "[&>*:not([data-tab-indicator])]:z-10",
        className
      )}
      role="tab"
      aria-controls={getPanelId(id)}
      aria-selected={isSelected || undefined}
      data-selected={isSelected || undefined}
      tabIndex={isSelected ? 0 : -1}
      onClick={(e) => {
        onClick?.(e);
 
        if (!e.defaultPrevented) {
          setSelectedTab(id);
        }
      }}
      onKeyDown={(e) => {
        onKeyDown?.(e);
 
        if (!e.defaultPrevented) {
          handleKeyboardNavigation(e);
        }
      }}
      {...props}
    >
      {typeof children === "string" ? <span>{children}</span> : children}
      {isSelected && (
        <motion.span
          data-tab-indicator="true"
          layoutId="tab-indicator"
          aria-hidden="true"
          className="bg-background-secondary absolute inset-0 z-0 rounded-xl"
          transition={{ type: "spring", duration: 0.3, bounce: 0.2 }}
        />
      )}
    </Comp>
  );
};
 
interface TabsPanelsProps
  extends Omit<React.ComponentPropsWithRef<"div">, "role"> {
  children: React.ReactNode;
}
 
const TabsPanels = ({ children, className, ...props }: TabsPanelsProps) => {
  const { tabs } = useTabsContext();
 
  return (
    <div
      className={cn("ring-ring transition has-focus-visible:ring-4", className)}
      {...props}
    >
      {Children.map(children, (child, index) => (
        <PanelIdContext value={tabs[index]}>{child}</PanelIdContext>
      ))}
    </div>
  );
};
 
interface TabsPanelProps
  extends Omit<React.ComponentPropsWithRef<"div">, "id"> {
  children: React.ReactNode;
  asChild?: boolean;
}
 
const PanelIdContext = createContext<string>("");
 
const getPanelId = (id: string) => `tab-panel${id}`;
 
const TabsPanel = ({
  children,
  asChild,
  className,
  ...props
}: TabsPanelProps) => {
  const id = use(PanelIdContext);
  const { selectedTab } = useTabsContext();
  const Comp = asChild ? Slot : "div";
 
  const isSelected = selectedTab === id;
 
  return (
    <Comp
      id={getPanelId(id)}
      role="tabpanel"
      className={cn("outline-none", className)}
      aria-labelledby={getItemId(id)}
      aria-hidden={!isSelected}
      data-selected={isSelected || undefined}
      tabIndex={isSelected ? 0 : -1}
      {...props}
    >
      {isSelected ? children : null}
    </Comp>
  );
};
 
export { Tabs, TabsItems, TabsItem, TabsPanels, TabsPanel };

Features

  • Smooth Animations: Animated transitions between tabs using Framer Motion
  • Keyboard Navigation: Full keyboard support for tab navigation
  • Orientation Support: Both horizontal and vertical layouts
  • Controlled & Uncontrolled: Flexible state management options
  • ARIA Support: Full accessibility implementation
  • Custom Styling: Extensible styling through className props

Anatomy

<Tabs>
  <TabsItems>
    <TabsItem />
  </TabsItems>
  <TabsPanels>
    <TabsPanel />
  </TabsPanels>
</Tabs>

API Reference

Tabs

The root container component that manages the state and behavior of the tabs.

PropDefaultTypeDescription

defaultIndex

-

number

The index of the tab that should be active by default.

selectedIndex

-

number

The controlled index of the active tab.

onChange

-

(index: number) => void

Callback fired when the active tab changes.

orientation

horizontal

"horizontal" | "vertical"

The orientation of the tabs.

TabsItems

The container for tab buttons. Provides proper ARIA attributes and keyboard navigation.

PropDefaultTypeDescription

asChild

-

boolean

Whether to merge props onto the child element.

TabsItem

The individual tab button component.

PropDefaultTypeDescription

asChild

-

boolean

Whether to merge props onto the child element.

disabled

false

boolean

Whether the tab is disabled.

TabsPanels

The container for tab panels. Manages the visibility of panels based on the selected tab.

PropDefaultTypeDescription

asChild

-

boolean

Whether to merge props onto the child element.

TabsPanel

The individual tab panel component.

PropDefaultTypeDescription

asChild

-

boolean

Whether to merge props onto the child element.

Accessibility

The Tabs component follows the WAI-ARIA Tabs Pattern. It implements all the required ARIA attributes and keyboard interactions.

Examples

Basic Usage

A basic example of tabs with icons and labels.

Vertical Orientation

Tabs can be oriented vertically using the orientation prop.

Controlled Tabs

Example of controlled tabs with external state management.

Best Practices

  1. Content Organization:

    • Use meaningful tab labels that describe their content
    • Keep tab labels concise
    • Order tabs in a logical sequence
  2. Performance:

    • Lazy load tab content when possible
    • Consider using dynamic imports for heavy content
    • Use motion animations sparingly on low-end devices
  3. Accessibility:

    • Ensure tab labels are descriptive
    • Maintain a logical tab order
    • Consider users who navigate with keyboard only
  4. Mobile Considerations:

    • Ensure touch targets are large enough
    • Consider scrollable tabs for many items
    • Test with different screen sizes