Vision UI

Cursor

Animated cursor for Vision UI

"use client";

import {
  animate,
  frame,
  motion,
  useMotionTemplate,
  useMotionValue,
  useSpring,
  useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";

const CONSTANTS = {
  TEXT_ELEMENT_TAGS: ["P", "SPAN", "H1", "H2", "H3", "H4", "TEXTAREA"],
  HOVER_ELEMENT_TAGS: ["BUTTON", "A", "LABEL", "SELECT"],
  HOVERABLE_CLASSNAME: "vision-pro-ui-hoverable", // No translate X or Y
  CURSOR_SPRING_CONFIG: { stiffness: 90, damping: 2, mass: 0.01 },
  DEFAULT_SPRING_CONFIG: { stiffness: 90, damping: 8, mass: 0.2 },
  CLICK_SPRING_CONFIG: { duration: 0.12 },
  DEFAULT_CURSOR_SIZE: 20,
  CURSOR_BORDER_RADIUS: 10,
  CURSOR_BLUR: 4,
  DEFAULT_CURSOR_OPACITY: 0.5,
  TEXT_CURSOR_OPACITY: 0.9,
  ACTIVE_CURSOR_OPACITY: 0.3,
  HOVER_EFFECT_X_MULTIPLIER: 1,
  HOVER_EFFECT_Y_MULTIPLIER: 4,
  SHINE_SIZE: 150,
  SHINE_OPACITY: 0.65,
  CLICK_ELEMENT_SCALE: 0.95,
  CLICK_CURSOR_SCALE: 0.9,
};

const CursorInner = () => {
  const cursorRef = useRef(null);
  const isCursorLockedRef = useRef(false);

  const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);

  const cursorXSpring = useSpring(0, CONSTANTS.CURSOR_SPRING_CONFIG);
  const cursorYSpring = useSpring(0, CONSTANTS.CURSOR_SPRING_CONFIG);
  const cursorWidthSpring = useSpring(
    CONSTANTS.DEFAULT_CURSOR_SIZE,
    CONSTANTS.CURSOR_SPRING_CONFIG
  );
  const cursorHeightSpring = useSpring(
    CONSTANTS.DEFAULT_CURSOR_SIZE,
    CONSTANTS.CURSOR_SPRING_CONFIG
  );
  const cursorOpacitySpring = useSpring(
    CONSTANTS.DEFAULT_CURSOR_OPACITY,
    CONSTANTS.CURSOR_SPRING_CONFIG
  );
  const cursorScaleSpring = useSpring(1, CONSTANTS.DEFAULT_SPRING_CONFIG);
  const cursorBorderRadiusSpring = useSpring(CONSTANTS.CURSOR_BORDER_RADIUS, {
    bounce: 0,
  });
  const cursorBlurSpring = useSpring(CONSTANTS.CURSOR_BLUR, CONSTANTS.DEFAULT_SPRING_CONFIG);

  const translateX = useMotionValue(0);
  const translateY = useMotionValue(0);

  const shineX = useSpring(0, CONSTANTS.DEFAULT_SPRING_CONFIG);
  const shineY = useSpring(0, CONSTANTS.DEFAULT_SPRING_CONFIG);

  const cursorBackgroundOpacity = useSpring(1, { bounce: 0 });

  const cursorBlurSpringString = useMotionTemplate`blur(${cursorBlurSpring}px)`;
  const cursorBackgroundColorString = useMotionTemplate`rgba(128, 128, 128, ${cursorBackgroundOpacity})`;

  const cursorLeft = useTransform<number, number>(
    [cursorXSpring, cursorWidthSpring],
    ([x, width]) => x - width / 2
  );
  const cursorTop = useTransform<number, number>(
    [cursorYSpring, cursorHeightSpring],
    ([y, height]) => y - height / 2
  );

  const handleElementMouseMove = (event: MouseEvent) => {
    if (!hoveredElement) return;
    if (CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
      const rect = hoveredElement.getBoundingClientRect();
      const halfHeight = rect.height / 2;
      const topOffset = (event.clientY - rect.top - halfHeight) / halfHeight;
      const halfWidth = rect.width / 2;
      const leftOffset = (event.clientX - rect.left - halfWidth) / halfWidth;
      translateX.set(leftOffset * CONSTANTS.HOVER_EFFECT_X_MULTIPLIER * 2);
      translateY.set(topOffset * CONSTANTS.HOVER_EFFECT_Y_MULTIPLIER * 0.5);

      shineX.set(event.clientX - rect.left - CONSTANTS.SHINE_SIZE / 2);
      shineY.set(event.clientY - rect.top - CONSTANTS.SHINE_SIZE / 2);

      animate(
        hoveredElement,
        {
          x: leftOffset * CONSTANTS.HOVER_EFFECT_X_MULTIPLIER * 4,
          y: topOffset * CONSTANTS.HOVER_EFFECT_Y_MULTIPLIER,
          "--btn-bg-translate-x": `${-leftOffset * CONSTANTS.HOVER_EFFECT_X_MULTIPLIER * 2}px`,
          "--btn-bg-translate-y": `${-topOffset * CONSTANTS.HOVER_EFFECT_Y_MULTIPLIER * 0.5}px`,
        },
        { type: "keyframes", duration: 0 }
      );
    } else if (
      hoveredElement.className.length &&
      hoveredElement.className
        .split(" ")
        .some((className) => className === CONSTANTS.HOVERABLE_CLASSNAME)
    ) {
      const rect = hoveredElement.getBoundingClientRect();
      shineX.set(event.clientX - rect.left - CONSTANTS.SHINE_SIZE / 2);
      shineY.set(event.clientY - rect.top - CONSTANTS.SHINE_SIZE / 2);
    }
  };

  const handleElementMouseLeave = () => {
    if (!hoveredElement) return;
    cursorBorderRadiusSpring.set(CONSTANTS.CURSOR_BORDER_RADIUS);
    cursorOpacitySpring.set(CONSTANTS.DEFAULT_CURSOR_OPACITY);
    cursorBlurSpring.set(0);
    cursorBackgroundOpacity.set(1);

    if (CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
      cursorWidthSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE);
      cursorHeightSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE);
      translateX.set(0);
      translateY.set(0);
    }
  };

  const handleElementMouseDown = () => {
    if (hoveredElement && CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
      animate(
        hoveredElement,
        { scale: CONSTANTS.CLICK_ELEMENT_SCALE },
        CONSTANTS.CLICK_SPRING_CONFIG
      );
      cursorScaleSpring.set(CONSTANTS.CLICK_CURSOR_SCALE);
    }
  };

  const handleElementMouseUp = () => {
    if (hoveredElement && CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
      animate(hoveredElement, { scale: 1 }, CONSTANTS.CLICK_SPRING_CONFIG);
      cursorScaleSpring.set(1);
    }
  };

  const handleMouseMove = (e: MouseEvent) => {
    if (!isCursorLockedRef.current) {
      cursorXSpring.set(e.clientX);
      cursorYSpring.set(e.clientY);
    }

    const element = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement;

    if (hoveredElement !== element) {
      // disable if data-cursor-disabled is true
      if (element.getAttribute("data-cursor-disabled")) return;
      setHoveredElement(element);

      if (
        CONSTANTS.HOVER_ELEMENT_TAGS.includes(element.tagName) ||
        (element.className.length &&
          element.className
            .split(" ")
            .some((className) => className === CONSTANTS.HOVERABLE_CLASSNAME))
      ) {
        isCursorLockedRef.current = true;
        let rect: DOMRect;
        frame.read(() => {
          rect = element.getBoundingClientRect();
          cursorXSpring.set(rect.left + rect.width / 2);
          cursorYSpring.set(rect.top + rect.height / 2);
        });

        frame.update(() => {
          cursorWidthSpring.set((rect?.width ?? 4) - 4);
          cursorHeightSpring.set((rect?.height ?? 4) - 4);
          cursorBorderRadiusSpring.set(
            parseInt(window.getComputedStyle(element).borderRadius, 10) - 2
          );
          cursorOpacitySpring.set(CONSTANTS.ACTIVE_CURSOR_OPACITY);
          cursorBlurSpring.set(CONSTANTS.CURSOR_BLUR);
          cursorBackgroundOpacity.set(0);
        });
      } else {
        // Reset logic for non-hoverable elements
        isCursorLockedRef.current = false;
        cursorWidthSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE);
        cursorHeightSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE);
        cursorBorderRadiusSpring.set(CONSTANTS.CURSOR_BORDER_RADIUS);
        cursorOpacitySpring.set(CONSTANTS.DEFAULT_CURSOR_OPACITY);
        cursorBlurSpring.set(0);
        cursorBackgroundOpacity.set(1);
      }
    }
  };
  useEffect(() => {
    document.addEventListener("mousemove", handleMouseMove);
    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
    };
  }, []);

  const cleanup = () => {
    if (hoveredElement) {
      hoveredElement.removeEventListener("mousemove", handleElementMouseMove);
      hoveredElement.removeEventListener("mouseleave", handleElementMouseLeave);
      hoveredElement.removeEventListener("mousedown", handleElementMouseDown);
      hoveredElement.removeEventListener("mouseup", handleElementMouseUp);

      animate(
        hoveredElement,
        {
          x: 0,
          y: 0,
          "--btn-bg-translate-x": `${0}px`,
          "--btn-bg-translate-y": `${0}px`,
        },
        { type: "spring", duration: 0.4 }
      );
    }
  };

  useEffect(() => {
    if (hoveredElement) {
      hoveredElement.addEventListener("mousemove", handleElementMouseMove);
      hoveredElement.addEventListener("mouseleave", handleElementMouseLeave);
      hoveredElement.addEventListener("mousedown", handleElementMouseDown);
      hoveredElement.addEventListener("mouseup", handleElementMouseUp);
    }

    return cleanup;
  }, [hoveredElement]);

  return (
    <motion.div
      ref={cursorRef}
      className="pointer-events-none fixed z-[999] overflow-hidden"
      style={{
        left: cursorLeft,
        top: cursorTop,
        width: cursorWidthSpring,
        height: cursorHeightSpring,
        opacity: cursorOpacitySpring,
        backgroundColor: cursorBackgroundColorString,
        borderRadius: cursorBorderRadiusSpring,
        scale: cursorScaleSpring,
        translateX: translateX,
        translateY: translateY,
        filter: cursorBlurSpringString,
      }}
    >
      <motion.div
        className="absolute"
        style={{
          width: CONSTANTS.SHINE_SIZE,
          height: CONSTANTS.SHINE_SIZE,
          borderRadius: "50%",
          opacity: CONSTANTS.SHINE_OPACITY,
          left: shineX,
          top: shineY,
          background:
            "radial-gradient(circle, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 75%), radial-gradient(101.08% 100% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(100.02% 100% at 50% 100%, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.00) 55.59%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.1)",
          // @ts-expect-error mix-blend-mode is a valid type
          mixBlendMode: "color-dodge, normal, color-dodge, lighten",
        }}
      />
    </motion.div>
  );
};

