Interactive dropdown menus with Radix UI

Jun 7, 2024

I recently tried adding an input to Radix's SubDropdownMenu component. The first issue I ran into was Radix calling focus in the onPointerMove of DropdownMenuItem. This UX works for buttons, but it can cause inputs to lose focus unexpectedly when you bump your mouse.

This was especially painful when the SubDropdownMenu contained DropdownMenuItems. Sure there is stopPropagation and preventDefault for overriding these events, but I wanted to know why Radix had made such a specific UX for their dropdown menus.

Another problem I faced was the conflicting keyboard interactions. The ARIA spec for role="menu" requires sub menus to open/close with right/left arrow keys, but arrow keys are used for moving your cursor within an input [1].

When a user clicks left, should you close the submenu or move the cursor to the left?

I started to realize that having inputs within submenus is just clunky, but replacing the submenu with a dialog containing your interactive content is a great workaround. Here is an example I found in Linear:

In this post, I will show you how to build this flow from a menu to dialog with the Radix DropdownMenu and ContextMenu. Here is the final demo:

Let's start by creating a "DropdownContent" component so that our root component is easy to read.

const Item = forwardRef(function Item(
  props: DropdownMenu.DropdownMenuItemProps,
  ref: React.Ref<HTMLDivElement>,
) {
  return <DropdownMenu.Item ref={ref} className="..." {...props} />
})
 
export function DropdownContent(props: DropdownMenu.DropdownMenuContentProps) {
  return (
    <DropdownMenu.Portal>
      <DropdownMenu.Content {...props}>
        <Item>Circle</Item>
        <Item>Triangle</Item>
        <Item>Square</Item>
        <Popover.Trigger asChild>
          <Item>Custom</Item>
        </Popover.Trigger>
      </DropdownMenu.Content>
    </DropdownMenu.Portal>
  )
}

You'll notice that I wrapped the Item in a Popover.Trigger. You might have been expecting to see a Dialog.Trigger, but rest assured the Radix Popover is just a specific type of dialog. This trigger will close the DropdownMenu and open a Popover.

Here is the App component where we render Popover.Root and DropdownMenu.Root.

function App() {
  return (
    <Popover.Root>
      <DropdownMenu.Root>
        <div className="w-52 h-52 flex justify-end items-start">
          <Popover.PopoverAnchor asChild>
            <DropdownMenuTrigger />
          </Popover.PopoverAnchor>
        </div>
        <DropdownContent sideOffset={5} />
        <PopoverContent sideOffset={5} align={'center'} />
      </DropdownMenu.Root>
    </Popover.Root>
  )
}

Don't miss the fact that our Popover.PopoverAnchor is wrapping DropdownMenuTrigger. This will ensure that our Popover opens in the same location as the DropdownMenu.

We are almost there! But as the DropdownMenu closes the Popover menu opens and then immediately closes.

Fixing Popover flicker

On the surface level, we are seeing this flicker because DropdownMenu defaults to modal={true}. Lets dig into why this is an issue.

What is a modal?

The word modal is an adjective meaning "of or relating to mode, manner, or form" [2].

In web dev, people use modal as a noun, and what they mean is "modal dialog." This dialog prevents outside interaction and forces the user to make a decision. An app goes out of "app mode" and into "decision mode" when a modal is opened.

Other widgets can be modal as well. The Radix DropdownMenu is one example of a modal widget that isn't a dialog.

What I think is happening in our case:

  • Focus leaves the DropdownMenu when the Popover is opened and it's input is focused.
  • This causes the DropdownMenu to close and return focus to the DropdownMenuTrigger, which closes the newly opened Popover.
  • This isn't instantaneous, so we see the Popover flicker in and out.

Popover vs Dialog

A Popover is a specific type of dialog that is non-modal and is positioned relative to its trigger.

Originally, I didn't understand the difference between Popover and Dialog and not knowing that a Popover is non-modal made finding this bug quite tricky. Things shouldn't be modal if possible so we will set modal={false} on our DropdownMenu. When composing Radix components, I have found that matching modal props helps avoid problems like this one.

Here is our demo with <DropdownMenu.Root modal={false} ...

Returning focus to the original trigger

When a dialog closes, the button that opened it should be refocused [3]. Our PopoverTrigger is no longer being rendered since the DropdownMenu closed. So instead we will focus the logical corresponding element which is the DropdownMenuTrigger.

