useCardHover
-
Este composable proporciona un efecto visual interactivo tipo “blob” que sigue el puntero del mouse al pasar sobre elementos tipo tarjetas.
-
Está diseñado para usarse en interfaces donde se desea destacar dinámicamente elementos al hacer hover .
Implementación del Composable:
Section titled “Implementación del Composable:”export function useCardHover(cardSelector: string, itemsSource?: Ref<unknown[]>) { // Umbral de distancia a partir del cual el blob deja de ser visible const DISTANCE_THRESHOLD = 350 // Clases CSS de los elementos blob const BLOB_CLASS_NAME = '.blob' const FAKEBLOB_CLASS_NAME = '.fakeblob'
// Referencia a los elementos que coinciden con el selector const allCards = ref<NodeListOf<HTMLElement> | null>(null) // ID del frame de animación para evitar múltiples llamadas a `requestAnimationFrame` let animationFrameId: number | null = null // Evento de mouse más reciente, usado para calcular la animación let latestMouseEvent: MouseEvent | null = null
// Función para actualizar la lista de tarjetas. Puede esperar un `nextTick()` si es necesario. const updateCards = async (waitNextTick = false) => { if (waitNextTick) { await nextTick() } allCards.value = document.querySelectorAll(cardSelector) }
// Manejador de movimiento del mouse const handleMouseMove = (ev: MouseEvent) => { latestMouseEvent = ev // Evita múltiples ejecuciones si ya hay un frame de animación pendiente if (animationFrameId !== null) return
animationFrameId = requestAnimationFrame(() => { animationFrameId = null if (!allCards.value || !latestMouseEvent) return
const ev = latestMouseEvent
allCards.value.forEach((card) => { const rect = card.getBoundingClientRect() const cardCenterX = rect.left + rect.width / 2 const cardCenterY = rect.top + rect.height / 2
const deltaX = ev.clientX - cardCenterX const deltaY = ev.clientY - cardCenterY const distanceToCardCenter = Math.hypot(deltaX, deltaY)
// Si el cursor está muy lejos, oculta el blob if (distanceToCardCenter > DISTANCE_THRESHOLD) { const blob = card.querySelector(BLOB_CLASS_NAME) as HTMLElement | null if (blob) blob.style.opacity = '0' return }
// Si está cerca, muestra y mueve el blob con animación const blob = card.querySelector(BLOB_CLASS_NAME) as HTMLElement | null const fakeBlob = card.querySelector(FAKEBLOB_CLASS_NAME) as HTMLElement | null if (!blob || !fakeBlob) return
const offsetX = ev.clientX - rect.left - rect.width / 2 const offsetY = ev.clientY - rect.top - rect.height / 2
blob.style.opacity = '1' blob.animate([{ transform: `translate(${offsetX}px, ${offsetY}px)` }], { duration: 300, fill: 'forwards', }) }) }) }
// Manejador para ocultar el blob cuando el mouse sale del card const handleMouseLeave = (card: HTMLElement) => { const blob = card.querySelector(BLOB_CLASS_NAME) as HTMLElement | null if (blob) blob.style.opacity = '0' }
// Añade los eventos a las tarjetas const setupEvents = () => { updateCards() if (!allCards.value) return
window.addEventListener('mousemove', handleMouseMove)
allCards.value.forEach((card) => { card.addEventListener('mouseleave', () => handleMouseLeave(card)) }) }
// Elimina los eventos globales const removeEvents = () => { window.removeEventListener('mousemove', handleMouseMove) }
// Configura todo al montar el componente onMounted(async () => { await updateCards(true) setupEvents() })
// Limpia todo al desmontar el componente onUnmounted(() => { removeEvents() if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId) } })
// Si se proporciona una fuente reactiva de ítems, reconfigura los eventos al cambiar if (itemsSource) { watch(itemsSource, async () => { removeEvents() await updateCards() setupEvents() }) }
// No retorna nada por ahora, podría devolver métodos de control si fuera necesario return {}}Ejemplo de uso:
Section titled “Ejemplo de uso:”const CARDS_CLASS_NAME = ".expert-card"; // nombre de la clase que engloba la card.useCardHover(CARDS_CLASS_NAME, searchedItems); // pasas la clase y el array de items// el segundo argumento no es necesario si no hay un BUSCADOR en la pagina,// si no varía el array del items dinamicamente, no hace falta pasarlo.Componente que renderiza un item
<template> <section v-if="slug" class="expert-card"> <div class="blob"></div> <div class="fakeblob"></div> <section class="expert-card__content"><p>hola soy una carta</p></section> </section></template>
<style lang="scss" scoped>.expert-card { position: relative; // * border-radius: 0.75em; padding: 0.125rem; // *
overflow: hidden; // * transition: var(--t-transition-button);
@include flex(column); // *
&__content { background-color: var(--c-black); // * border-radius: 0.55em; overflow: hidden; // * z-index: 1; // * height: 100%; width: 100%; }
&__name { font-size: var(--s-font-p); font-family: var(-f-font-medium); }}
.blob { @include blob(); // *}
.fakeblob { @include fakeblob(); // *}// * = estilos importantes para la funcionalidad de useCardHover</style>