UNPKG

@yhattav/react-component-cursor

Version:

A lightweight, TypeScript-first React library for creating beautiful custom cursors with SSR support, smooth animations, and zero dependencies. Perfect for interactive websites, games, and creative applications.

527 lines (521 loc) 18.6 kB
import { __spreadValues, browserOnly, isBrowser, isMobileDevice, isSSR, safeDocument, safeWindow } from "./chunk-YMONBIUG.dev.mjs"; // src/CustomCursor.tsx import * as React2 from "react"; import { createPortal } from "react-dom"; // src/hooks/useMousePosition.ts import { useEffect, useState, useCallback, useRef } from "react"; function useMousePosition(id, containerRef, offsetX, offsetY, throttleMs = 0) { const [position, setPosition] = useState({ x: null, y: null }); const [targetPosition, setTargetPosition] = useState({ x: null, y: null }); const isInitialized = useRef(false); const isVisible = targetPosition.x !== null && targetPosition.y !== null; const updateTargetWithBoundsCheck = useCallback((globalPosition) => { const adjustedPosition = { x: globalPosition.x + offsetX, y: globalPosition.y + offsetY }; if (containerRef == null ? void 0 : containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); const isInside = globalPosition.x >= rect.left && globalPosition.x <= rect.right && globalPosition.y >= rect.top && globalPosition.y <= rect.bottom; if (isInside) { setTargetPosition(adjustedPosition); } else { setTargetPosition({ x: null, y: null }); } } else { setTargetPosition(adjustedPosition); } }, [containerRef, offsetX, offsetY]); const handleUpdate = useCallback((globalPosition) => { updateTargetWithBoundsCheck(globalPosition); }, [updateTargetWithBoundsCheck]); useEffect(() => { if (!(containerRef == null ? void 0 : containerRef.current)) return; const container = containerRef.current; const handleMouseLeave = () => { setTargetPosition({ x: null, y: null }); }; container.addEventListener("mouseleave", handleMouseLeave); return () => { container.removeEventListener("mouseleave", handleMouseLeave); }; }, [containerRef]); useEffect(() => { let isCleanedUp = false; const subscriptionRef = { unsubscribe: null }; import("./CursorCoordinator-6CTTW3XY.dev.mjs").then(({ CursorCoordinator }) => { if (isCleanedUp) return; const cursorCoordinator = CursorCoordinator.getInstance(); subscriptionRef.unsubscribe = cursorCoordinator.subscribe({ id, onPositionChange: handleUpdate, throttleMs }); }).catch((error) => { console.warn("Failed to load cursor coordinator:", error); }); return () => { var _a; isCleanedUp = true; (_a = subscriptionRef.unsubscribe) == null ? void 0 : _a.call(subscriptionRef); }; }, [id, throttleMs, handleUpdate]); useEffect(() => { if (targetPosition.x !== null && targetPosition.y !== null && !isInitialized.current) { setPosition(targetPosition); isInitialized.current = true; } }, [targetPosition]); return { position, setPosition, targetPosition, isVisible }; } // src/hooks/useSmoothAnimation.ts import { useEffect as useEffect2, useCallback as useCallback2 } from "react"; var SMOOTHING_THRESHOLD = 0.1; function useSmoothAnimation(targetPosition, smoothFactor, setPosition) { const calculateNewPosition = useCallback2( (currentPosition) => { if (currentPosition.x === null || currentPosition.y === null || targetPosition.x === null || targetPosition.y === null) { return currentPosition; } const dx = targetPosition.x - currentPosition.x; const dy = targetPosition.y - currentPosition.y; if (Math.abs(dx) < SMOOTHING_THRESHOLD && Math.abs(dy) < SMOOTHING_THRESHOLD) { return currentPosition; } return { x: currentPosition.x + dx / smoothFactor, y: currentPosition.y + dy / smoothFactor }; }, [targetPosition.x, targetPosition.y, smoothFactor] ); const animate = useCallback2(() => { let animationFrameId; const smoothing = () => { setPosition((prev) => { const newPosition = calculateNewPosition(prev); if (newPosition.x === prev.x && newPosition.y === prev.y) { return prev; } return newPosition; }); animationFrameId = requestAnimationFrame(smoothing); }; animationFrameId = requestAnimationFrame(smoothing); return () => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); } }; }, [calculateNewPosition, setPosition, targetPosition]); useEffect2(() => { var _a; if (isSSR()) return; const mediaQuery = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null; const prefersReducedMotion = (_a = mediaQuery == null ? void 0 : mediaQuery.matches) != null ? _a : false; if (smoothFactor <= 1 || prefersReducedMotion) { setPosition(targetPosition); return; } return animate(); }, [smoothFactor, targetPosition.x, targetPosition.y, animate, setPosition]); } // src/utils/validation.ts function validateProps(props) { if (false) return; const { id, smoothness, throttleMs, zIndex, offset, containerRef, centered, showDevIndicator, onMove, onVisibilityChange } = props; if (id !== void 0 && typeof id !== "string") { console.error( `CustomCursor: 'id' must be a string. Received: ${id} (${typeof id}). Note: empty strings are allowed and will auto-generate a UUID.` ); } if (smoothness !== void 0) { if (typeof smoothness !== "number" || isNaN(smoothness)) { console.error( `CustomCursor: 'smoothness' must be a number. Received: ${smoothness} (${typeof smoothness})` ); } else if (smoothness < 0) { console.error( `CustomCursor: 'smoothness' must be non-negative. Received: ${smoothness}. Use 1 for no smoothing, higher values for more smoothing.` ); } else if (smoothness > 20) { console.warn( `CustomCursor: 'smoothness' value ${smoothness} is very high. Values above 20 may cause poor performance. Consider using a lower value.` ); } } if (throttleMs !== void 0) { if (typeof throttleMs !== "number" || isNaN(throttleMs)) { console.error( `CustomCursor: 'throttleMs' must be a number. Received: ${throttleMs} (${typeof throttleMs})` ); } else if (throttleMs < 0) { console.error( `CustomCursor: 'throttleMs' must be non-negative. Received: ${throttleMs}. Use 0 for no throttling.` ); } else if (throttleMs > 100) { console.warn( `CustomCursor: 'throttleMs' value ${throttleMs} is quite high. This may make the cursor feel sluggish. Consider using a lower value (0-50ms).` ); } } if (zIndex !== void 0) { if (typeof zIndex !== "number" || isNaN(zIndex)) { console.error( `CustomCursor: 'zIndex' must be a number. Received: ${zIndex} (${typeof zIndex})` ); } else if (!Number.isInteger(zIndex)) { console.warn( `CustomCursor: 'zIndex' should be an integer. Received: ${zIndex}` ); } } if (offset !== void 0) { if (typeof offset !== "object" || offset === null) { console.error( `CustomCursor: 'offset' must be an object with x and y properties. Received: ${offset}` ); } else { const { x, y } = offset; if (typeof x !== "number" || isNaN(x)) { console.error( `CustomCursor: 'offset.x' must be a number. Received: ${x} (${typeof x})` ); } if (typeof y !== "number" || isNaN(y)) { console.error( `CustomCursor: 'offset.y' must be a number. Received: ${y} (${typeof y})` ); } } } if (containerRef !== void 0) { if (typeof containerRef !== "object" || containerRef === null || !("current" in containerRef)) { console.error( `CustomCursor: 'containerRef' must be a React ref object (created with useRef). Received: ${containerRef}` ); } } if (centered !== void 0 && typeof centered !== "boolean") { console.error( `CustomCursor: 'centered' must be a boolean. Received: ${centered} (${typeof centered})` ); } if (showDevIndicator !== void 0 && typeof showDevIndicator !== "boolean") { console.error( `CustomCursor: 'showDevIndicator' must be a boolean. Received: ${showDevIndicator} (${typeof showDevIndicator})` ); } if (onMove !== void 0 && typeof onMove !== "function") { console.error( `CustomCursor: 'onMove' must be a function. Received: ${typeof onMove}` ); } if (onVisibilityChange !== void 0 && typeof onVisibilityChange !== "function") { console.error( `CustomCursor: 'onVisibilityChange' must be a function. Received: ${typeof onVisibilityChange}` ); } } // src/CustomCursor.tsx var ANIMATION_DURATION = "0.3s"; var ANIMATION_NAME = "cursorFadeIn"; var DEFAULT_Z_INDEX = 9999; var DevIndicator = ({ position, show }) => { var _a, _b; if (!show) return null; return /* @__PURE__ */ React2.createElement( "div", { style: { position: "fixed", top: 0, left: 0, transform: `translate(${(_a = position.x) != null ? _a : 0}px, ${(_b = position.y) != null ? _b : 0}px)`, width: "50px", height: "50px", border: "2px solid red", borderRadius: "50%", pointerEvents: "none", zIndex: 1e4, opacity: 0.5, // Center the circle around the cursor marginLeft: "-25px", marginTop: "-25px" } } ); }; var arePropsEqual = (prevProps, nextProps) => { var _a, _b, _c, _d, _e, _f; if (prevProps.id !== nextProps.id || prevProps.enabled !== nextProps.enabled || prevProps.className !== nextProps.className || prevProps.zIndex !== nextProps.zIndex || prevProps.smoothness !== nextProps.smoothness || prevProps.centered !== nextProps.centered || prevProps.throttleMs !== nextProps.throttleMs || prevProps.showDevIndicator !== nextProps.showDevIndicator) { return false; } if (((_a = prevProps.offset) == null ? void 0 : _a.x) !== ((_b = nextProps.offset) == null ? void 0 : _b.x) || ((_c = prevProps.offset) == null ? void 0 : _c.y) !== ((_d = nextProps.offset) == null ? void 0 : _d.y)) { return false; } if (((_e = prevProps.containerRef) == null ? void 0 : _e.current) !== ((_f = nextProps.containerRef) == null ? void 0 : _f.current)) { return false; } const prevStyle = prevProps.style || {}; const nextStyle = nextProps.style || {}; const prevStyleKeys = Object.keys(prevStyle); const nextStyleKeys = Object.keys(nextStyle); if (prevStyleKeys.length !== nextStyleKeys.length) { return false; } for (const key of prevStyleKeys) { if (prevStyle[key] !== nextStyle[key]) { return false; } } if (prevProps.onMove !== nextProps.onMove || prevProps.onVisibilityChange !== nextProps.onVisibilityChange) { return false; } if (prevProps.children !== nextProps.children) { return false; } return true; }; var generateCursorId = () => { return `cursor-${Math.random().toString(36).substr(2, 9)}-${Date.now().toString(36)}`; }; var CustomCursor = React2.memo( ({ id, enabled = true, children, className = "", style = {}, zIndex = DEFAULT_Z_INDEX, offset = { x: 0, y: 0 }, smoothness = 1, containerRef, centered = true, throttleMs = 0, showDevIndicator = true, onMove, onVisibilityChange, "data-testid": dataTestId, role, "aria-label": ariaLabel }) => { const cursorId = React2.useMemo(() => id || generateCursorId(), [id]); validateProps({ id: cursorId, enabled, children, className, style, zIndex, offset, smoothness, containerRef, centered, throttleMs, showDevIndicator, onMove, onVisibilityChange }); const offsetValues = React2.useMemo(() => ({ x: typeof offset === "object" ? offset.x : 0, y: typeof offset === "object" ? offset.y : 0 }), [offset]); const isMobile = React2.useMemo(() => isMobileDevice(), []); const mousePositionHook = useMousePosition(cursorId, containerRef, offsetValues.x, offsetValues.y, throttleMs); const { position, setPosition, targetPosition, isVisible } = mousePositionHook; useSmoothAnimation(targetPosition, smoothness, setPosition); const [portalContainer, setPortalContainer] = React2.useState(null); const getPortalContainerMemo = React2.useCallback(() => { const doc = safeDocument(); if (!doc) return null; const existingContainer = doc.getElementById("cursor-container"); if (existingContainer) { existingContainer.style.zIndex = zIndex.toString(); return existingContainer; } const container = doc.createElement("div"); container.id = "cursor-container"; container.style.position = "fixed"; container.style.top = "0"; container.style.left = "0"; container.style.pointerEvents = "none"; container.style.zIndex = zIndex.toString(); doc.body.appendChild(container); return container; }, [zIndex]); React2.useEffect(() => { setPortalContainer(getPortalContainerMemo()); return () => { const doc = safeDocument(); if (!doc) return; const container = doc.getElementById("cursor-container"); if (container && container.children.length === 0) { try { if (container.parentNode) { container.parentNode.removeChild(container); } } catch (e) { if (true) { console.warn("Portal container cleanup failed:", e); } } } }; }, [getPortalContainerMemo]); const styleSheetContent = React2.useMemo(() => { const centerTransform = centered ? " translate(-50%, -50%)" : ""; return ` @keyframes ${ANIMATION_NAME} { from { opacity: 0; transform: translate(var(--cursor-x), var(--cursor-y))${centerTransform} scale(0.8); } to { opacity: 1; transform: translate(var(--cursor-x), var(--cursor-y))${centerTransform} scale(1); } } @media (prefers-reduced-motion: reduce) { @keyframes ${ANIMATION_NAME} { from { opacity: 0; transform: translate(var(--cursor-x), var(--cursor-y))${centerTransform} scale(1); } to { opacity: 1; transform: translate(var(--cursor-x), var(--cursor-y))${centerTransform} scale(1); } } } `; }, [centered]); React2.useEffect(() => { const doc = safeDocument(); if (!doc) return; const styleId = `cursor-style-${cursorId}`; const existingStyle = doc.getElementById(styleId); if (existingStyle) { existingStyle.remove(); } const styleSheet = doc.createElement("style"); styleSheet.id = styleId; styleSheet.textContent = styleSheetContent; doc.head.appendChild(styleSheet); return () => { const style2 = doc.getElementById(styleId); if (style2) { try { style2.remove(); } catch (e) { if (true) { console.warn("Style cleanup failed:", e); } } } }; }, [cursorId, styleSheetContent]); const handleMove = React2.useCallback(() => { if (position.x !== null && position.y !== null && typeof onMove === "function") { const cursorPosition = { x: position.x, y: position.y }; onMove(cursorPosition); } }, [position.x, position.y, onMove]); React2.useEffect(() => { handleMove(); }, [handleMove]); const handleVisibilityChange = React2.useCallback(() => { if (typeof onVisibilityChange === "function") { const actuallyVisible = enabled && isVisible; const reason = !enabled ? "disabled" : "container"; onVisibilityChange(actuallyVisible, reason); } }, [enabled, isVisible, onVisibilityChange]); React2.useEffect(() => { handleVisibilityChange(); }, [handleVisibilityChange]); React2.useEffect(() => { if (isMobile && typeof onVisibilityChange === "function") { onVisibilityChange(false, "touch"); } }, [isMobile, onVisibilityChange]); if (isMobile) { return null; } const cursorStyle = React2.useMemo( () => { var _a, _b, _c, _d; const baseTransform = `translate(${(_a = position.x) != null ? _a : 0}px, ${(_b = position.y) != null ? _b : 0}px)`; const centerTransform = centered ? " translate(-50%, -50%)" : ""; return __spreadValues({ position: "fixed", top: 0, left: 0, transform: baseTransform + centerTransform, pointerEvents: "none", zIndex, opacity: 1, visibility: "visible", animation: `${ANIMATION_NAME} ${ANIMATION_DURATION} ease-out`, "--cursor-x": `${(_c = position.x) != null ? _c : 0}px`, "--cursor-y": `${(_d = position.y) != null ? _d : 0}px` }, style); }, [position.x, position.y, zIndex, centered, style] ); const globalStyleContent = React2.useMemo(() => ` #cursor-container { pointer-events: none !important; } `, []); const shouldRender = !isSSR() && enabled && isVisible && position.x !== null && position.y !== null && portalContainer; if (!shouldRender) return null; return /* @__PURE__ */ React2.createElement(React2.Fragment, null, /* @__PURE__ */ React2.createElement("style", { id: `cursor-style-global-${cursorId}` }, globalStyleContent), createPortal( /* @__PURE__ */ React2.createElement(React2.Fragment, { key: `cursor-${cursorId}` }, /* @__PURE__ */ React2.createElement( "div", { id: `custom-cursor-${cursorId}`, style: cursorStyle, className, "aria-hidden": "true", "data-testid": dataTestId, role, "aria-label": ariaLabel }, children ), /* @__PURE__ */ React2.createElement(DevIndicator, { position, show: showDevIndicator })), portalContainer )); }, arePropsEqual ); CustomCursor.displayName = "CustomCursor"; var CustomCursor_default = CustomCursor; export { CustomCursor_default as CustomCursor, browserOnly, isBrowser, isSSR, safeDocument, safeWindow }; //# sourceMappingURL=index.dev.mjs.map