Maintaining Accessibility with React createPortal and aria-owns: A Complete Guide

When building modern React applications, developers frequently use createPortal
to render components like dropdowns, modals, and tooltips outside their natural DOM hierarchy. While this solves styling and z-index issues, it creates a significant accessibility problem: screen readers lose the semantic connection between trigger elements and their associated content.
Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.
After working with enterprise React applications and conducting extensive accessibility audits, I’ve seen this pattern cause real usability issues for users with disabilities. In this comprehensive guide, we’ll explore how to properly maintain ARIA relationships when using portals, with practical examples and testing strategies.
The Problem: Broken Semantic Relationships
Consider this common dropdown implementation:
function Dropdown({ children, options }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{children}
</button>
{isOpen && createPortal(
<ul className="dropdown-menu">
{options.map(option => (
<li key={option.value}>{option.label}</li>
))}
</ul>,
document.body
)}
</div>
);
}
What’s wrong here? The menu renders at the document root, completely disconnected from the button. Screen reader users experience:
- No indication that a menu appeared when the button is pressed
- Inability to understand the relationship between button and menu
- Confusion about where focus should go and what content is available
The Solution: Strategic Use of ARIA Attributes
The key is using aria-owns
to explicitly declare ownership relationships, combined with other ARIA attributes for complete accessibility:
function AccessibleDropdown({ children, options }) {
const [isOpen, setIsOpen] = useState(false);
const buttonId = useId();
const menuId = useId();
return (
<div>
<button
id={buttonId}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="menu"
aria-owns={isOpen ? menuId : undefined}
>
{children}
</button>
{isOpen && createPortal(
<ul
id={menuId}
role="menu"
aria-labelledby={buttonId}
className="dropdown-menu"
>
{options.map(option => (
<li key={option.value} role="menuitem">
{option.label}
</li>
))}
</ul>,
document.body
)}
</div>
);
}
Breaking Down the ARIA Implementation
On the trigger button:
aria-expanded
: Communicates whether the menu is currently openaria-haspopup="menu"
: Indicates this button opens a menuaria-owns
: Creates the semantic parent-child relationship with the portaled menu
On the portaled menu:
role="menu"
: Defines the semantic role for screen readersaria-labelledby
: References the button that controls this menuid
: Provides the target for thearia-owns
relationship
Advanced Pattern: Combobox with Portal
For more complex interactions like searchable dropdowns, the pattern extends further:
function SearchableCombobox({ options, onSelect, placeholder }) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const comboboxId = useId();
const listboxId = useId();
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(query.toLowerCase())
);
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
);
break;
case 'Enter':
if (activeIndex >= 0) {
onSelect(filteredOptions[activeIndex]);
setIsOpen(false);
setQuery('');
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div className="combobox-container">
<input
id={comboboxId}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-owns={isOpen ? listboxId : undefined}
aria-activedescendant={
activeIndex >= 0
? `${listboxId}-option-${activeIndex}`
: undefined
}
autoComplete="off"
/>
{isOpen && filteredOptions.length > 0 && createPortal(
<ul
id={listboxId}
role="listbox"
aria-labelledby={comboboxId}
className="combobox-listbox"
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
setQuery('');
}}
className={index === activeIndex ? 'active' : ''}
>
{option.label}
</li>
))}
</ul>,
document.body
)}
</div>
);
}
Testing Your Implementation
Automated Testing
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('dropdown maintains ARIA relationships with portal', async () => {
const user = userEvent.setup();
render(
<AccessibleDropdown options={[{value: '1', label: 'Option 1'}]}>
Select Option
</AccessibleDropdown>
);
const button = screen.getByRole('button');
// Initially closed
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(button).not.toHaveAttribute('aria-owns');
// Open the dropdown
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(button).toHaveAttribute('aria-owns');
const menu = screen.getByRole('menu');
const menuId = menu.getAttribute('id');
expect(button).toHaveAttribute('aria-owns', menuId);
expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id'));
});
Manual Testing with Screen Readers
NVDA/JAWS on Windows:
- Navigate to the button with Tab
- Press Enter/Space to open
- Verify announcement includes “expanded” state
- Confirm menu content is read automatically
VoiceOver on macOS:
- Use VO+Right Arrow to navigate
- Listen for “pop-up button” or “menu button” announcement
- Verify relationship announcements when menu opens
Testing Checklist:
- Screen reader announces button role and state
- Opening menu is announced automatically
- Menu items are navigable with arrow keys
- Relationship between trigger and menu is clear
- Focus management works correctly
- Escape key closes menu and returns focus
Common Pitfalls and Solutions
Pitfall 1: Conditional aria-owns
❌ Wrong: Always setting aria-owns
aria-owns={menuId} // Even when menu is closed
✅ Correct: Only when menu is open
aria-owns={isOpen ? menuId : undefined}
Pitfall 2: Missing Focus Management
❌ Wrong: No focus handling
{isOpen && createPortal(<Menu />, document.body)}
✅ Correct: Proper focus management
useEffect(() => {
if (isOpen) {
// Focus first menu item or search input
const firstItem = document.querySelector(`#${menuId} [role="menuitem"]`);
firstItem?.focus();
}
}, [isOpen, menuId]);
Pitfall 3: Inconsistent IDs
❌ Wrong: Generated IDs that change
const menuId = `menu-${Math.random()}`;
✅ Correct: Stable IDs with useId
const menuId = useId();
Performance Considerations
Portal rendering can impact performance, especially with complex menus:
// Optimize with useMemo and useCallback
const MemoizedPortalMenu = memo(({ isOpen, menuId, options, onSelect }) => {
if (!isOpen) return null;
return createPortal(
<Menu
id={menuId}
options={options}
onSelect={onSelect}
/>,
document.body
);
});
Conclusion
Properly implementing aria-owns
with createPortal
requires attention to detail, but the accessibility benefits are substantial. Users with screen readers gain:
- Clear understanding of UI relationships
- Proper navigation patterns
- Predictable interaction models
- Equal access to application functionality
The patterns shown here have been tested across multiple screen readers and meet WCAG 2.1 AA standards. When building portal-based components, always test with actual assistive technology to ensure the experience works as intended.
For production applications, CoreUI provides battle-tested components that implement these accessibility patterns correctly out of the box, including the Autocomplete component and Multi-Select component. However, understanding the underlying ARIA relationships helps you build better custom components, extend existing ones, and debug accessibility issues when they arise.
Further Reading
- ARIA Authoring Practices Guide - Combobox Pattern
- React createPortal Documentation
- WCAG 2.1 Guidelines on Relationships
- Screen Reader Testing Guide