AnimationsIntermediateApril 27, 2026
Cursor Hover Label
A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.
View Full Demo →Preview
View
Source
demo.jsx
import CursorHoverLabel from "./index.jsx";
import { cursorHoverLabel } from "@/content/animations/demo-data.js";
import styles from "./demo.module.css";
export default function CursorHoverLabelDemo() {
const { projects } = cursorHoverLabel;
return (
<CursorHoverLabel label="View">
<div className={styles.demo}>
<header className={styles.header}>
<span className={styles.logo}>Studio</span>
<nav className={styles.nav}>
<span>Work</span>
<span>About</span>
<span>Contact</span>
</nav>
</header>
<div className={styles.hero}>
<p className={styles.eyebrow}>Selected Projects</p>
<h1 className={styles.heading}>Work</h1>
</div>
<div className={styles.grid}>
{projects.map((project) => (
<a
key={project.title}
href="#"
className={styles.card}
data-cursor-label={project.title}
>
<div className={styles.imageWrap}>
<img
src={project.image}
alt=""
className={styles.image}
/>
</div>
<div className={styles.cardInfo}>
<span className={styles.cardCategory}>{project.category}</span>
<h3 className={styles.cardTitle}>{project.title}</h3>
</div>
</a>
))}
</div>
</div>
</CursorHoverLabel>
);
}
index.jsx
"use client";
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import styles from "./styles.module.css";
export default function CursorHoverLabel({ children, label = "View" }) {
const containerRef = useRef(null);
const cursorRef = useRef(null);
const pos = useRef({ x: -200, y: -200 });
const [visible, setVisible] = useState(false);
const [activeLabel, setActiveLabel] = useState(label);
useEffect(() => {
const container = containerRef.current;
const cursor = cursorRef.current;
if (!container || !cursor) return;
const xTo = gsap.quickTo(cursor, "x", { duration: 0.4, ease: "power3" });
const yTo = gsap.quickTo(cursor, "y", { duration: 0.4, ease: "power3" });
const onMove = (e) => {
pos.current = { x: e.clientX, y: e.clientY };
xTo(e.clientX);
yTo(e.clientY);
};
const onEnterTrigger = (e) => {
const triggerLabel = e.currentTarget.dataset.cursorLabel;
if (triggerLabel) setActiveLabel(triggerLabel);
setVisible(true);
};
const onLeaveTrigger = () => {
setVisible(false);
};
container.addEventListener("mousemove", onMove);
const triggers = container.querySelectorAll("[data-cursor-label]");
triggers.forEach((el) => {
el.addEventListener("mouseenter", onEnterTrigger);
el.addEventListener("mouseleave", onLeaveTrigger);
});
return () => {
container.removeEventListener("mousemove", onMove);
triggers.forEach((el) => {
el.removeEventListener("mouseenter", onEnterTrigger);
el.removeEventListener("mouseleave", onLeaveTrigger);
});
};
}, []);
return (
<div ref={containerRef} className={styles.container}>
{children}
<div
ref={cursorRef}
className={`${styles.cursor} ${visible ? styles.cursorVisible : ""}`}
>
<span className={styles.cursorText}>{activeLabel}</span>
</div>
</div>
);
}
demo.module.css
.demo {
min-height: 100vh;
background: #0a0a0a;
color: #fafafa;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 3rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.logo {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.nav {
display: flex;
gap: 2rem;
font-size: 0.8125rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
}
/* ── Hero ── */
.hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 6rem 2rem 4rem;
gap: 1rem;
}
.eyebrow {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
}
.heading {
font-size: clamp(4rem, 12vw, 10rem);
font-weight: 500;
letter-spacing: -0.04em;
line-height: 0.9;
}
/* ── Project Grid ── */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
padding: 0 3rem 4rem;
}
.card {
display: block;
text-decoration: none;
color: inherit;
cursor: none;
overflow: hidden;
}
.imageWrap {
overflow: hidden;
aspect-ratio: 3 / 2;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.card:hover .image {
transform: scale(1.04);
}
.cardInfo {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 1rem 0 2rem;
}
.cardCategory {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
}
.cardTitle {
font-size: 1.125rem;
font-weight: 400;
letter-spacing: -0.01em;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
padding: 0 1.5rem 3rem;
}
.header {
padding: 1.25rem 1.5rem;
}
.hero {
padding: 4rem 1.5rem 3rem;
}
}
styles.module.css
.container {
position: relative;
cursor: default;
}
/* ── Floating cursor label ── */
.cursor {
pointer-events: none;
position: fixed;
top: 0;
left: 0;
z-index: 1000;
padding: 4px 8px 5px;
border: 1px solid #2a2a2a;
background-color: #0f0f0f;
color: #fafafa;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1;
white-space: nowrap;
opacity: 0;
transform: translate(-50%, -50%);
transition: opacity 0.3s linear;
}
.cursorVisible {
opacity: 1;
}
.cursorText {
display: block;
}
Dependencies
gsap