aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
234 lines (231 loc) • 8.2 kB
JavaScript
'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