function App() {
  const buttonRef = useRef<HTMLButtonElement>(null) 
  return (
    <Popover.Root>
      <DropdownMenu.Root modal={false}>
        <div className="w-52 h-52 flex justify-end items-start">
          <Popover.PopoverAnchor asChild>
            <DropdownMenuTrigger />
            <DropdownMenuTrigger ref={buttonRef} />
          </Popover.PopoverAnchor>
        </div>
        <DropdownContent sideOffset={5} />
        <PopoverContent
          sideOffset={5}
          align={'center'}

          onCloseAutoFocus={e => {
            e.preventDefault()
            buttonRef.current?.focus()
          }}
        />
      </DropdownMenu.Root>
    </Popover.Root>
  )
}
  1. We manually store a ref to the DropdownMenuTrigger
  2. We override onCloseAutoFocus preventing the default autoFocus, and providing our own by focusing the buttonRef

Try closing the Popover and to see the refocus behavior in action.

ContextMenu

A context menu is the menu that opens when you right click. When you use the ContextMenu component from Radix you are overriding this default right click menu.

The APIs for ContextMenu and DropdownMenu are similar. This is great for quickly mirroring their behavior.

  1. We wrap the entire card with a ContextMenu.Root & ContextMenu.Trigger
  2. And create a ContextContent similar to our DropdownContent but with ContextMenuItems
function App() {
  const buttonRef = useRef<HTMLButtonElement>(null):
  return (
    <Popover.Root>

      <ContextMenu.Root modal={false}>
        <ContextMenu.Trigger asChild>
          <DropdownMenu.Root modal={false}>
            <div className="w-52 h-52 flex justify-end items-start">
              <Popover.PopoverAnchor asChild>
                <DropdownMenuTrigger ref={buttonRef} />
              </Popover.PopoverAnchor>
            </div>
            <DropdownContent sideOffset={5} />
            <PopoverContent
              sideOffset={5}
              align={'center'}
              onCloseAutoFocus={e => {
                e.preventDefault()
                buttonRef.current?.focus()
              }}
            />
          </DropdownMenu.Root>

        </ContextMenu.Trigger>
        <ContextContent />
      </ContextMenu.Root>
    </Popover.Root>
  )
}

You might be thinking "can we combine these components to reduce the amount of markup required?" A nice side effect of abstractions is that they reduce code, but their purpose is primarily to group instances of the same thing. DropdownMenus and ContextMenus are semantically different things. So it doesn't make sense to create an abstraction over them.

Fixing ContextMenu Flicker

When we open the context menu from the dropdown menu our manual focus on the buttonRef causes the context menu to immediately close.

We really only want to call focus on the trigger when the Popover is closed and nothing else is focused. We can check this seeing if the body of our document is focused in onCloseAutoFocus.

function App() {
  const buttonRef = useRef<HTMLButtonElement>(null):
  return (
    <Popover.Root>
      <ContextMenu.Root modal={false}>
        <ContextMenu.Trigger asChild>
          <DropdownMenu.Root modal={false}>
            <div className="w-52 h-52 flex justify-end items-start">
              <Popover.PopoverAnchor asChild>
                <DropdownMenuTrigger ref={buttonRef} />
              </Popover.PopoverAnchor>
            </div>
            <DropdownContent sideOffset={5} />
            <PopoverContent
              sideOffset={5}
              align={'center'}
              onCloseAutoFocus={e => {
                e.preventDefault()
 

                if (document.activeElement === document.body) {
                  buttonRef.current?.focus();

                }
              }}
            />
          </DropdownMenu.Root>
        </ContextMenu.Trigger>
        <ContextContent />
      </ContextMenu.Root>
    </Popover.Root>
  )
}

This is looking much better, but in my mind it doesn't make sense to open the context menu from the dropdown menu.

<PopoverContent
  sideOffset={5}
  align={'center'}
  onCloseAutoFocus={e => {
    e.preventDefault()
 
    if (document.activeElement === document.body) {
      buttonRef.current?.focus()
    }
  }}

  onContextMenu={e => e.stopPropagation()}
/>

preventDefault isn't great here because it prevents both the custom and default context menus. So we use stopPropagation to prevent the custom ContextMenu while still getting the default.

Now our Popover instances have matching context menus.

Bonus: Virtually anchored Popover

Our demo is working great, but it would be kinda sexy if the Popover was anchored to the ContextMenu's position.

Radix uses Popper (now Floating UI) to position its menus, and Popper supports this concept with its virtual elements API. With a little snooping I found that the PopoverAnchor component has an undocumented property called virtualRef which exposes this API.

