Skip to content

The many ways to build a tabs component in React

Building a tabs component in React can be done in various ways. In this article, we will explore how different component libraries implement tabs and discuss important considerations to keep in mind.

Published
Jul 23, 2024
Updated
Jul 23, 2024
Reading time
23 min read

We'll first take a look at the tabs pattern and its anatomy, then explore how different component libraries implement tabs in React. We'll cover Material UI, PrimeReact, Radix UI, and React Aria Components. We'll see how they handle unique identifiers, keyboard navigation, and other accessibility requirements.

Code snippets

The code snippets provided in this post are extracted from the source code of the mentioned libraries and are intended to help you grasp the concept. They have been condensed and simplified. The actual implementations often address additional scenarios.

What are tabs?

I'm sure you are familiar with tabs, but just for the record here's how the ARIA Authoring Practice Guide defines tabs1:

A set of layered sections of content, known as tab panels, that display one panel of content at a time.

Anatomy

The tabs component consists of a tab list containing multiple tabs. Each tab is associated with a tab panel that displays the content of the tab. Only one tab can be active at a time.

Anatomy of Tabs

Let's see how this looks like in HTML.

DOM Structure

Implementing this anatomy will look like something below. The tab list is a div element with an explicit tablist role. It contains a list of tabs, implemented as buttons with the tab role. The tab content is rendered in a tabpanel element.

<div>
  <div role="tablist" aria-label="My Tabs">
    <button
      role="tab"
      id="tab-1"
      aria-selected="true"
      aria-controls="tabpanel-1"
      tabindex="0"
    >
      Tab 1
    </button>
    <button
      role="tab"
      id="tab-2"
      aria-selected="false"
      aria-controls="tabpanel-2"
      tabindex="-1"
    >
      Tab 2
    </button>
  </div>

  <div id="tabpanel-1" role="tabpanel" aria-labelledby="tab-1" tabindex="0">
    Content of Tab 1
  </div>
  <div id="tabpanel-2" role="tabpanel" aria-labelledby="tab-2" tabindex="0">
    Content of Tab 2
  </div>
</div>

For accessibility purposes, we need to include additional attributes. The active state of a tab is indicated by the aria-selected attribute. To establish the connection between the tab and its corresponding tab panel, we use the aria-controls attribute. The tab panels are labeled using the aria-labelledby attribute.

The tabs and tab panels require unique identifiers that are referenced by aria-controls and aria-labelledby. It is crucial to use identifiers that are unique throughout the entire document. While this may seem simple, it can actually be quite complex - as we will soon discover.

Next we'll take a look at keyboard navigation.

Keyboard Navigation

The active tab should be keyboard focusable (tabIndex="0"), while all other tabs should not receive focus when tabbing through the UI elements (tabindex="-1"). When the active tab is focused, pressing arrow left / arrow right should navigate to the previous or next tab.

To illustrate the concept, you can simply add an onKeyDown event handler, check the key of the keyboard event, and then focus the previous or next tab. However, determining the previous / next tab element depends on the implementation of the UI library, which we will explore later.

<button
  role="tab"
  onKeyDown={(e) => {
    if (e.key === "ArrowRight") {
      // Focus next tab ...
    } else if (e.key === "ArrowLeft") {
      // Focus previous tab ...
    }
  }}
/>

The tab panel should be focusable if it does not contain interactive elements or if the first element with content is not focusable. To achieve this, we set tabindex="0". There are optional keyboard interactions, such as pressing Home to focus the first tab, and pressing Delete to remove a tab if it is deletable.

More things to consider

There are additional considerations to keep in mind, such as different tab layouts (horizontal vs vertical), handling overflow, deletable tabs and controlled / uncontrolled mode. However, for the purpose of this article, we will focus solely on the basic implementation.


Now let's explore how different component libraries implement the tabs component. These implementations vary greatly, ranging from basic to more advanced approaches.

Material UI

Component API

The current implementation of the MUI Tabs component is minimalistic. It consists of a Tabs component for the tab list and a Tab component for individual tabs. However, it does not include a tab panel component. Therefore, it is your responsibility to render the tab panel and establish the necessary connections between the tab and the tab panel.

<Tabs aria-label="My Tabs">
  <Tab label="Tab 1" id="tab-1" aria-controls="tabpanel-1">
  <Tab label="Tab 2" id="tab-2" aria-controls="tabpanel-2">
