UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

234 lines (231 loc) 8.2 kB
'use client'; import { jsx } from 'react/jsx-runtime'; import React, { forwardRef, useRef, useState, useCallback, useEffect } from 'react'; import { cn } from '../../lib/utilsComprehensive.js'; import { GlassButton } from './GlassButton.js'; import { Slot } from '@radix-ui/react-slot'; import '../../primitives/GlassCore.js'; import '../../primitives/glass/GlassAdvanced.js'; import '../../primitives/OptimizedGlassCore.js'; import '../../primitives/glass/OptimizedGlassAdvanced.js'; import '../../primitives/MotionNative.js'; import { MotionFramer } from '../../primitives/motion/MotionFramer.js'; import { announceToScreenReader } from '../../utils/a11y.js'; import { safeMatchMedia, isBrowser, getSafeWindow } from '../../utils/env.js'; /** * A GlassButton with a magnetic effect that attracts the button towards the cursor on hover. */ const MagneticButton = /*#__PURE__*/forwardRef(function MagneticButton({ magneticStrength = 0.5, magneticRadius = 150, magneticDampingFactor = 0.8, style: propStyle, onPointerEnter, onPointerLeave, asChild = false, announceInteractions = false, respectReducedMotion = true, children, ...restProps }, ref) { const isTestEnvironment = typeof process !== "undefined" && !!process.env.JEST_WORKER_ID; const Comp = asChild ? Slot : GlassButton; const elementRef = useRef(null); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isHovered, setIsHovered] = useState(false); const animationFrame = useRef(null); // Check if user prefers reduced motion // CRITICAL SSR FIX: Default to false (motion allowed) to match client default const prefersReducedMotion = useCallback(() => { if (isTestEnvironment) return true; if (!respectReducedMotion) return false; return safeMatchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false; }, [respectReducedMotion, isTestEnvironment]); // Accessibility: Announce when magnetic interaction becomes available const announceInteractionRef = useRef(false); useEffect(() => { if (!isBrowser()) return; if (announceInteractions && !announceInteractionRef.current && !prefersReducedMotion()) { announceToScreenReader('Interactive magnetic button available', 'polite'); announceInteractionRef.current = true; } }, [announceInteractions, prefersReducedMotion]); const combinedRef = useCallback(node => { if (elementRef.current !== node) { elementRef.current = node; } if (ref) { if (typeof ref === 'function') { ref(node); } else { ref.current = node; } } }, [ref]); const calculateMagneticAttraction = useCallback((distanceX, distanceY, rect) => { // Disable magnetic effect if user prefers reduced motion if (prefersReducedMotion()) { return { x: 0, y: 0 }; } const distance = Math.sqrt(distanceX ** 2 + distanceY ** 2); if (distance > magneticRadius || !rect) { return { x: 0, y: 0 }; } // Smoother falloff using cosine easing const normalizedDistance = distance / magneticRadius; const pullFactor = (Math.cos(normalizedDistance * Math.PI) + 1) / 2; // Cosine easing const attraction = pullFactor * magneticStrength; // Avoid division by zero if distance is zero if (distance === 0) return { x: 0, y: 0 }; // Scale pull relative to button size (pull towards edge, not just center proportional) const pullToX = distanceX / distance * (rect.width / 2) * attraction; const pullToY = distanceY / distance * (rect.height / 2) * attraction; return { x: pullToX, y: pullToY }; }, [magneticRadius, magneticStrength, prefersReducedMotion]); const handlePointerMove = useCallback(e => { if (!isBrowser() || !elementRef.current || !isHovered) return; const rect = elementRef.current.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const distanceX = e.clientX - centerX; const distanceY = e.clientY - centerY; const attraction = calculateMagneticAttraction(distanceX, distanceY, rect); // Directly set position based on attraction setPosition({ x: attraction.x, y: attraction.y }); }, [isHovered, calculateMagneticAttraction]); const returnToCenter = useCallback(() => { const win = getSafeWindow(); if (!win) return; if (!isHovered && elementRef.current && (Math.abs(position.x) > 0.01 || Math.abs(position.y) > 0.01)) { // Smoother damping towards center const dampFactor = 0.15; // Controls speed of return (higher is faster damping) const nextX = position.x * (1 - dampFactor); const nextY = position.y * (1 - dampFactor); setPosition({ x: nextX, y: nextY }); animationFrame.current = win.requestAnimationFrame(returnToCenter); } else if (!isHovered) { // Snap to center when close enough setPosition({ x: 0, y: 0 }); if (animationFrame.current !== null) { win.cancelAnimationFrame(animationFrame.current); animationFrame.current = null; } } else { // If hovered, ensure no return animation is running if (animationFrame.current !== null) { win.cancelAnimationFrame(animationFrame.current); animationFrame.current = null; } } }, [isHovered, position.x, position.y]); useEffect(() => { if (!isBrowser() || isTestEnvironment) { return; } const win = getSafeWindow(); if (!win) return; win.addEventListener('pointermove', handlePointerMove, { capture: false, passive: true }); return () => { win.removeEventListener('pointermove', handlePointerMove, { capture: false }); if (animationFrame.current !== null) { cancelAnimationFrame(animationFrame.current); } }; }, [handlePointerMove]); useEffect(() => { if (isTestEnvironment) { return; } // Stop any ongoing return animation if we start hovering if (isHovered && animationFrame.current !== null) { const win = getSafeWindow(); if (win && animationFrame.current !== null) { win.cancelAnimationFrame(animationFrame.current); } animationFrame.current = null; } // Start return animation if we stop hovering else if (!isHovered) { // Ensure previous animation frame is cancelled before starting a new one if (animationFrame.current !== null) { cancelAnimationFrame(animationFrame.current); } const win = getSafeWindow(); if (win) { animationFrame.current = win.requestAnimationFrame(returnToCenter); } } // Cleanup function for the effect return () => { const win = getSafeWindow(); if (win && animationFrame.current !== null) { win.cancelAnimationFrame(animationFrame.current); } }; }, [isHovered, returnToCenter]); const handlePointerEnter = e => { setIsHovered(true); if (onPointerEnter) { onPointerEnter(e); } }; const handlePointerLeave = e => { setIsHovered(false); if (onPointerLeave) { onPointerLeave(e); } }; const combinedStyle = { ...propStyle, transform: prefersReducedMotion() ? 'none' : `translate3d(${position.x}px, ${position.y}px, 0)`, transition: prefersReducedMotion() ? 'none' : isHovered ? 'transform 0.05s linear' : 'transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1)', willChange: prefersReducedMotion() ? 'auto' : 'transform' }; return jsx(MotionFramer, { preset: "none", className: cn('glass-inline-block'), children: jsx(Comp, { ref: combinedRef, style: combinedStyle, onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave, ...restProps, children: React.Children.count(children) > 0 ? children : jsx("span", { className: "sr-only", children: "Magnetic button" }) }) }); }); MagneticButton.displayName = 'MagneticButton'; export { MagneticButton, MagneticButton as default }; //# sourceMappingURL=GlassMagneticButton.js.map