Animated Button
A magnetic button with spring physics and cursor-following interactions.
Overview
A premium button component that creates a magnetic pull effect toward the cursor, using spring physics for smooth, tactile interactions. The button and its internal content move independently, creating depth through parallax motion.
Features
- Magnetic hover attraction with configurable strength
- Spring-based physics for natural movement
- Parallax content animation
- Customizable accent colors and glow effects
- Full keyboard and focus state support
Preview
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/animated-button.jsonMethod 2: Manual
-
Install Dependencies
npm install framer-motion lucide-react -
Copy the Source Code
Copy the code below into
components/zenblocks/animated-button.tsx.Click to expand source
"use client"; import React, { useRef } from "react"; import { motion, useMotionValue, useSpring, useTransform } from "framer-motion"; import { cn } from "@/lib/utils"; import { LucideIcon, ArrowRight } from "lucide-react"; /** * AnimatedButtonProps * @extends React.ButtonHTMLAttributes<HTMLButtonElement> */ export interface AnimatedButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { /** Optional icon component to display after the label */ icon?: LucideIcon | React.ReactNode; /** Accent color for the glow and border effects (e.g., #6366f1) */ accentColor?: string; /** Intensity of the magnetic pull (0 to 1) */ effectStrength?: number; } /** * AnimatedButton * * A signature ZenBlocks component featuring a tactile magnetic hover attraction. * It uses spring-physics to pull the entire button towards the cursor within its bounds. */ export const AnimatedButton: React.FC<AnimatedButtonProps> = ({ children, className, icon: Icon, accentColor = "#6366f1", // Default indigo-500 effectStrength = 1, onMouseMove, onMouseLeave, ...props }) => { const buttonRef = useRef<HTMLButtonElement>(null); // Motion values for magnetic displacement const x = useMotionValue(0); const y = useMotionValue(0); // Smooth springs for high-end feel const springConfig = { stiffness: 150, damping: 15, mass: 0.1 }; const springX = useSpring(x, springConfig); const springY = useSpring(y, springConfig); // Parallax secondary movement for internal content const contentX = useTransform(springX, (val) => val * 0.4); const contentY = useTransform(springY, (val) => val * 0.4); const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => { if (!buttonRef.current) return; const rect = buttonRef.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; // Calculate magnetic pull (max 15px displacement) x.set(distanceX * 0.3 * effectStrength); y.set(distanceY * 0.3 * effectStrength); if (onMouseMove) onMouseMove(e); }; const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => { x.set(0); y.set(0); if (onMouseLeave) onMouseLeave(e); }; // Destructure to separate motion-conflicting props const { onDrag, onDragStart, onDragEnd, onAnimationStart, onDragOver, onDragEnter, onDragLeave, ...filteredProps } = props; return ( <motion.button ref={buttonRef} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} style={{ x: springX, y: springY, }} className={cn( "group relative flex items-center justify-center gap-2 px-8 py-4 rounded-2xl", "bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800", "text-zinc-900 dark:text-zinc-100 font-bold uppercase tracking-[0.2em] text-[10px]", "transition-colors duration-300 hover:bg-zinc-50 dark:hover:bg-zinc-900", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950", className )} {...filteredProps} > {/* Dynamic Glow Overlay */} <div className="absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-10 transition-opacity duration-500 blur-xl pointer-events-none" style={{ backgroundColor: accentColor }} /> {/* Inner Content with Parallax */} <motion.div className="relative z-10 flex items-center justify-center gap-3" style={{ x: contentX, y: contentY, }} > <span className="relative">{children}</span> {Icon && ( typeof Icon === "function" ? ( React.createElement(Icon as LucideIcon, { className: "w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5" }) ) : ( <span className="inline-flex items-center justify-center w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5 [&_svg]:w-full [&_svg]:h-full"> {Icon} </span> ) )} </motion.div> {/* Accent Border Reveal */} <div className="absolute inset-[-1px] rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none border" style={{ borderColor: accentColor }} /> </motion.button> ); }; export default AnimatedButton;
Usage
import { AnimatedButton } from "@/components/zenblocks/animated-button";
import { ArrowRight } from "lucide-react";
<AnimatedButton icon={<ArrowRight />}>
Click Me
</AnimatedButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | - | Button label text |
| icon | LucideIcon | ReactNode | - | Optional icon component to display after text. Pass as element (<Icon />) in Server Components. |
| accentColor | string | "#6366f1" | Hex color for glow and border effects |
| effectStrength | number | 1 | Magnetic pull intensity (0 to 1) |
| className | string | - | Additional CSS classes |
Accessibility
- Semantic
<button>element with proper focus states - Keyboard navigation via Tab and Enter/Space
- Focus-visible ring indicator
- Motion respects
prefers-reduced-motionfor spring animations
Customization
Accent Color
<AnimatedButton accentColor="#ec4899">
Pink Accent
</AnimatedButton>Effect Strength
<AnimatedButton effectStrength={0.5}>
Subtle Pull
</AnimatedButton>Custom Styling
<AnimatedButton className="px-12 py-6 text-lg">
Large Button
</AnimatedButton>Motion Behavior
The button uses two layers of spring physics:
- Primary layer: The entire button follows cursor position within bounds (max 15px displacement)
- Secondary layer: Internal content moves at 40% of button speed for parallax depth
Springs use stiffness: 150, damping: 15, mass: 0.1 for responsive yet smooth motion.
Performance Notes
- Uses Framer Motion's
useMotionValueanduseSpringfor GPU-accelerated transforms - No React re-renders during animation (direct style manipulation)
- Glow effects use CSS transitions instead of animation frames
Examples
With Icon
<AnimatedButton icon={<ArrowRight />}>
Get Started
</AnimatedButton>Disabled State
<AnimatedButton disabled>
Unavailable
</AnimatedButton>Notes
- The magnetic effect only activates on hover, not on touch devices
- Icon automatically inherits button text color
- Works with any Lucide React icon component