</Tabs>

<div role="tabpanel" id="tabpanel-1" aria-labelledby="tab-1">
  Content of Tab 1
</div>
<div role="tabpanel" id="tabpanel-2" aria-labelledby="tab-2">
  Content of Tab 2
</div>

The component provides keyboard navigation and sets the aria-selected attribute on the tabs. However, the implementation of other features is left to the user.

Unique Identifiers

As mentioned earlier, it is important to provide unique identifiers for each tab and tab panel. These identifiers are used by the aria-controls and aria-labelledby attributes. In the case of Material UI, it is the responsibility of the developer to define these identifiers, so there is not much to cover in this regard.

Keyboard Navigation

For the keyboard navigation, particularly switching between tabs using the arrow left and arrow right keys, the component requires access to the previous and next tab. Let's explore how Material UI handles this.

MUI's keyboard event handler2 is registered on the tab list, not the individual tabs. It uses document.activeElement to retrieve the currently focused tab and then uses nextElementSibling to access the next tab (and previousElementSibling to access the previous tab). If the end of the tab list is reached, it wraps back to the first tab using tabList.firstChild. Inactive tabs are excluded by checking the disabled property (and also aria-disabled).

Here's a stripped down implementation:

const onKeyDown = (e) => {
  const tabList = tabListRef.current;
  const currentFocus = list.ownerDocument.activeElement;

  switch (e.key) {
    case "ArrowRight":
      e.preventDefault();
      moveFocus(tabList, currentFocus);
      break;
  }
};

const moveFocus = (tabList, currentFocus) => {
  let nextFocus = nextItem(tabList, currentFocus);

  while (nextFocus) {
    if (nextFocus.disabled) {
      nextFocus = nextItem(tabList, nextFocus);
    } else {
      nextFocus.focus();
      return;
    }
  }
};

const nextItem = (tabList, item) => {
  return item.nextElementSibling ?? tabList.firstChild;
};

The WAI-ARIA specification allows for wrapping a tab in a wrapper element for styling purposes3. However, in MUI's implementation, the tabs must be direct children of the tab list to ensure proper keyboard navigation. Wrapping the tabs in a wrapper element would break the keyboard navigation functionality.

PrimeReact

Component API

PrimeReact's TabView component simplifies the structure by abstracting the tab and tab panel into a single TabPanel component, which is then wrapped in a TabView.

<TabView>
  <TabPanel header="Tab 1">Content of Tab 1</TabPanel>
  <TabPanel header="Tab 2">Content of Tab 2</TabPanel>
</TabView>

It's easier to use than MUI, but the naming may be a bit confusing when transitioning from the plain HTML structure.

Rendering

In the JSX structure above, the tabs and tab panels are merged into a single node. However, when rendering the HTML, it is necessary to separate these two elements. Otherwise, we would have an unusual structure where the tab panels are rendered within the tab list:

const TabPanel = (props) => {
  // This would NOT be a valid structure.
  // The TabPanel should be rendered outside the tab list.
  return (
    <>
      <button role="tab">{props.header}</button>
      <div role="tabpanel">{props.children}</div>
    </>
  );
};

So, in PrimeReact, the tabs and tab panels need to be separated before rendering the content. If you examine the implementation of the TabPanel component4, you will notice that there is no specific code present.

export const TabPanel = () => {};

The component is responsible for defining the available tabs and accepting props, it doesn't render anything. When it comes to rendering, PrimeReact utilizes React.Children.map() to iterate over the provided children5, extract the props, and render separate components for the tab and tab panel:

