Add magnetic hover hook
This commit is contained in:
+2
-1
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Caveat, Outfit, Space_Grotesk } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { MotionProvider } from "@/components/motion-provider";
|
||||
|
||||
@@ -28,7 +29,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
import { MagneticHoverShowcase } from "@/components/magnetic-hover-showcase";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen px-8 py-16 md:px-16 lg:px-24">
|
||||
<section className="mx-auto max-w-4xl space-y-16">
|
||||
{/* Typography Showcase */}
|
||||
<div className="space-y-8">
|
||||
<h1 className="font-bold font-heading text-5xl md:text-7xl">Space Grotesk</h1>
|
||||
<p className="font-body text-xl leading-relaxed md:text-2xl">
|
||||
@@ -15,7 +16,6 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Palette */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="font-heading font-semibold text-3xl">Color Palette</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -42,7 +42,6 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Text Demo */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-heading font-semibold text-3xl">Accent Colors in Use</h2>
|
||||
<p className="text-xl">
|
||||
@@ -51,6 +50,8 @@ export default function Home() {
|
||||
<span className="text-accent-violet">violet for secondary emphasis</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MagneticHoverShowcase />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { m } from "motion/react";
|
||||
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
|
||||
|
||||
export const MagneticHoverShowcase = () => {
|
||||
const defaultHover = useMagneticSpringHover();
|
||||
const strongMagnet = useMagneticSpringHover({
|
||||
magnetStrength: 0.5,
|
||||
scaleAmount: 1.08,
|
||||
});
|
||||
|
||||
const subtleHover = useMagneticSpringHover({
|
||||
magnetStrength: 0.15,
|
||||
scaleAmount: 1.02,
|
||||
shadowElevation: 4,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="font-heading font-semibold text-3xl">Magnetic Hover Showcase</h2>
|
||||
<p className="font-body text-foreground/70">
|
||||
Hover over the elements below to see the magnetic + spring physics in action.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<m.div
|
||||
ref={defaultHover.ref}
|
||||
style={defaultHover.style}
|
||||
{...defaultHover.handlers}
|
||||
className="flex h-32 w-32 cursor-pointer items-center justify-center rounded-xl bg-accent-cyan/20 p-4 text-center"
|
||||
>
|
||||
<span className="font-body text-sm">Default</span>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
ref={strongMagnet.ref}
|
||||
style={strongMagnet.style}
|
||||
{...strongMagnet.handlers}
|
||||
className="flex h-32 w-32 cursor-pointer items-center justify-center rounded-xl bg-accent-violet/20 p-4 text-center"
|
||||
>
|
||||
<span className="font-body text-sm">Strong Magnet</span>
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
ref={subtleHover.ref}
|
||||
style={subtleHover.style}
|
||||
{...subtleHover.handlers}
|
||||
className="flex h-32 w-32 cursor-pointer items-center justify-center rounded-xl bg-foreground/10 p-4 text-center"
|
||||
>
|
||||
<span className="font-body text-sm">Subtle</span>
|
||||
</m.div>
|
||||
</div>
|
||||
|
||||
{defaultHover.isReducedMotion && (
|
||||
<p className="font-body text-accent-cyan text-sm">
|
||||
Reduced motion detected - showing opacity-only fallback
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Combined magnetic + spring hover hook.
|
||||
*
|
||||
* Creates a physics-based hover effect where elements:
|
||||
* - Drift toward the cursor (magnetic attraction)
|
||||
* - Scale up with spring physics
|
||||
* - Lift with shadow elevation
|
||||
*
|
||||
* Gracefully degrades on touch devices (scale only, no magnetic).
|
||||
* Respects prefers-reduced-motion (opacity only).
|
||||
*/
|
||||
|
||||
import type { MotionStyle, SpringOptions } from "motion/react";
|
||||
import { useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
|
||||
import type { MouseEvent, RefObject } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { springSnappy } from "@/lib/motion";
|
||||
|
||||
export type UseMagneticSpringHoverOptions = {
|
||||
/** Strength of magnetic attraction (0-1, default 0.3) */
|
||||
magnetStrength?: number;
|
||||
/** Scale multiplier on hover (default 1.05) */
|
||||
scaleAmount?: number;
|
||||
/** Shadow elevation on hover in px (default 8) */
|
||||
shadowElevation?: number;
|
||||
/** Spring configuration (default: springSnappy from lib/motion) */
|
||||
springConfig?: SpringOptions;
|
||||
};
|
||||
|
||||
export type UseMagneticSpringHoverReturn<T extends HTMLElement> = {
|
||||
/** Ref to attach to the element */
|
||||
ref: RefObject<T | null>;
|
||||
/** Style object to spread onto motion component */
|
||||
style: MotionStyle;
|
||||
/** Event handlers to spread onto component */
|
||||
handlers: {
|
||||
onMouseMove: (e: MouseEvent) => void;
|
||||
onMouseLeave: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onTouchStart: () => void;
|
||||
onTouchEnd: () => void;
|
||||
};
|
||||
/** Whether reduced motion is active */
|
||||
isReducedMotion: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for magnetic + spring hover effects.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { ref, style, handlers } = useMagneticSpringHover()
|
||||
*
|
||||
* return (
|
||||
* <m.button ref={ref} style={style} {...handlers}>
|
||||
* Hover me
|
||||
* </m.button>
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const useMagneticSpringHover = <T extends HTMLElement = HTMLDivElement>(
|
||||
options: UseMagneticSpringHoverOptions = {},
|
||||
): UseMagneticSpringHoverReturn<T> => {
|
||||
const {
|
||||
magnetStrength = 0.3,
|
||||
scaleAmount = 1.05,
|
||||
shadowElevation = 8,
|
||||
springConfig = springSnappy,
|
||||
} = options;
|
||||
|
||||
const ref = useRef<T | null>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setIsTouchDevice(window.matchMedia("(pointer: coarse)").matches);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Motion values for cursor offset
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
// Hover progress for scale and shadow (0 = not hovered, 1 = fully hovered)
|
||||
const hoverProgress = useMotionValue(0);
|
||||
|
||||
// Apply spring physics to position
|
||||
const springX = useSpring(x, springConfig);
|
||||
const springY = useSpring(y, springConfig);
|
||||
|
||||
// Apply spring physics to hover progress
|
||||
const springHoverProgress = useSpring(hoverProgress, springConfig);
|
||||
|
||||
// Derive scale from hover progress
|
||||
const scale = useTransform(springHoverProgress, [0, 1], [1, scaleAmount]);
|
||||
|
||||
// Derive shadow values from hover progress
|
||||
const shadowY = useTransform(springHoverProgress, [0, 1], [2, shadowElevation]);
|
||||
const shadowBlur = useTransform(springHoverProgress, [0, 1], [4, shadowElevation * 2]);
|
||||
const shadowOpacity = useTransform(springHoverProgress, [0, 1], [0.1, 0.2]);
|
||||
|
||||
// Compose shadow string from individual values (OKLCH color space)
|
||||
const boxShadow = useTransform(
|
||||
[shadowY, shadowBlur, shadowOpacity],
|
||||
([y, blur, alpha]) => `0 ${y}px ${blur}px oklch(0 0 0 / ${alpha})`,
|
||||
);
|
||||
|
||||
// Derive opacity for reduced motion fallback
|
||||
const opacity = useTransform(springHoverProgress, [0, 1], [0.9, 1]);
|
||||
|
||||
// Mouse move handler - track cursor offset from element center
|
||||
const onMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!ref.current || isTouchDevice || shouldReduceMotion) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// Calculate offset from center, scaled by magnetic strength
|
||||
const offsetX = (e.clientX - centerX) * magnetStrength;
|
||||
const offsetY = (e.clientY - centerY) * magnetStrength;
|
||||
|
||||
x.set(offsetX);
|
||||
y.set(offsetY);
|
||||
},
|
||||
[isTouchDevice, shouldReduceMotion, magnetStrength, x, y],
|
||||
);
|
||||
|
||||
// Mouse enter handler - set hover state
|
||||
const onMouseEnter = useCallback(() => {
|
||||
hoverProgress.set(1);
|
||||
}, [hoverProgress]);
|
||||
|
||||
// Mouse leave handler - reset position and hover state
|
||||
const onMouseLeave = useCallback(() => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
hoverProgress.set(0);
|
||||
}, [x, y, hoverProgress]);
|
||||
|
||||
// Touch handlers for touch devices
|
||||
const onTouchStart = useCallback(() => {
|
||||
hoverProgress.set(1);
|
||||
}, [hoverProgress]);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
hoverProgress.set(0);
|
||||
}, [hoverProgress]);
|
||||
|
||||
// Build style object based on motion preference
|
||||
const style: MotionStyle = shouldReduceMotion
|
||||
? {
|
||||
// Reduced motion: only opacity change
|
||||
opacity,
|
||||
}
|
||||
: {
|
||||
// Full motion: magnetic drift + scale + shadow
|
||||
x: isTouchDevice ? 0 : springX,
|
||||
y: isTouchDevice ? 0 : springY,
|
||||
scale,
|
||||
boxShadow,
|
||||
};
|
||||
|
||||
return {
|
||||
ref,
|
||||
style,
|
||||
handlers: {
|
||||
onMouseMove,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onTouchStart,
|
||||
onTouchEnd,
|
||||
},
|
||||
isReducedMotion: shouldReduceMotion ?? false,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user