Accessible by Design – Building Compound Components the Right Way
Accessibility does not have to be complicated. With the right patterns in place, it can naturally fit into your workflow. Think of it like using semantic HTML or defining design tokens for consistent spacing and color. When you are building a design system, you have the perfect opportunity to make accessibility a built-in feature, not an extra step. Not something you need to remember or review every time, but something that is built right into the components your team uses.
One of the most effective ways to achieve this is by using compound components. They give you full control over the structure and behavior of your components, which means you can take care of accessibility behind the scenes. You decide how the pieces fit together, and the people using your components can focus on building features, without having to worry about ARIA attributes, keyboard interactions, or focus management.
In this article, we will explore how the compound component pattern can help you build accessible components by default. We will walk through a Tabs component as a practical example and highlight how this approach makes your design system more reliable, more consistent, and easier to work with.
The Building Blocks We Will Use
If you have read my article on Atomic Design and breaking up components, you already know how powerful it can be to split components into smaller, reusable building blocks. Compound components follow that same philosophy, but take it a step further by sharing state and behavior internally.
In short, compound components are a pattern in React where a group of related components share the same context and work together as a single unit. Instead of passing everything down through props, they communicate behind the scenes. You might have seen this pattern in components like Tabs, Dropdowns, or Accordions. Each part is a separate component, but together they form one cohesive feature. Think of something like this:
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="account">
Account content
</Tabs.Panel>
<Tabs.Panel value="settings">
Settings content
</Tabs.Panel>
</Tabs>
Each piece, like the list, triggers, and panels, knows what it needs to do based on context. That setup is not just useful for managing state. It also gives us full control over accessibility from within the component itself. We define what roles to apply, how keyboard navigation should work, and how screen readers interact with the different parts. And that is exactly what we will explore in the next section.