const TabView = (props) => {
  const tabHeaders = React.Children.map(props.children, (tab) => {
     const tabProps = { /* omitted for brevity */ };
     return <a {...tabProps}>{tab.props.title}</a>;
  });

  const tabPanels = React.Children.map(props.children, (tab) => {
    const panelProps = { /* omitted for brevity */ };
    return <div {...panelProps}>{props.children}</div>
  };

  return (
    <div>
      <div class="tablist">{tabHeaders}</div>
      <div>{tabPanels}</div>
    </div>
  );
};

Unique Identifiers

Each tab is assigned a unique identifier, such as pr_id_1_header_0. This identifier consists of a generated prefix (pr_id_1), a static part (header), and the tab's index (0). The generated prefix guarantees that the identifier remains unique, even when multiple tabs are present on the page.

For the generated part, instead of relying on the useId() hook, a counter is incremented6. This approach is likely used to support React 17. However, using a counter can cause issues with server-side rendering7. To address this, the unique ID is created in a useEffect8. In server-side rendered applications, the prefix is set to null, resulting in an ID like null_header_09.

Here is a simplified implementation:

let lastId = 0;

function UniqueComponentId(prefix = "pr_id_") {
  lastId++;
  return `${prefix}${lastId}`;
}

const TabView = (props) => {
  const [idState, setIdState] = useState(props.id);

  useMountEffect(() => {
    if (!idState) {
      setIdState(UniqueComponentId());
    }
  });

  // idState is used as prefix for the tab ids
  // e.g. idState + "_header_" + index
};

If a custom ID is provided, it is used to initialize the idState variable. However, the state is not updated if the ID changes. This could lead to duplicate IDs in very specific scenarios, but it is unlikely to occur in practice.

Keyboard Navigation

PrimeReact follows a similar approach to MUI for keyboard navigation but the event handler is registered on the individual tabs, not the tab list. It also utilizes nextElementSibling to retrieve the next tab element10. However, instead of using a ref, PrimeReact accesses the tab list through event.target.parentElement. This also prevents you from wrapping the tabs in a wrapper element.

const onKeyDown = (e) => {
  switch (e.key) {
    case "ArrowRight":
      const tabList = event.target.parentElement;
      const nextHeaderAction = findNextHeaderAction(tabList);

      changeFocusedTab(nextHeaderAction);
      event.preventDefault();
      break;
  }
};

const findNextHeaderAction = (tabElement) => {
  const headerElement = tabElement.nextElementSibling;
  return headerElement
    ? headerElement.getAttribute("data-p-disabled")
      ? findNextHeaderAction(headerElement)
      : headerElement
    : null;
};

Disabled tabs are skipped by checking the data-p-disabled attribute. However, it seems that this attribute is not added to the element, which makes disabled tabs still focusable. I'm not sure if this behavior is intentional or not, but it aligns with the AGP Practices11.

Radix UI

Component API

Tabs in Radix UI are composed of four components, allowing for greater customization. Each tab requires a unique value within the tab root, which connects the Trigger component to its corresponding Content component.

<Tabs.Root defaultValue="tab-1">
  <Tabs.List aria-label="My Tabs">
    <Tabs.Trigger value="tab-1">Tab 1</Tabs.Trigger>
    <Tabs.Trigger value="tab-2">Tab 2</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="tab-1">Content of Tab 1</Tabs.Content>
  <Tabs.Content value="tab-2">Content of Tab 2</Tabs.Content>
</Tabs.Root>

Unique Identifiers

Radix generates a unique ID for each tab, such as radix-:r1:-trigger-tab-1. The ID is composed of three parts:

  1. A static prefix, radix-.
  2. A uniquely generated identifier, :r1:, which is generated using useId() or a counter variable for older versions of React12.
  3. The value of the tab, such as tab-1.

Here's a simplified version:

// Use React's useId() and a noop for React 17
const useReactId = React.useId || (() => undefined);

// Counter as fallback for React 17
let count = 0;

export function useId(): string {
  const [id, setId] = useState(useReactId());

  useLayoutEffect(() => {
    // Generate a unique ID if none is provided
    setId((reactId) => reactId ?? count++);
  }, []);

  return id ? `radix-${id}` : "";
}

ID with whitespace

Ensure that you avoid including any whitespace characters in your tab values. Radix utilizes them as suffixes for the HTML IDs, resulting in invalid IDs13 that can compromise accessibility.

Collection

The Radix Tabs primitive leverages the @radix-ui/react-collection package for efficient collection management. This package is responsible for creating and managing a collection of tabs, which is then used for enabling keyboard navigation within the tabs component.

The collection API14 is made up of three components: the provider, the slot, and the slot item. The provider initializes the state and provides it through React Context. The slot serves as the root of the collection, and the slot items are the tab triggers in our case.

<Collection.Provider>
  <Collection.Slot>
    <Collection.SlotItem />
    <Collection.SlotItem />
    <Collection.SlotItem />
  </Collection.Slot>
</Collection.Provider>

The SlotItem component utilizes the useEffect hook to register itself in the state initialized by the provider. This state is a map that contains all items of the current collection. Additionally, the SlotItem component adds a data attribute to itself, which is later used to query the DOM.

const SlotItem = (props) => {
  const { children, ...itemData } = props;
  const context = useCollectionContext();
  const ref = useRef(null);

  useEffect(() => {
    // Register the item in the map of items (+ cleanup on unmount)
    context.itemMap.set(ref, { ref, ...itemData });
    return () => context.itemMap.delete(ref);
  });

  return (
    <Slot ref={ref} data-radix-collection-item>
      {children}
    </Slot>
  );
};

The context.itemMap provides access to all collection items, but they may not be in the correct order. For instance, if a new component is mounted in the middle of the list, it will be appended to the end in the map. To obtain the ordered list of collection items, the useCollection() hook queries the DOM:

function useCollection() {
  const context = useCollectionContext();

  const getItems = () => {
    // Get the root ref (from `Collection.Slot`)
    const ref = context.collectionRef.current;

    // Query all slot items using the data attribute
    const orderedNodes = Array.from(
      ref.querySelectorAll(`[data-radix-collection-item]`)
    );

    // Get all unordered slot items from the registry
    const items = Array.from(context.itemMap.values());

    // Order the items by order of DOM elements
    const orderedItems = items.sort(
      (a, b) =>
        orderedNodes.indexOf(a.ref.current) -
        orderedNodes.indexOf(b.ref.current)
    );

    return orderedItems;
  };

  return getItems;
}

Keyboard Navigation

The roving focus utility15 uses the collection APIs to access all tab triggers and their refs. It determines the next tab to focus based on the currently focused tab (event.currentTarget) from the list of tab triggers.

const RovingFocusGroupItem = (props) => {
  const getItems = useCollection();

  return (
    <Collection.ItemSlot focusable={props.focusable} active={props.active}>
      <span
        tabIndex={isCurrentTabStop ? 0 : -1}
        onKeyDown={(event) => {
          if (event.key === "ArrowRight") {
            // Get all focusable collection items
            const items = getItems().filter((item) => item.focusable);

            // Get the index of the currently focused tab
            const currentIndex = candidateNodes.indexOf(event.currentTarget);

            // Get all next tabs
            const candidateNodes = items
              .map((item) => item.ref.current)
              .slice(currentIndex + 1);

            // Focus the first available tab
            focusFirst(candidateNodes);
          }
        }}
      />
    </Collection.ItemSlot>
  );
};

React Aria Components

Component API

The React Aria Components library provides a Tabs component with a similar API to Radix. Like Radix, it is a compound component and requires you to assign a unique ID to each tab.

<Tabs>
  <TabList>
    <Tab id="tab-1">Tab 1</Tab>
    <Tab id="tab-2">Tab 2</Tab>
  </TabList>
  <TabPanel id="tab-1">Content of Tab 1</TabPanel>
  <TabPanel id="tab-2">Content of Tab 2</TabPanel>
</Tabs>

Unique Identifiers

Behind the scenes, React Aria generates unique IDs for each tab. An example ID is react-aria8402689404-:r4:-tab-tab-1, which consists of the following parts:

  1. A static prefix of react-aria.
  2. A randomly generated number, such as 8402689404, to ensure uniqueness when multiple React applications with React Aria are rendered on the same page (e.g., in a micro frontend)16. This number is omitted for server-side rendered apps to avoid hydration errors.
  3. A unique ID generated by React's useId() hook, indicated by :r4:. For older versions of React, a fallback using a counter variable is provided. Unlike other libraries, React Aria does not rely on useEffect or useLayoutEffect, but instead accesses React's internal __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED variable to access the Fiber instance17.
  4. The role of the element, which in this case is tab.
  5. The provided ID of the tab, such as tab-1. React Aria ensures that the ID is a valid HTML ID by removing any whitespace characters18.

Collection

Before diving into keyboard navigation, it's important to understand how React Aria Components manages collections. React Aria Components utilizes the @react-aria/collections package to create a collection of the currently rendered tabs. This collection is then used for state management and keyboard navigation.

Limitations of existing solutions

The JSX approach, used by PrimeReact, allows you to access the collection before rendering anything to the DOM, but it only works with direct children and does not support wrapping them in custom components.

Building the collection from the rendered DOM elements, as implemented by Radix UI, is more flexible, but it is only available after the first render cycle. This can limit what you can do on the initial render and may trigger additional render cycles. In addition, it doesn't work with server-side rendering.

The Tabs component in React Aria addresses the limitations of other solutions by rendering the content twice. First, it renders the CollectionBuilder component, which generates the tabs and constructs the collection based on the rendered nodes. Then, it utilizes this collection to create the tab list state and render the user interface through the TabsInner component.

const Tabs = (props) => {
  return (
    <CollectionBuilder content={children}>
      {(coll) => <TabsInner props={props} collection={coll} />}
    </CollectionBuilder>
  );
};

const TabsInner = ({ props, collection }) => {
  const state = useTabListState({ ...props, collection });

  return (
    <div {...filterDOMProps(props)}>
      <TabListStateContext.Provider value={state}>
        {props.children}
      </TabListStateContext.Provider>
    </div>
  );
};

Let's examine the CollectionBuilder in more detail. Although I mentioned that the content is rendered twice, this is not entirely accurate. While both the CollectionBuilder and TabsInner render the content, the collection builder does not render anything in the actual DOM. Instead, it utilizes a virtual DOM.

React Aria provides a fake DOM implementation19 with a minimal API that supports the necessary features used by React DOM, such as creating a node or adding it to the document.

In a simplified manner, React Aria utilizes a custom Document class, which represents a fake DOM. It uses the createPortal() function to render the content into this fake DOM. The Document class provides the necessary APIs and maintains a mutable collection of rendered elements.

const CollectionRoot = ({ content }) => {
  // Create a fake DOM instance
  const [doc] = useState(() => new Document());

  // Render the content into the fake DOM
  return createPortal(content, doc);
};

class Document {
  createElement(type) {
    return new ElementNode(type, this);
  }

  addNode(element) {
    const collection = this.getMutableCollection();
    collection.addNode(element.node);
  }

  removeNode(node) {
    const collection = this.getMutableCollection();
    collection.removeNode(node.node.key);
  }

  // ...
}

This collection allows React Aria to access the list of tabs after the content has been rendered into the virtual DOM but before rendering anything into the real DOM (via TabsInner).

Rendering the content into a fake DOM rather than the actual DOM is much faster, so rendering twice doesn't take twice as long. However, it still takes time, and for building the collection, we are only interested in the tabs, not the tab panel content. Rendering the tab panel content can be expensive, even in a virtual DOM. To address this, the collection builder wraps the content into a HiddenContext provider. If a TabPanel is inside a hidden context, it returns null, effectively skipping the rendering of the tab panel content20.

const TabPanel = () => {
  const isHidden = useContext(HiddenContext);
  if (isHidden) return null;

  // render content...
};

Now that we have a collection of tabs, we can implement keyboard navigation.

Keyboard Navigation

React Aria Components relies on several packages, including @react-stately/tabs, @react-stately/list, @react-stately/selection, and @react-aria/selection, to manage the tab state. When it comes to keyboard navigation, the useSelectableCollection hook from the @react-aria/selection package is particularly important. The hook implements an onKeyDown handler and updates the currently selected key when pressing arrow right21.

export function useSelectableCollection() {
  const onKeyDown = (e) => {
    if (e.key === "ArrowRight") {
      const focusedKey = selectionManager.focusedKey;
      const nextKey = keyboardDelegate.getKeyRightOf(focusedKey);
      selectionManager.replaceSelection(nextKey);
    }
  };

  const onFocused = () => selectionManager.setFocused(true);
  const onBlur = () => selectionManager.setFocused(false);

  return {
    collectionProps: {
      onKeyDown,
      onFocused,
      onBlur,
      // ...
    },
  };
}

keyboardDelegate is an instance of the TabsKeyboardDelegate class22. This class handles tab-specific logic for keyboard navigation, such as support for right-to-left and disabled tabs. State updates are managed by the SelectionManager class23.

As you can see, there is no focus() call. Unlike other libraries, the keyboard handler in React Aria Components doesn't directly focus the HTML element. Instead, it updates the state and calls setFocus() to indicate whether the component is currently focused. This information, along with the currently selected tabs, is then used in a useEffect call of the tab element to focus the HTML element24.

// In the Tab component
useEffect(() => {
  const isFocused = key === selectionManager.focusedKey;
  if (isFocused && selectionManager.isFocused) {
    focusSafely(ref.current);
  }
}, [selectionManager.focusedKey, selectionManager.isFocused]);

The focusSafely utility25 is used to focus the HTML element. It calls element.focus({ preventScrolling: true }) to focus the element, with a polyfill for older browsers that don't support preventScrolling. Additionally, it includes special handling for screen reader users due to an issue in VoiceOver on iOS.

Additional Topics

Handling RTL

A short note about keyboard navigation in right-to-left languages like Arabic. Pressing the arrow right key will always focus the tab to the right of the currently selected tab, even in RTL languages. However, in LTR languages, the tab to the right is the next tab, while in RTL languages, it is the previous tab.

Keyboard Navigation in LTR vs. RTL

PrimeReact does not support RTL languages26. The other three libraries, Material UI, Radix UI, and React Aria, rely on a global direction or internationalization provider that uses React Context to provide the current direction to components272829. The main difference is that Material UI and Radix UI require you to provide the direction explicitly, while React Aria accepts a locale and infers the direction from it30 using Intl.Locale (and a list of hardcoded RTL languages for older browsers). Intl.Locale31 provides information about a locale, such as the direction:

const locale = new Intl.Locale("ar");
console.log(locale.getTextInfo());
// Prints: { direction: "rtl" }

Detecting Focusable Elements

Following the ARIA Authoring Practices Guide (APG), there are cases where the tab panel should be made focusable32.

When the tabpanel does not contain any focusable elements or the first element with content is not focusable, the tabpanel should set tabindex="0" to include it in the tab sequence of the page.

Material UI does not provide a TabPanel component, so it does not implement the focusability of tab panels. PrimeReact renders tab panels without setting the tabindex attribute (they are never focusable). Radix UI, on the other hand, always sets tabindex="0" for tab panels, making them all focusable33. React ARIA is the only library in the list that implements the focusability of tab panels conditionally34. It checks if the panel contains any tabbable elements and sets the tabindex either to -1 if necessary.

This check is implemented in the useHasTabbableChild() hook, which uses a layout effect to check if there are any tabbable elements inside the tab panel and also monitors changes using MutationObserver.

export function useHasTabbableChild(ref) {
  const [hasTabbableChild, setHasTabbableChild] = useState(false);

  useLayoutEffect(() => {
    const update = () => {
      const walker = getFocusableTreeWalker(ref.current);
      setHasTabbableChild(!!walker.nextNode());
    };

    update(); // Initial check

    // Observe changes to the tab panel
    const observer = new MutationObserver(update);
    observer.observe(ref.current, { /* ... */ });

    return () => observer.disconnect();
  });

  return hasTabbableChild;
}

The getFocusableTreeWalker() utility utilizes a composed query selector to identify potentially tabbable elements such as textareas, links with href, and elements with contenteditable attribute. It excludes hidden and non-tabbable elements using the :not([hidden]):not([tabindex="-1"]) selector.

When dealing with more complex cases, using document.querySelector() may not be sufficient, especially when we need to exclude certain elements based on specific conditions. In such scenarios, document.querySelectorAll() can be used, but it is less efficient as it retrieves all possible elements instead of just determining if there is any tabbable element.

React Aria utilizes a more efficient approach by using a TreeWalker:

const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
  acceptNode(node) {
    if (node.matches(selector)) {
      return NodeFilter.FILTER_ACCEPT;
    }

    return NodeFilter.FILTER_SKIP;
  },
});