type CursorProps = {
  /**
   * Whether the cursor should be visible in all environments.
   *
   * This overrides the default behavior of checking if the device is a touch device.
   * @default false
   */
  visibleOnTouch?: boolean;
};

const Cursor = ({ visibleOnTouch = false }: CursorProps) => {
  const [isTouchDevice, setIsTouchDevice] = useState(false);

  const checkIfTouchDevice = () => {
    const isTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
    setIsTouchDevice(isTouch);
  };

  useEffect(() => {
    checkIfTouchDevice();
    window.addEventListener("resize", checkIfTouchDevice);

    return () => {
      window.removeEventListener("resize", checkIfTouchDevice);
    };
  }, []);

  return !isTouchDevice || visibleOnTouch ? <CursorInner /> : null;
};

export { Cursor };
export type { CursorProps };

Mouse only

The Cursor component is currently only supported on non-touch devices.

The Cursor Component is a custom, interactive cursor implementation using React and Framer Motion. It provides a dynamic cursor that responds to user interactions and changes appearance based on the elements it hovers over.

With ButtonGroup

import { ALargeSmall, ListOrdered, Redo2, Undo2 } from "lucide-react";
import { Button, ButtonGroup } from "@/components/core/button";
import { Window } from "@/components/core/window";
import { Separator } from "@/components/ui/separator";

