Cursor Highlight

PreviousNext

Text is revealed on scroll with pointer, gradient, and border effects.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/cursor-highlight.tsx
"use client";

import React, { useRef, useEffect, useState } from "react";
import { motion, useInView } from "framer-motion";
import { cn } from "@/lib/utils";

interface CursorHighlightProps {
  text?: string;
  children?: React.ReactNode;
  className?: string;
  gradient?: string;
  direction?: "left" | "right" | "top" | "bottom";
  animate?: boolean;
  duration?: number;
  showPointer?: boolean;
  pointerDuration?: number;
  pointerClassName?: string;
  containerClassName?: string;
  rectangle?: boolean | string;
}

const Pointer = (props: React.SVGProps<SVGSVGElement>) => (
  <svg
    stroke="currentColor"
    fill="currentColor"
    strokeWidth="1"
    strokeLinecap="round"
    strokeLinejoin="round"
    viewBox="0 0 16 16"
    height="1em"
    width="1em"
    xmlns="http://www.w3.org/2000/svg"
    {...props}
  >
    <path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z"></path>
  </svg>
);

interface PointerAnim {
  initial: { x: number; y: number; rotate: number };
  animate: { x: number; y: number; rotate: number };
}

export function CursorHighlight({
  text,
  children,
  className,
  gradient = "from-purple-800 via-purple-600 to-purple-800",
  direction = "left",
  animate = false,
  duration = 2,
  showPointer = false,
  pointerDuration = 1,
  pointerClassName,
  containerClassName,
  rectangle = false,
}: CursorHighlightProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const isInView = useInView(containerRef, { amount: 0.3, once: false });
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const [resetKey, setResetKey] = useState(0);

  useEffect(() => {
    const element = containerRef.current;
    if (element) {
      const { width, height } = element.getBoundingClientRect();
      setDimensions({ width, height });
    }

    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        setDimensions({ width, height });
      }
    });

    if (element) resizeObserver.observe(element);
    return () => {
      if (element) resizeObserver.unobserve(element);
    };
  }, [text, children]);

  useEffect(() => {
    if (!isInView) {
      const timeout = setTimeout(() => setResetKey((k) => k + 1), 200);
      return () => clearTimeout(timeout);
    }
  }, [isInView]);

  const content = text || children;
  const hasCustomTextColor =
    className &&
    (className.includes("text-") &&
      (className.includes("dark:text-") || !className.includes("dark:")));

  const defaultTextColors = hasCustomTextColor
    ? ""
    : "text-black dark:text-white";

  const getPointerAnimation = (): PointerAnim => {
    const startOffset = 15;
    const endOffset = 5;
    const yPos = dimensions.height / 2;

    switch (direction) {
      case "left":
        return {
          initial: { x: -startOffset, y: yPos, rotate: 275 },
          animate: { x: dimensions.width + endOffset, y: yPos, rotate: 275 },
        };
      case "right":
        return {
          initial: { x: dimensions.width + startOffset, y: yPos, rotate: 5 },
          animate: { x: -20, y: yPos, rotate: 5 },
        };
      case "top":
        return {
          initial: { x: -startOffset, y: yPos, rotate: 275 },
          animate: { x: dimensions.width + endOffset, y: yPos, rotate: 275 },
        };
      case "bottom":
        return {
          initial: { x: -startOffset, y: yPos, rotate: 275 },
          animate: { x: dimensions.width + endOffset, y: yPos, rotate: 275 },
        };
      default:
        return {
          initial: { x: -startOffset, y: yPos, rotate: 275 },
          animate: { x: dimensions.width + endOffset, y: yPos, rotate: 275 },
        };
    }
  };

  const delay = showPointer ? pointerDuration + 0.2 : 0.2;

  const directionStyles: Record<
    "left" | "right" | "top" | "bottom",
    {
      initial: { clipPath: string };
      animate: { clipPath: string };
      transition: { duration: number; ease: "easeInOut"; delay: number };
    }
  > = {
    left: {
      initial: { clipPath: "inset(0 100% 0 0)" },
      animate: {
        clipPath: isInView ? "inset(0 0% 0 0)" : "inset(0 100% 0 0)",
      },
      transition: { duration, ease: "easeInOut", delay },
    },
    right: {
      initial: { clipPath: "inset(0 0 0 100%)" },
      animate: {
        clipPath: isInView ? "inset(0 0 0 0%)" : "inset(0 0 0 100%)" },
      transition: { duration, ease: "easeInOut", delay },
    },
    top: {
      initial: { clipPath: "inset(100% 0 0 0)" },
      animate: {
        clipPath: isInView ? "inset(0% 0 0 0)" : "inset(100% 0 0 0)",
      },
      transition: { duration, ease: "easeInOut", delay },
    },
    bottom: {
      initial: { clipPath: "inset(0 0 100% 0)" },
      animate: {
        clipPath: isInView ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
      },
      transition: { duration, ease: "easeInOut", delay },
    },
  };

  const pointerAnimation = getPointerAnimation();
  const gradientClass = `bg-gradient-to-r ${gradient}`;

  return (
    <div ref={containerRef} className={cn("relative inline-block", containerClassName)}>
      {rectangle && dimensions.width > 0 && dimensions.height > 0 && (
        <motion.div
          className={cn(
            "absolute z-10 pointer-events-none",
            "border border-neutral-800 dark:border-neutral-200 bg-transparent",
            typeof rectangle === "string" ? rectangle : ""
          )}
          style={{
            left: direction === "right" ? "auto" : 0,
            right: direction === "right" ? 0 : "auto",
          }}
          initial={{ width: 0, height: dimensions.height }}
          animate={
            isInView
              ? { width: dimensions.width, height: dimensions.height }
              : { width: 0, height: dimensions.height }
          }
          transition={{ duration: pointerDuration, ease: "easeInOut" }}
        />
      )}

      <div className="relative z-40">
        <span className={cn(defaultTextColors, "relative z-20", className)}>
          {content}
        </span>

        <motion.span
          key={resetKey}
          className={cn(
            "absolute inset-0 bg-clip-text text-transparent z-30",
            gradientClass,
            className
          )}
          initial={directionStyles[direction].initial}
          animate={directionStyles[direction].animate}
          transition={directionStyles[direction].transition}
        >
          {content}
        </motion.span>
      </div>

      {showPointer && dimensions.width > 0 && dimensions.height > 0 && (
        <motion.div
          className="pointer-events-none absolute z-40"
          initial={{ opacity: 0, ...pointerAnimation.initial }}
          animate={
            isInView
              ? { opacity: 1, ...pointerAnimation.animate }
              : { opacity: 0 }
          }
          transition={{
            duration: pointerDuration,
            ease: "easeInOut",
            opacity: { duration: 0.2, ease: "easeInOut" },
          }}
        >
          <Pointer className={cn("h-4 w-4 text-blue-500", pointerClassName)} />
        </motion.div>
      )}
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/cursor-highlight

Usage

import { CursorHighlight } from "@/components/cursor-highlight"
<CursorHighlight />