TreeWalker is provided by the DOM API and allows you to traverse and navigate through the nodes of a DOM tree using methods like nextNode(), previousNode() and parentNode(). You can specify the starting node and the filtering criteria for the nodes you want to traverse. The acceptNode() method is used to determine whether a node should be accepted, skipped, or rejected35.

Summary

That was quite a journey, but we made it! 😅 We explored various ways to implement a tabs component in React, ranging from simple (MUI) to more advanced implementations (React Aria). There are numerous scenarios to consider when implementing tabs or any other UI pattern, and we only scratched the surface. It's often more efficient to use an existing library rather than reinventing the wheel with a custom implementation.

My intention was to explore various implementations, rather than comparing which library is superior. However, I must say that React Aria's implementation has impressed me greatly. It handles a wider range of scenarios and offers superior accessibility compared to the other libraries mentioned. I highly recommend giving it a try for your next component library needs.

I hope you enjoyed this article. Send me your feedback on Mastodon.


  1. APG Tab Guide: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ ↩︎

  2. Material UI's keyboard handler https://github.com/mui/material-ui/blob/288863bd2f8681a82c4bfbaf13215b41043bc551/packages/mui-material/src/Tabs/Tabs.js#L775 ↩︎

  3. Elements with the tab role must be either contained in a tablist or owned by one (via aria-owns): https://www.w3.org/TR/wai-aria-1.2/#tab ↩︎

  4. Prime React's TabPanel component doesn't render anything: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/tabview/TabView.js#L12 ↩︎

  5. React Prime uses React.Children.map for rendering the tabs and tab panels: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/tabview/TabView.js#L419-L423 ↩︎

  6. PrimeReact's counter for unique ids: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/utils/UniqueComponentId.js ↩︎

  7. See the deep dive Why is useId better than an incrementing counter? in React docs: https://react.dev/reference/react/useId#why-is-useid-better-than-an-incrementing-counter ↩︎

  8. PrimeReact creates the unique id for tabs in a mount effect: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/tabview/TabView.js#L307-L311 ↩︎

  9. You can verify the SSR result of PrimeReact by inspecting the source of the docs page: https://primereact.org/tabview/ ↩︎

  10. PrimeReact's findNextHeaderAction for keyboard navigation: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/tabview/TabView.js#L208 ↩︎

  11. The AGP Practices recommend to keep disabled elements focusable for some composite widget elements like tabs: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols ↩︎

  12. Radix UI's custom useId() hook: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/id/src/id.tsx#L8 ↩︎

  13. The HTML spec defines that the id attribute on HTML elements must not contain any whitespace: https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute ↩︎

  14. Radix UI's collection API: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/collection/src/Collection.tsx ↩︎

  15. Radix UI's RovingFocusGroup for handling keyboard interactions: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/roving-focus/src/RovingFocusGroup.tsx ↩︎

  16. React Aria uses Math.random() to generate a unique id to support multiple copies of React Aria in client side rendered apps: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/ssr/src/SSRProvider.tsx#L29-L37 ↩︎

  17. React Aria uses a counter variable for older React versions by accessing React internals: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/ssr/src/SSRProvider.tsx#L104-L143 ↩︎

  18. React Aria removes whitespaces in user provided values before using them as id for an HTML element: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/tabs/src/utils.ts#L19-L21 ↩︎

  19. React Arias fake DOM implementation: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/collections/src/Document.ts ↩︎

  20. The TabPanel component is created via createHideableComponent that checks if its's rendered inside a Hidden context: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/collections/src/Hidden.tsx#L67 ↩︎

  21. The keyboard handler of the @react-aria/selection package: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/selection/src/useSelectableCollection.ts#L123 ↩︎

  22. The TabsKeyboardDelegate handles keyboard interactions for tabs: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/tabs/src/TabsKeyboardDelegate.ts#L15 ↩︎

  23. React Stately's SelectionManager: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-stately/selection/src/SelectionManager.ts#L35 ↩︎

  24. React Aria's useSelectableItem hook uses an effect to focus the currently selected item: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/selection/src/useSelectableItem.ts#L161-L172 ↩︎

  25. The focus utility of React Aria: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/focus/src/focusSafely.ts#L21 ↩︎

  26. PrimeReact doesn't not yet support RTL: https://github.com/primefaces/primereact/issues/3096 ↩︎

  27. Material's RtlProvider for RTL support: https://github.com/mui/material-ui/blob/288863bd2f8681a82c4bfbaf13215b41043bc551/packages/mui-system/src/RtlProvider/index.js ↩︎

  28. Radix UI's Direction provider for RTL support: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/direction/src/Direction.tsx ↩︎

  29. React Aria's I18nProvider for RTL support: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/i18n/src/context.tsx ↩︎

  30. React Aria implements an isRTL utility based on Intl.Locale to get the direction of a locale: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/i18n/src/utils.ts#L20 ↩︎

  31. The Intl.Locale represents a Unicode locale identifier and gives you additional information about a locale, like it's direction: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale ↩︎

  32. TabPanels should be focusable in certain cases (see last paragraph in the Note box): https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#keyboardinteraction ↩︎

  33. Radix UI makes all tab panels focusable: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/tabs/src/Tabs.tsx#L251C13-L251C21 ↩︎

  34. React Aria sets the tab index by checking if the tab panel has any tabbable child: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/tabs/src/useTabPanel.ts#L34 ↩︎

  35. The TreeWalker object represents the nodes of a document subtree and a position within them: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker ↩︎