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.