Octocat

Tabs

A tab navigation component with smooth animations and keyboard support.

Panel 1
Panel 2
Panel 3
import {
  CreditCardIcon,
  GearIcon,
  UsersIcon,
} from '@phosphor-icons/react/dist/ssr';

import {
  Tabs,
  TabsItem,
  TabsItems,
  TabsPanel,
  TabsPanels,
} from '@/components/tabs';

export default function TabsPreview() {
  return (
    <Tabs>
      <TabsItems>
        <TabsItem>
          <UsersIcon />
          <span>Users</span>
        </TabsItem>
        <TabsItem>
          <CreditCardIcon />
          <span>Billing</span>
        </TabsItem>
        <TabsItem>
          <GearIcon />
          <span>Settings</span>
        </TabsItem>
      </TabsItems>
      <TabsPanels>
        <TabsPanel>Panel 1</TabsPanel>
        <TabsPanel>Panel 2</TabsPanel>
        <TabsPanel>Panel 3</TabsPanel>
      </TabsPanels>
    </Tabs>
  );
}

Dependencies

Source Code

'use client';

import { motion } from 'motion/react';
import {
  Children,
  createContext,
  use,
  useCallback,
  useId,
  useLayoutEffect,
  useMemo,
  useState,
} from 'react';

import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';

interface TabsContextValue {
  id: string;
  tabs: string[];
  selectedTab: string | undefined;
  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 id = useId();
  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 itemId = getItemId(tabs[index]);
      if (itemId) {
        const selectedTab = document.getElementById(itemId);
        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(
    () => ({
      id,
      tabs,
      selectedTab,
      setSelectedTab,
      next,
      previous,
      orientation,
      registerTab,
    }),
    [
      id,
      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 | undefined) => (id ? `tab${id}` : undefined);

const TabsItem = ({
  children,
  asChild,
  onClick,
  onKeyDown,
  className,
  ...props
}: TabsItemProps) => {
  const id = useId();
  const {
    id: tabsId,
    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(
        'relative flex cursor-pointer items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-foreground/50 outline-none ring-ring transition hover:text-foreground focus-visible:ring-4 data-selected:text-foreground',
        '[&>*: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={tabsId}
          aria-hidden="true"
          className="absolute inset-0 z-0 rounded-xl bg-background-secondary"
          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 | undefined>(undefined);

const getPanelId = (id: string | undefined) =>
  id ? `tab-panel${id}` : undefined;

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, TabsItem, TabsItems, TabsPanel, TabsPanels };

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.

Prop Default Type Description
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.

Prop Default Type Description
asChild - boolean Whether to merge props onto the child element.

TabsItem

The individual tab button component.

Prop Default Type Description
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.

Prop Default Type Description
asChild - boolean Whether to merge props onto the child element.

TabsPanel

The individual tab panel component.

Prop Default Type Description
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.

Panel 1
Panel 2
Panel 3
import {
  CreditCardIcon,
  GearIcon,
  UsersIcon,
} from '@phosphor-icons/react/dist/ssr';

import {
  Tabs,
  TabsItem,
  TabsItems,
  TabsPanel,
  TabsPanels,
} from '@/components/tabs';

export default function TabsPreview() {
  return (
    <Tabs>
      <TabsItems>
        <TabsItem>
          <UsersIcon />
          <span>Users</span>
        </TabsItem>
        <TabsItem>
          <CreditCardIcon />
          <span>Billing</span>
        </TabsItem>
        <TabsItem>
          <GearIcon />
          <span>Settings</span>
        </TabsItem>
      </TabsItems>
      <TabsPanels>
        <TabsPanel>Panel 1</TabsPanel>
        <TabsPanel>Panel 2</TabsPanel>
        <TabsPanel>Panel 3</TabsPanel>
      </TabsPanels>
    </Tabs>
  );
}

Vertical Orientation

Tabs can be oriented vertically using the orientation prop.

Users Panel

Manage your users here.

Billing Panel

Manage your billing information.

Settings Panel

Configure your application settings.

import {
  CreditCardIcon,
  GearIcon,
  UsersIcon,
} from '@phosphor-icons/react/dist/ssr';

import {
  Tabs,
  TabsItem,
  TabsItems,
  TabsPanel,
  TabsPanels,
} from '@/components/tabs';

export default function TabsVerticalPreview() {
  return (
    <Tabs orientation="vertical" className="flex gap-4">
      <TabsItems className="w-60">
        <TabsItem className="w-full justify-start">
          <UsersIcon />
          <span>Users</span>
        </TabsItem>
        <TabsItem className="w-full justify-start">
          <CreditCardIcon />
          <span>Billing</span>
        </TabsItem>
        <TabsItem className="w-full justify-start">
          <GearIcon />
          <span>Settings</span>
        </TabsItem>
      </TabsItems>
      <TabsPanels className="w-90">
        <TabsPanel>
          <h3 className="font-medium text-lg">Users Panel</h3>
          <p className="text-foreground-secondary">Manage your users here.</p>
        </TabsPanel>
        <TabsPanel>
          <h3 className="font-medium text-lg">Billing Panel</h3>
          <p className="text-foreground-secondary">
            Manage your billing information.
          </p>
        </TabsPanel>
        <TabsPanel>
          <h3 className="font-medium text-lg">Settings Panel</h3>
          <p className="text-foreground-secondary">
            Configure your application settings.
          </p>
        </TabsPanel>
      </TabsPanels>
    </Tabs>
  );
}

Controlled Tabs

Example of controlled tabs with external state management.

Current tab:0
Panel 1
Panel 2
Panel 3
'use client';

import { useState } from 'react';

import {
  Tabs,
  TabsItem,
  TabsItems,
  TabsPanel,
  TabsPanels,
} from '@/components/tabs';

export default function TabsControlledPreview() {
  const [selectedIndex, setSelectedIndex] = useState(0);

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2">
        <span className="text-foreground-secondary text-sm">Current tab:</span>
        <span className="font-medium">{selectedIndex}</span>
        <button
          type="button"
          onClick={() => setSelectedIndex((prev) => (prev + 1) % 3)}
          className="rounded-lg border px-3 py-1 text-sm"
        >
          Next tab
        </button>
      </div>

      <Tabs selectedIndex={selectedIndex} onChange={setSelectedIndex}>
        <TabsItems>
          <TabsItem>Tab 1</TabsItem>
          <TabsItem>Tab 2</TabsItem>
          <TabsItem>Tab 3</TabsItem>
        </TabsItems>
        <TabsPanels>
          <TabsPanel>Panel 1</TabsPanel>
          <TabsPanel>Panel 2</TabsPanel>
          <TabsPanel>Panel 3</TabsPanel>
        </TabsPanels>
      </Tabs>
    </div>
  );
}

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

Previous

Switch

Next

Textarea