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.
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 useEffect
8. In server-side rendered applications, the prefix is set to null
, resulting in an ID like null_header_0
9.
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:
- A static prefix,
radix-
. - A uniquely generated identifier,
:r1:
, which is generated usinguseId()
or a counter variable for older versions of React12. - 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:
- A static prefix of
react-aria
. - 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. - 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 onuseEffect
oruseLayoutEffect
, but instead accesses React's internal__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
variable to access the Fiber instance17. - The role of the element, which in this case is
tab
. - 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.
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.Locale
31 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.
APG Tab Guide: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ ↩︎
Material UI's keyboard handler https://github.com/mui/material-ui/blob/288863bd2f8681a82c4bfbaf13215b41043bc551/packages/mui-material/src/Tabs/Tabs.js#L775 ↩︎
Elements with the
tab
role must be either contained in atablist
or owned by one (viaaria-owns
): https://www.w3.org/TR/wai-aria-1.2/#tab ↩︎Prime React's TabPanel component doesn't render anything: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/tabview/TabView.js#L12 ↩︎
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 ↩︎PrimeReact's counter for unique ids: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/utils/UniqueComponentId.js ↩︎
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 ↩︎
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 ↩︎
You can verify the SSR result of PrimeReact by inspecting the source of the docs page: https://primereact.org/tabview/ ↩︎
PrimeReact's
findNextHeaderAction
for keyboard navigation: https://github.com/primefaces/primereact/blob/a67ffd8ee5d12bdb228193b1809d87839389de1c/components/lib/tabview/TabView.js#L208 ↩︎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 ↩︎
Radix UI's custom
useId()
hook: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/id/src/id.tsx#L8 ↩︎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 ↩︎Radix UI's collection API: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/collection/src/Collection.tsx ↩︎
Radix UI's
RovingFocusGroup
for handling keyboard interactions: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/roving-focus/src/RovingFocusGroup.tsx ↩︎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 ↩︎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 ↩︎
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 ↩︎
React Arias fake DOM implementation: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/collections/src/Document.ts ↩︎
The
TabPanel
component is created viacreateHideableComponent
that checks if its's rendered inside aHidden
context: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/collections/src/Hidden.tsx#L67 ↩︎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 ↩︎The
TabsKeyboardDelegate
handles keyboard interactions for tabs: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/tabs/src/TabsKeyboardDelegate.ts#L15 ↩︎React Stately's SelectionManager: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-stately/selection/src/SelectionManager.ts#L35 ↩︎
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 ↩︎The focus utility of React Aria: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/focus/src/focusSafely.ts#L21 ↩︎
PrimeReact doesn't not yet support RTL: https://github.com/primefaces/primereact/issues/3096 ↩︎
Material's
RtlProvider
for RTL support: https://github.com/mui/material-ui/blob/288863bd2f8681a82c4bfbaf13215b41043bc551/packages/mui-system/src/RtlProvider/index.js ↩︎Radix UI's
Direction
provider for RTL support: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/direction/src/Direction.tsx ↩︎React Aria's
I18nProvider
for RTL support: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/i18n/src/context.tsx ↩︎React Aria implements an
isRTL
utility based onIntl.Locale
to get the direction of a locale: https://github.com/adobe/react-spectrum/blob/914b5dfeb7c4622ad177993d92b391cf993fd371/packages/%40react-aria/i18n/src/utils.ts#L20 ↩︎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 ↩︎TabPanels should be focusable in certain cases (see last paragraph in the Note box): https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#keyboardinteraction ↩︎
Radix UI makes all tab panels focusable: https://github.com/radix-ui/primitives/blob/8175208cdbb8577e53f1165678ee0ce28a801837/packages/react/tabs/src/Tabs.tsx#L251C13-L251C21 ↩︎
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 ↩︎
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 ↩︎