Hover Trigger with Radix Vue Components
03 Mar 2024

This post is to share my experience customizing radix-vue component to trigger via hover. The other requirement I have is to also have the menu animate in and out when activated. Let’s see how we can do it.

Using radix-vue’s DropdownMenu

Here is how we would use the DropdownMenu component. The important thing to note here is we have references to the trigger element and content element. With these references, we pass them to the useMenuHover() composable that we will have a look at. The next important thing to note here is that in order to define our own HTML element, we need to set the as-child flag on the radix-vue components. The next thing I found out when testing is to set the modal prop to false, this is to not have an overlay on top of the trigger which will trigger our mouseleave event immediately, which is not what we want.

The other interesting thing is that radix-vue exposes some CSS custom properties so we can hook into it. In this case we can make use of the --radix-dropdown-menu-content-transform-origin to define the origin of the scale when it is animating. Cool!

The last thing to note here is because we want to trigger the open state manually, we have a toggleState ref to handle that.

<script setup lang="ts">
import {
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuPortal,
  DropdownMenuRoot,
  DropdownMenuTrigger,
} from "radix-vue";
import { ref } from "vue";

import { useMenuHover } from "./useMenuHover";

const toggleState = ref(false);
const triggerEl = ref<HTMLElement>();
const contentEl = ref<HTMLElement>();

useMenuHover(toggleState, triggerEl, contentEl);
</script>

<template>
  <DropdownMenuRoot v-model:open="toggleState" :modal="false">
    <DropdownMenuTrigger as-child>
      <div ref="triggerEl" class="inline-block">
        <slot name="trigger" />
      </div>
    </DropdownMenuTrigger>

    <DropdownMenuPortal>
      <DropdownMenuContent as-child side="bottom" align="start" force-mount>
        <div ref="contentEl">
          <Transition
            class="origin-[--radix-dropdown-menu-content-transform-origin] transition-[transform_opacity]"
            enter-from-class="scale-y-75 opacity-0"
            enter-to-class="scale-y-100 opacity-100"
            leave-to-class="scale-y-75 opacity-0"
          >
            <div v-if="toggleState">
              <slot />
            </div>
          </Transition>
        </div>
      </DropdownMenuContent>
    </DropdownMenuPortal>
  </DropdownMenuRoot>
</template>

useMenuHover composable

Here we have the useMenuHover composable to handle all the hover logic. The logic we use here basically has a setTimeout function to let the user hover to the menu area after leaving the trigger element.

import { type Ref } from "vue";

import { useDomEvent } from "./useDomEvent";

export type UseMenuHoverOptions = {
  timeoutDuration: number;
};

export function useMenuHover(
  toggleState: Ref<boolean>,
  triggerEl: Ref<HTMLElement | undefined>,
  contentEl: Ref<HTMLElement | undefined>,
  options: Partial<UseMenuHoverOptions> = {
    timeoutDuration: 200,
  }
) {
  let triggerMouseLeaveTimeoutId: ReturnType<typeof setTimeout> | undefined =
    undefined;
  let contentMouseLeaveTimeoutId: ReturnType<typeof setTimeout> | undefined =
    undefined;

  function onTriggerMouseEnter() {
    if (!toggleState.value) {
      toggleState.value = true;
    } else {
      if (contentMouseLeaveTimeoutId) {
        clearTimeout(contentMouseLeaveTimeoutId);
        contentMouseLeaveTimeoutId = undefined;
      }
    }
  }

  function onTriggerMouseLeave() {
    triggerMouseLeaveTimeoutId = setTimeout(() => {
      if (toggleState.value) {
        toggleState.value = false;
        triggerMouseLeaveTimeoutId = undefined;
      }
    }, options.timeoutDuration);
  }

  function onContentMouseEnter() {
    if (triggerMouseLeaveTimeoutId) {
      clearTimeout(triggerMouseLeaveTimeoutId);
      triggerMouseLeaveTimeoutId = undefined;
    }
  }

  function onContentMouseLeave() {
    contentMouseLeaveTimeoutId = setTimeout(() => {
      if (toggleState.value) {
        toggleState.value = false;
        contentMouseLeaveTimeoutId = undefined;
      }
    }, options.timeoutDuration);
  }

  useDomEvent(triggerEl, "mouseenter", onTriggerMouseEnter);

  useDomEvent(triggerEl, "mouseleave", onTriggerMouseLeave);

  useDomEvent(contentEl, "mouseenter", onContentMouseEnter);

  useDomEvent(contentEl, "mouseleave", onContentMouseLeave);
}

useDomEvent composable

This is a low-level helper method to register the DOM event when it is mounted and unregister the handler when it is unmounted.

import { type Ref, onUnmounted, watch } from "vue";

export function useDomEvent(
  element: Ref<HTMLElement | undefined>,
  eventName: string,
  callback: EventListenerOrEventListenerObject
) {
  let savedElement: HTMLElement | undefined = undefined;

  watch(element, (newElement, oldElement) => {
    if (newElement) {
      newElement.addEventListener(eventName, callback);
      savedElement = newElement;
    }
    if (oldElement) {
      oldElement.removeEventListener(eventName, callback);
    }
  });

  onUnmounted(() => {
    if (savedElement) {
      savedElement.removeEventListener(eventName, callback);
    }
  });
}

Demo

Try hovering the “MENU” button and also checking the “Right Side Menu” toggle to see how the menu behaves.

Conclusion

Customizing the behavior for radix-vue in my opinion doesn’t seem straightforward. Requires a little play around with. I foresee this requirement of triggering via hover will be quite common in my scope of work, so I just want to make the effort to jot it down so I can reference it in the future.