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

React createPortal and aria-owns

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.

Table of Contents

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 open
  • aria-haspopup="menu": Indicates this button opens a menu
  • aria-owns: Creates the semantic parent-child relationship with the portaled menu

On the portaled menu:

  • role="menu": Defines the semantic role for screen readers
  • aria-labelledby: References the button that controls this menu
  • id: Provides the target for the aria-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:

  1. Navigate to the button with Tab
  2. Press Enter/Space to open
  3. Verify announcement includes “expanded” state
  4. Confirm menu content is read automatically

VoiceOver on macOS:

  1. Use VO+Right Arrow to navigate
  2. Listen for “pop-up button” or “menu button” announcement
  3. 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


About the Author

Subscribe to our newsletter
Get early information about new products, product updates and blog posts.
How to concatenate a strings in JavaScript?
How to concatenate a strings in JavaScript?

How to Detect a Click Outside of a React Component
How to Detect a Click Outside of a React Component

Javascript Random - How to Generate a Random Number in JavaScript?
Javascript Random - How to Generate a Random Number in JavaScript?

What is globalThis in JavaScript?
What is globalThis in JavaScript?

What is JavaScript Array.pop() Method?
What is JavaScript Array.pop() Method?

How to Open Link in a New Tab in HTML?
How to Open Link in a New Tab in HTML?