export const WithButtonGroup = () => {
  return (
    <Window>
      <ButtonGroup>
        <Button variant="secondary" size="icon">
          <Undo2 data-slot="icon" />
        </Button>
        <Button variant="secondary" size="icon">
          <Redo2 data-slot="icon" />
        </Button>
        <Separator orientation="vertical" className="h-8" />
        <Button variant="secondary" size="icon">
          <ALargeSmall data-slot="icon" />
        </Button>
        <Button variant="secondary" size="icon">
          <ListOrdered data-slot="icon" />
        </Button>
      </ButtonGroup>
    </Window>
  );
};

Component Structure

The component is structured into two main parts:

  1. CursorInner: The core functionality of the custom cursor.
  2. Cursor: A wrapper component that checks if the device is touch-enabled and renders CursorInner accordingly.

The Cursor component will automatically handle hiding the default cursor and showing the custom cursor on non-touch devices.

Performance Optimizations

  • Use of Framer Motion's frame API to batch DOM reads, updates, and renders, preventing layout thrashing.
  • Efficient event handling with checks to prevent unnecessary updates.
  • Use of springs and motion values for smooth, hardware-accelerated animations.

Usage

To use the Cursor component in your React application:

import { Cursor } from "@/components/core/cursor";

function App() {
  return (
    <div>
      {/* Your app content */}
      <Cursor />
    </div>
  );
}

API

PropTypeDefault
visibleOnTouch?
boolean
false
Edit on GitHub

Last updated on