From 00967f36ab5d3e46721c61cf6935c10156d1dd0d Mon Sep 17 00:00:00 2001 From: nishupr Date: Wed, 27 May 2026 16:11:31 +0530 Subject: [PATCH] feat(cursor): add custom animated cursor for landing page --- src/app/layout.tsx | 2 + src/components/CustomCursor.tsx | 149 ++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/components/CustomCursor.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 267e879a..d20c5948 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import CustomCursor from "@/components/CustomCursor"; import type { Metadata, Viewport } from "next"; import { Inter, Syne, JetBrains_Mono } from "next/font/google"; import Footer from "@/components/Footer"; @@ -85,6 +86,7 @@ export default async function RootLayout({ +
diff --git a/src/components/CustomCursor.tsx b/src/components/CustomCursor.tsx new file mode 100644 index 00000000..7057e975 --- /dev/null +++ b/src/components/CustomCursor.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const DOT_SIZE = 8; +const RING_SIZE = 36; +const CLICK_SCALE = 0.6; +const HOVER_RING_SCALE = 1.6; + +export default function CustomCursor() { + const dotRef = useRef(null); + const ringRef = useRef(null); + + const mouse = useRef({ x: -100, y: -100 }); + const ring = useRef({ x: -100, y: -100 }); + const rafId = useRef(0); + + const [visible, setVisible] = useState(false); + const [clicking, setClicking] = useState(false); + const [hovering, setHovering] = useState(false); + + useEffect(() => { + if (window.matchMedia("(pointer: coarse)").matches) return; + + document.documentElement.style.cursor = "none"; + + const isInteractive = (el: Element | null): boolean => { + if (!el) return false; + const tag = (el as HTMLElement).tagName.toLowerCase(); + if (["a", "button", "input", "select", "textarea", "label"].includes(tag)) return true; + if ((el as HTMLElement).getAttribute("role") === "button") return true; + if ((el as HTMLElement).style.cursor === "pointer") return true; + return isInteractive(el.parentElement); + }; + + const onMove = (e: MouseEvent) => { + mouse.current = { x: e.clientX, y: e.clientY }; + if (!visible) setVisible(true); + }; + + const onEnter = () => setVisible(true); + const onLeave = () => setVisible(false); + const onDown = () => setClicking(true); + const onUp = () => setClicking(false); + const onOver = (e: MouseEvent) => setHovering(isInteractive(e.target as Element)); + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseenter", onEnter); + document.addEventListener("mouseleave", onLeave); + document.addEventListener("mousedown", onDown); + document.addEventListener("mouseup", onUp); + document.addEventListener("mouseover", onOver); + + const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + const LERP_FACTOR = 0.12; + + const tick = () => { + ring.current.x = lerp(ring.current.x, mouse.current.x, LERP_FACTOR); + ring.current.y = lerp(ring.current.y, mouse.current.y, LERP_FACTOR); + + if (dotRef.current) { + dotRef.current.style.transform = + `translate(${mouse.current.x - DOT_SIZE / 2}px, ${mouse.current.y - DOT_SIZE / 2}px)`; + } + if (ringRef.current) { + ringRef.current.style.transform = + `translate(${ring.current.x - RING_SIZE / 2}px, ${ring.current.y - RING_SIZE / 2}px)`; + } + + rafId.current = requestAnimationFrame(tick); + }; + + rafId.current = requestAnimationFrame(tick); + + return () => { + document.documentElement.style.cursor = ""; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseenter", onEnter); + document.removeEventListener("mouseleave", onLeave); + document.removeEventListener("mousedown", onDown); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("mouseover", onOver); + cancelAnimationFrame(rafId.current); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const base: React.CSSProperties = { + position: "fixed", + top: 0, + left: 0, + pointerEvents: "none", + zIndex: 9999, + willChange: "transform", + }; + + const dotStyle: React.CSSProperties = { + ...base, + width: clicking ? DOT_SIZE * CLICK_SCALE : DOT_SIZE, + height: clicking ? DOT_SIZE * CLICK_SCALE : DOT_SIZE, + marginLeft: clicking ? (DOT_SIZE - DOT_SIZE * CLICK_SCALE) / 2 : 0, + marginTop: clicking ? (DOT_SIZE - DOT_SIZE * CLICK_SCALE) / 2 : 0, + borderRadius: "50%", + background: "radial-gradient(circle, #a78bfa, #7c3aed)", + boxShadow: clicking + ? "0 0 6px 2px #a78bfa80" + : "0 0 12px 4px #7c3aedaa, 0 0 24px 8px #7c3aed44", + opacity: visible ? 1 : 0, + transition: "opacity 0.2s ease, box-shadow 0.15s ease, width 0.1s ease, height 0.1s ease", + }; + + const ringStyle: React.CSSProperties = { + ...base, + width: hovering ? RING_SIZE * HOVER_RING_SCALE : RING_SIZE, + height: hovering ? RING_SIZE * HOVER_RING_SCALE : RING_SIZE, + marginLeft: hovering ? -(RING_SIZE * (HOVER_RING_SCALE - 1)) / 2 : 0, + marginTop: hovering ? -(RING_SIZE * (HOVER_RING_SCALE - 1)) / 2 : 0, + borderRadius: "50%", + border: hovering ? "1.5px solid #a78bfa" : "1.5px solid #6d28d9aa", + background: hovering ? "#7c3aed18" : "transparent", + boxShadow: clicking && hovering + ? "0 0 0 4px #7c3aed22, inset 0 0 12px #7c3aed18" + : "none", + opacity: visible ? 1 : 0, + transition: [ + "opacity 0.2s ease", + "border-color 0.2s ease", + "background 0.2s ease", + "width 0.2s ease", + "height 0.2s ease", + "margin 0.2s ease", + ].join(", "), + }; + + return ( + <> + +