[DropdownMenu] Issue
Title
bug(DropdownMenu): Keyboard navigation and item focus break when ported inside Shadow DOM
Describe the Bug
When rendering the Radix UI DropdownMenu inside a Web Component using an isolated Shadow DOM, keyboard arrow navigation (ArrowUp / ArrowDown) completely stops working. Items do not gain focus, and accessibility properties are ignored.
Why this is happening (Technical Root Cause)
- Event Retargeting: Inside a Shadow DOM, keyboard events are retargeted by the browser. When
keydownbubbles up to Radix's event listeners attached at the portal root level, the event target (e.target) evaluates to the host element container (<my-web-component>) instead of the individual<DropdownMenuItem>node. - Active Element Filtering: Radix relies natively on
document.activeElementto track list index updates and shift focus. In a Shadow DOM context,document.activeElementalways returns the custom element host wrapper rather than the deeply encapsulated focused node inside the shadow root. Radix incorrectly evaluates this as an outside focus shift and halts loop increments. - Portal Element Scope: When
<DropdownMenuContent>is rendered with a custom container reference passed to<DropdownMenuPrimitive.Portal container={shadowRoot}>, Radix automatically creates its container HTML wrapper inside theshadowRoottree. While this cleanly scopes styling, it leaves the inner Radix layout engine completely sandboxed away from standard window interaction trees. Keyboard bindings do not bridge across this boundary because the engine assumes it is running at the absolute top layer of the globaldocument.body.
Reproduction Steps
- Create a standard custom element/Web Component using
this.attachShadow({ mode: 'open' }). - Mount a React root inside the Shadow DOM containing a standard
DropdownMenu. - Explicitly pass the component's
shadowRootinto the<DropdownMenuPrimitive.Portal container={shadowRoot}>primitive to keep styles scoped correctly. - Open the dropdown menu and try moving between items using the keyboard arrow keys.
- Observed Behavior: The menu opens inside the shadow container, but hitting
ArrowDownorArrowUpfails to focus any child items.
Expected Behavior
Radix should correctly capture key events and navigate through active children when targeted elements are structurally nested inside a custom web-component shadow boundary by checking shadowRoot.activeElement if a portal container is specified.
Environment Context
- Library Version:
@radix-ui/react-dropdown-menu(Latest) - Framework context: React inside a Custom Web Component / Shadow DOM Layer
- Browsers tested: Chromium-based browsers (Chrome, Edge), Safari, Firefox
Temporary Workaround Added
Currently, this must be circumvented by attaching a manual fallback key interception matrix inside the DropdownMenuContent primitives to track indexes manually via shadowRoot.activeElement:
const handleShadowKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const items = Array.from(contentRef.current.querySelectorAll('[data-slot="dropdown-menu-item"]')) as HTMLElement[];
const activeEl = shadowRoot.activeElement;
const currentIndex = items.indexOf(activeEl as HTMLElement);
let nextIndex = e.key === 'ArrowDown'
? (currentIndex + 1 >= items.length ? 0 : currentIndex + 1)
: (currentIndex - 1 < 0 ? items.length - 1 : currentIndex - 1);
items[nextIndex]?.focus();
}
};
function useShadowContainer() {
const [state, setState] = React.useState<{ container: HTMLElement | ShadowRoot | undefined; isShadow: boolean }>({
container: undefined,
isShadow: false,
});
const refCallback = React.useCallback((node: HTMLElement | null) => {
if (node) {
const root = node.getRootNode();
if (root instanceof ShadowRoot) {
setState({ container: root, isShadow: true });
}
}
}, []);
return { ...state, refCallback };
}Source: radix-ui/themes