Skip to content

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 .

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 {}
}
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>