The term virtualRef indicates that Popper is expecting a subset of properties available on a reference to a DOM node.
Usually when an API specifies virtualRef it's because this subset is small enough to be "faked." Our situation is no exception. The type interface only requires getBoundingClientRect. This makes sense to me too because Popper is a library for positioning elements on the screen and getBoundingClientRect is how you get that information

export const App = () => {
  const buttonRef = useRef<HTMLButtonElement>(null)

  const [position, setPosition] = useState<DOMRect | null>(null)
 
  return (
    <Popover.Root>

      {position && (
        <Popover.PopoverAnchor
          virtualRef={{
            current: {
              getBoundingClientRect: () => position,
            },
          }}
        />
      )}
      <ContextMenu.Root modal={false}>
        <ContextMenu.Trigger
          asChild

          onContextMenu={e =>
            setPosition(new DOMRect(e.clientX, e.clientY, 0, 0))
          }
        >

          <Popover.Root>
            <DropdownMenu.Root modal={false}>
              <div className="w-52 h-52 flex justify-end items-start">
                <Popover.PopoverAnchor asChild>
                  <DropdownMenuTrigger ref={buttonRef} />
                </Popover.PopoverAnchor>
              </div>
              <DropdownContent sideOffset={5} />
              <PopoverContent
                sideOffset={5}
                align={'center'}
                onCloseAutoFocus={e => {
                  e.preventDefault()
 
                  if (document.activeElement === document.body) {
                    buttonRef.current?.focus()
                  }
                }}
              />
            </DropdownMenu.Root>

          </Popover.Root>
        </ContextMenu.Trigger>
        <ContextContent />

        <PopoverContent
          sideOffset={0}
          align={'start'}
          onCloseAutoFocus={e => {
            e.preventDefault()
 
            if (document.activeElement === document.body) {
              buttonRef.current?.focus()
            }
          }}
        />
      </ContextMenu.Root>
    </Popover.Root>
  )
}
  1. We create some state to hold the position of the right clicked event, and store it as a DOMRect since that is what getBoundingClientRect returns.

    const [position, setPosition] = useState<DOMRect | null>(null)
  2. We set that state in the onContextMenu of our ContextMenu.Trigger.

    onContextMenu={e => new DOMRect(e.clientX, e.clientY, 0, 0)}

    Popper only uses x & y so we can set width & height to 0.

  3. The position of our Popover should match the position of our ContextMenu. So we wrap our DropdownMenu with a new Popover.Root

    <Popover.Root>
      <DropdownMenu.Root modal={false}>{/* ... */}</DropdownMenu.Root>
    </Popover.Root>

    allowing us to render another PopoverContent with sideOffset and align matching the ContextMenu.

    <PopoverContent sideOffset={0} align={'start'} ... />
  4. When that PopoverContent is closed, we only refocus the trigger when focus is lost. So we check if our body is the activeElement before calling focus.

    if (document.activeElement === document.body) {
      buttonRef.current?.focus()
    }
  5. Finally, we render the PopoverAnchor creating the virtualRef on the fly like this:

    {
      position && (
        <Popover.PopoverAnchor
          virtualRef={{
            current: {
              getBoundingClientRect: () => position,
            },
          }}
        />
      )
    }

The Popover is opening relative to our right click, but it's a smidge to the left of the context menu.

This is because Radix ContextMenuContent hard codes a sideOffset of 2 [4]. We can compensate for this by hardcoding the same sideOffset in the Popover

My first attempt was specifying like so:

<PopoverContent

  sideOffset={0}

  sideOffset={2}
  align={'start'}
  onCloseAutoFocus={e => {
    e.preventDefault()
 
    if (document.activeElement === document.body) {
      buttonRef.current?.focus()
    }
  }}
/>

But Radix's Popper middleware clamps the sideOffset to the width of the anchor. Our virtualRef's width is 0px so using sideOffset is ineffective. Instead we can fix this by making our position 2px to the right.

onContextMenu={e => {
  setPosition(new DOMRect(e.clientX, e.clientY, 0, 0)), 
  setPosition(new DOMRect(e.clientX + 2, e.clientY, 0, 0)), 
}}

There you have it. A dialog / menu combo for interactive item specific interactions.

Hope you found this post helpful! I'll see you in the next one :)

Subscribe to the newsletter

A monthly no filler update.

Contact me at