#807·themes

[DropdownMenu] Issue

Author: yathink3Created Jun 16, 2026Updated Jun 16, 2026

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)

  1. Event Retargeting: Inside a Shadow DOM, keyboard events are retargeted by the browser. When keydown bubbles 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.
  2. Active Element Filtering: Radix relies natively on document.activeElement to track list index updates and shift focus. In a Shadow DOM context, document.activeElement always 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.
  3. 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 the shadowRoot tree. 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 global document.body.

Reproduction Steps

  1. Create a standard custom element/Web Component using this.attachShadow({ mode: 'open' }).
  2. Mount a React root inside the Shadow DOM containing a standard DropdownMenu.
  3. Explicitly pass the component's shadowRoot into the <DropdownMenuPrimitive.Portal container={shadowRoot}> primitive to keep styles scoped correctly.
  4. Open the dropdown menu and try moving between items using the keyboard arrow keys.
  5. Observed Behavior: The menu opens inside the shadow container, but hitting ArrowDown or ArrowUp fails 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:

typescript
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 };
}