diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8f281c6..4d71a09 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f0e4330..d1d47da 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,8 +1,9 @@
+import { MagneticHoverShowcase } from "@/components/magnetic-hover-showcase";
+
export default function Home() {
return (
- {/* Typography Showcase */}
Space Grotesk
@@ -15,7 +16,6 @@ export default function Home() {
- {/* Color Palette */}
Color Palette
@@ -42,7 +42,6 @@ export default function Home() {
- {/* Accent Text Demo */}
Accent Colors in Use
@@ -51,6 +50,8 @@ export default function Home() {
violet for secondary emphasis.
+
+
);
diff --git a/src/components/magnetic-hover-showcase.tsx b/src/components/magnetic-hover-showcase.tsx
new file mode 100644
index 0000000..fd894f6
--- /dev/null
+++ b/src/components/magnetic-hover-showcase.tsx
@@ -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 (
+
+
Magnetic Hover Showcase
+
+ Hover over the elements below to see the magnetic + spring physics in action.
+
+
+
+
+ Default
+
+
+
+ Strong Magnet
+
+
+
+ Subtle
+
+
+
+ {defaultHover.isReducedMotion && (
+
+ Reduced motion detected - showing opacity-only fallback
+
+ )}
+
+ );
+};
diff --git a/src/hooks/use-magnetic-spring-hover.ts b/src/hooks/use-magnetic-spring-hover.ts
new file mode 100644
index 0000000..e57ed57
--- /dev/null
+++ b/src/hooks/use-magnetic-spring-hover.ts
@@ -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 = {
+ /** Ref to attach to the element */
+ ref: RefObject;
+ /** 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 (
+ *
+ * Hover me
+ *
+ * )
+ * ```
+ */
+export const useMagneticSpringHover = (
+ options: UseMagneticSpringHoverOptions = {},
+): UseMagneticSpringHoverReturn => {
+ const {
+ magnetStrength = 0.3,
+ scaleAmount = 1.05,
+ shadowElevation = 8,
+ springConfig = springSnappy,
+ } = options;
+
+ const ref = useRef(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,
+ };
+};