react
react copied to clipboard
[Portals] DOM order accessibility
Components built with Portals include accessibility defects leading to poor usability/task abandonment.
During accessibility audits and usability testing of Shopify components, we’ve noticed a prominent defect with components built with Portals; DOM structure.
Portals sets dynamically generated content containers at the bottom of the DOM. This is fine for modal window components since keyboard focus is explicitly placed on/within the modal container. The Portals documentation even calls this out and links to APG for more documentation.
The issue lies with other component patterns; disclosures (popovers, drop-down navigation, etc,) comboboxes, tooltips, etc. The expectation for these components is for focus to remain on the activator and for dynamic content containers to appear next in the DOM, a direct sibling to the related activator control.
For example, during usability testing, testers struggled to use one such component which was built using Portals. When the dynamic container appeared after interacting with the activator, testers would move their screen reader virtual cursor forward from the activator, expecting to interact with the dynamic content. Instead, the cursor moved "underneath" the container, bypassing the content, to the next (unrelated) item in the DOM. This led to a confusing user experience.
In addition, this issue is a violation of WCAG 1.3.2: Meaningful Sequence (Level A.) Specifically, Failure of Success Criterion 1.3.2 due to changing the meaning of content by positioning information with CSS.
Is it possible to adjust the position of the dynamic content container to be a sibling of the activator?
This would be a big win for accessibility, not only for Shopify components built with Portals, but for all platforms.
Isn't this entirely up to the implementation of components that you're rendering? The point of ReactDOM.createPortal
is to allow you to render content where the React VDOM hierarchy differs from the actual DOM hierarchy. It's when you want a component to control content that is distant from it, oftentimes mounted directly to <body>
.
If you don't want that, then don't use ReactDOM.createPortal
, or use it with a different attachment point. If you want the attachment point to be within the component's existing DOM, you probably don't want a portal at all.
@nmain I believe the main benefit of our use of Portals is in managing z-index
— to guarantee content is layered on top of the activator's siblings and ancestors.
Do you have any recommendations on:
- Helping to manage
z-index
layering, and - Inserting dynamic content into the DOM as a sibling to the activator?
It's been a while since I built something like this, but my general approach would be to render the menu in same component that renders the button, with no portal, so the focus order is automatically correct. Then style the menu with position:fixed
so it can pop on top of everything, and then use a library like Popper to keep the menu appropriately "attached" to the button.
Portals specifically allow breaking DOM order. One use case is z-index but it's also any other style cascading (which is called out in the docs for overflow: hidden
). But changing DOM order could also be intended.
If you use this to move content all over the page so that it breaks semantics then that's an author error. The docs don't contain examples for that and even call out a11y considerations.
Calling out these author errors out automatically is incredibly hard at the React level. e2e testing or manual testing are more accurate calling out these issues.
I would recommend filing these issues against the component implementation since createPortal
specifically is for breaking DOM order.
The only solution for React would be aria-owns
but support for that is very poor as far as I know and it's not clear to me that this would always be the correct solution.
Portals specifically allow breaking DOM order.
If you use this to move content all over the page so that it breaks semantics then that's an author error.
@eps1lon Thanks. So if I understand correctly, if we want to ensure the content container is a sibling to the activator, we should not be using Portals. If this is correct, could you recommend another method to manage z-index
of dynamic components?
The docs don't contain examples for that and even call out a11y considerations.
I would strongly suggest adding additional warnings to the documentation. "Do not use to implement disclosure, combobox, or tooltip patterns as this will break DOM order."
@nmain Thanks. Popper looks interesting. I'll share this back with my dev team.
"Do not use to implement disclosure, combobox, or tooltip patterns as this will break DOM order."
Just because DOM order is changed, doesn't necessarily mean these a11y patterns do not work.
The portal docs specifically say that it portals something to a different place. At some point documentation becomes overwhelming if you repeat everything with different words. Saying that an element is moved to a different place and order is not the same are saying the same thing.
Maybe let's approach it from a different angle: Which point in the docs implies that order of the UI would not be changed?
"Do not use to implement disclosure, combobox, or tooltip patterns as this will break DOM order."
Just because DOM order is changed, doesn't necessarily mean these a11y patterns do not work.
The portal docs specifically say that it portals something to a different place. At some point documentation becomes overwhelming if you repeat everything with different words. Saying that an element is moved to a different place and order is not the same are saying the same thing.
Maybe let's approach it from a different angle: Which point in the docs implies that order of the UI would not be changed?
Hey @eps1lon! 👋🏽
I don't think there's a lack of clarity around what portals do, but there's an opportunity to flesh out the best practices around their use while also calling out when not to use them. This paragraph in particular should be updated:

Portals are used rampantly in a way that makes React apps (and therefore a good chunk of the modern internet) inaccessible, but this documentation is a great learning opportunity for devs who are considering use of a portal as a solution for stacking order problems they're facing. We can be more straightforward with why portals aren't an answer that solves the problem for everyone consuming their app.
When dynamic content containers are injected outside of the parent that they're triggered from, people consuming the content with assistive technologies (AT) don't have the same experience as users consuming the content visually. Keyboard accessibility is the tip of the iceberg and typically doesn't translate to a coherent experience for other AT hardware devices or even screen readers. With the exception of modals, the use of programmatic focus of the portaled element falls short in keeping things like comboboxes up to WCAG spec, where DOM focus must remain on the text input. This means that developers are unknowingly building experiences that could get them sued under laws like the Americans with Disabilities Act or the European Accessibility Act.
We should aim to make it clear in the documentation the implications portals have on the DOM and Accessibility trees, and discourage their use as a z-index
hack. I'd love to learn more about other use cases aside from hacking styles and contribute to updating the portal docs. In the meantime, I'll work with my team to undo our misuse of portals in the Polaris design system.
It's been a while since I built something like this, but my general approach would be to render the menu in same component that renders the button, with no portal, so the focus order is automatically correct. Then style the menu with position:fixed so it can pop on top of everything, and then use a library like Popper to keep the menu appropriately "attached" to the button.
@nmain thanks for recommending Popper.js, this looks like exactly what we currently do with our PositionedOverlay
that uses a portal component under the hood 👀
@chloerice I would encourage you to propose these sort of suggestions in the form of a PR to the https://github.com/reactjs/reactjs.org/ repository. That would seem more constructive then reiterating the implications of changing DOM order.
this looks like exactly what we currently do with our PositionedOverlay that uses a portal component under the hood 👀
@chloerice You should be able to use position: fixed
to "pop out" without needing a portal, as position: fixed
almost always creates a new top level stacking context. Then with slightly different settings, Popper can manage that fixed position element just as it would any other element.