Building the Tabs Component
Let’s start with a basic structure. We will create a Tabs
component using the compound pattern. The idea is to wrap everything in a shared context, so all parts know what the current tab is and can behave accordingly.
Step 1: Setting up the context
We will need to track the current value and expose a way for triggers to update it. Here is the initial context setup:
import React, { createContext, useContext, useState, ReactNode } from "react"
type TabsContextType = {
value: string
setValue: (val: string) => void
}
const TabsContext = createContext<TabsContextType | undefined>(undefined)
export function useTabsContext() {
const context = useContext(TabsContext)
if (!context) {
throw new Error("Tabs components must be used within <Tabs>")
}
return context
}
This gives us a simple way to share the selected tab state across all subcomponents.
Step 2: Creating the Tabs root component
The root component wraps everything and provides the context.
type TabsProps = {
defaultValue: string
children: ReactNode
}
export function Tabs({ defaultValue, children }: TabsProps) {
const [value, setValue] = useState(defaultValue)
return (
<TabsContext.Provider value={{ value, setValue }}>
{children}
</TabsContext.Provider>
)
}
Now that the context is in place, let’s build the parts that make up the rest of the component. We will start with the tab list and the individual triggers.
Step 3: The Tabs List
The Tabs.List
is a simple wrapper around the tab buttons. It gives us a container to structure the layout and later add accessibility roles.
type TabsListProps = {
children: ReactNode
}
export function TabsList({ children }: TabsListProps) {
return (
<div>
{children}
</div>
)
}
At this point, this component just renders its children. We will come back to this later when we add accessibility roles and keyboard navigation.
Step 4: The Tabs Trigger
Each Tabs.Trigger
is a button that updates the selected value. It uses the shared context to know if it is selected and to update the active tab when clicked.
type TabsTriggerProps = {
value: string
children: ReactNode
}
export function TabsTrigger({ value, children }: TabsTriggerProps) {
const { value: currentValue, setValue } = useTabsContext()
const isSelected = currentValue === value
return (
<button onClick={() => setValue(value)}>
{children}
</button>
)
}
Again, this is just the bare minimum to make the tab work. The visual styling and accessibility will come next. We are keeping the focus here on getting the core behavior in place.
Next, we will set up the panel that displays the content for the selected tab. The panel is where we show the content for the selected tab. It needs to know what value it is linked to, and whether it should be visible based on the current selection.
Step 5: The Tabs Panel
type TabsPanelProps = {
value: string
children: ReactNode
}
export function TabsPanel({ value, children }: TabsPanelProps) {
const { value: currentValue } = useTabsContext()
const isActive = currentValue === value
if (!isActive) {
return null
}
return (
<div>
{children}
</div>
)
}
This panel component is only rendered when its value matches the active tab. It keeps things simple and avoids rendering content that is not visible. Just like with the list and triggers, we will enhance this later on with accessibility attributes and proper linking.
At this point, we have a clean and functional tab system. The pieces know how to talk to each other, the state is shared through context, and everything renders exactly when it should. Because we control the full structure, we can start layering in accessibility features without changing the API. We can add ARIA roles, connect triggers to panels, manage keyboard navigation, and handle focus behavior. The people using this component do not have to think about any of it. They just use Tabs
, and everything works as expected.
Adding Accessibility Features
Now that the tabs are fully functional, we can start layering in accessibility. Because we have complete control over the internal structure, it is easy to make accessibility a built-in feature rather than an afterthought.
We will focus on three key improvements:
- Adding semantic roles for screen readers
- Linking triggers to their corresponding panels
- Supporting custom IDs and ARIA attributes when needed
Let us go through them step by step.
Adding Roles to the Tabs List
The first improvement is to tell screen readers that the buttons inside Tabs.List
are part of a related group. We do that by adding the tablist
role:
export function TabsList({ children }: TabsListProps) {
return (
<div role="tablist">
{children}
</div>
)
}
This role provides essential context, helping assistive technology recognize this as a tab group.
Connecting Triggers and Panels with ARIA
Each trigger must:
- Have
role="tab"
- Be linked to its panel using
aria-controls
- Mark itself as selected with
aria-selected
We also want to give consumers the option to override the ID or ARIA attributes if needed. Here is how the updated Tabs.Trigger
component looks:
type TabsTriggerProps = {
value: string
children: ReactNode
id?: string
ariaControls?: string
}
export function TabsTrigger({
value,
children,
id,
ariaControls
}: TabsTriggerProps) {
const {
value: currentValue,
setValue,
getPanelId,
getTriggerId
} = useTabsContext()
const isSelected = currentValue === value
const triggerId = id ?? getTriggerId(value)
const controlsId = ariaControls ?? getPanelId(value)
return (
<button
id={triggerId}
role="tab"
aria-selected={isSelected}
aria-controls={controlsId}
onClick={() => setValue(value)}
>
{children}
</button>
)
}
This setup allows for full flexibility. If you do not provide an ID, it falls back to a predictable one based on context. If you want full control, you can pass your own.
Making Panels Recognizable and Linked
For screen readers, each panel needs:
role="tabpanel"
- A unique ID
- A connection to its corresponding trigger via
aria-labelledby
Just like with triggers, we allow consumers to override the ID or ARIA attributes if they need to. Here is the updated Tabs.Panel
:
type TabsPanelProps = {
value: string
children: ReactNode
id?: string
ariaLabelledBy?: string
}
export function TabsPanel({
value,
children,
id,
ariaLabelledBy
}: TabsPanelProps) {
const {
value: currentValue,
getTriggerId,
getPanelId
} = useTabsContext()
const isActive = currentValue === value
if (!isActive) {
return null
}
const panelId = id ?? getPanelId(value)
const labelledById = ariaLabelledBy ?? getTriggerId(value)
return (
<div
id={panelId}
role="tabpanel"
aria-labelledby={labelledById}
>
{children}
</div>
)
}
With these updates, the component is now fully connected from a screen reader perspective. Triggers and panels are paired correctly, selection is announced, and all parts behave exactly as expected.
The real advantage of this approach is that accessibility becomes part of the foundation. As a developer, you are not just avoiding mistakes. You are actively building accessible interfaces by default. By handling most of the structure and behavior inside the component, and exposing just enough control through props and types, you make it easier to do the right thing without losing flexibility.
Adding Keyboard Navigation
To complete the experience, we want users to be able to move between tabs using the arrow keys. It brings the component behavior closer to native tab interfaces.
In this example, we will take a more React-friendly approach. Each trigger will register itself with the Tabs context. This keeps everything predictable and fully within the React lifecycle.
In the root Tabs
component, we start by tracking all trigger buttons using a shared ref. Each trigger will register itself once when it mounts:
const triggerRefs = useRef<HTMLButtonElement[]>([])
const registerTrigger = (ref: HTMLButtonElement | null) => {
if (!ref) return
if (!triggerRefs.current.includes(ref)) {
triggerRefs.current.push(ref)
}
}
const getTriggers = () => triggerRefs.current
We expose both functions in the context so other parts of the component tree can access them.
In each Tabs.Trigger
, we use a ref and call registerTrigger
when the component mounts:
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
registerTrigger(buttonRef.current)
}, [registerTrigger])
Now that all triggers are registered, we can update Tabs.List
to support keyboard navigation. We will listen for keydown events and use the arrow keys to shift focus and we attach this to the Tabs.List
container:
import { useTabsContext } from "./TabsContext"
type TabsListProps = {
children: React.ReactNode
}
export function TabsList({ children }: TabsListProps) {
const { getTriggers } = useTabsContext()
function handleKeyDown(event: React.KeyboardEvent) {
const buttons = getTriggers()
const currentIndex = buttons.findIndex(btn => btn === document.activeElement)
if (currentIndex === -1) return
if (event.key === "ArrowRight") {
const next = buttons[(currentIndex + 1) % buttons.length]
next.focus()
}
if (event.key === "ArrowLeft") {
const prev = buttons[(currentIndex - 1 + buttons.length) % buttons.length]
prev.focus()
}
}
return (
<div role="tablist" onKeyDown={handleKeyDown}>
{children}
</div>
)
}
With this setup, users can move between tabs using the left and right arrow keys. The focus moves as expected, and the active state updates when a trigger is clicked. You can take this even further by supporting the Home and End keys, or activating tabs on focus instead of click. The important thing is that this behavior comes built into the component. It works out of the box, without asking developers to wire up anything themselves.
Wrapping Up
We started this article with a simple idea. What if accessibility could be built in, instead of added later? Compound components give us a powerful way to do exactly that. By controlling structure and behavior from inside the component, we were able to:
- Handle ARIA roles and relationships automatically
- Connect triggers to panels using stable IDs
- Support keyboard navigation in a way that just works
- Expose flexibility through props and types without risking broken accessibility
And all of it is reusable. Once your Tabs component is in place, any team using the design system benefits instantly. They do not need to look up accessibility specs or worry about roles and attributes. It is all included.
This is where design systems shine. When the right decisions are made at the foundation, the people building features move faster and users get a better experience without anyone thinking twice about it. Compound components are not just about structure. They are an opportunity to make accessibility the default. You can find the full code here.