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 →
View
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;
}
  • gsap

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.

Apr 9, 2026

ANIMATIONS

Cascading Slider

GSAP-driven carousel that positions slides across 7 slots (active, left/right siblings, far left/right, and two hidden staging slots). Slides clip-path into view as they move, with the active slide title fading in after the transition completes. Keyboard and click navigable.