Zubeyr··ComponentsbuttonsMagnetic Button
A rolling pill that leans toward the cursor. The label springs further than the pill, so it strains against the edge holding it.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/magnetic-button.json- →Installs
motionfrom npm. - →Files land in
components/landing/magnetic-button.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add motionUsage
import { MagneticButton } from '@/components/landing/magnetic-button/magnetic-button'
export default function Demo() {
return (
<div className="flex flex-wrap items-center justify-center gap-6">
<MagneticButton size="sm">Hover me</MagneticButton>
<MagneticButton arrow>Get in touch</MagneticButton>
<MagneticButton size="lg" contentStrength={0.4}>
Start a project
</MagneticButton>
</div>
)
}Source code
import { cn } from '@/lib/utils'
import { Magnetic } from './magnetic'
const sizes = {
sm: 'px-5 py-2.5 text-sm',
md: 'px-7 py-3.5 text-base',
lg: 'px-10 py-5 text-lg',
}
type MagneticButtonProps = {
as?: React.ElementType
href?: string
size?: keyof typeof sizes
arrow?: boolean
strength?: number
contentStrength?: number
fillClassName?: string
className?: string
children: string
} & Omit<React.HTMLAttributes<HTMLElement>, 'children'>
export function MagneticButton({
as,
href,
size = 'md',
arrow = false,
strength = 0.3,
contentStrength = 0.2,
fillClassName = 'bg-muted-foreground',
className,
children,
...props
}: MagneticButtonProps) {
const Tag = as ?? (href ? 'a' : 'button')
const label = arrow ? `${children} ↗` : children
return (
<Magnetic strength={strength} contentStrength={contentStrength}>
<Tag
href={href}
type={Tag === 'button' ? 'button' : undefined}
className={cn(
'group relative inline-flex items-center justify-center overflow-hidden rounded-full bg-primary leading-none font-medium text-primary-foreground uppercase',
sizes[size],
className,
)}
{...props}
>
<span className="absolute inset-0 overflow-hidden">
<span
className={cn(
'block size-full translate-y-full rounded-t-[50%] transition-[translate,border-radius] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:translate-y-0 group-hover:rounded-none',
fillClassName,
)}
/>
</span>
<span className="relative block [translate:var(--magnetic-x,0px)_var(--magnetic-y,0px)] overflow-hidden">
<span
data-label={label}
className="block transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:-translate-y-full after:absolute after:top-full after:left-0 after:content-[attr(data-label)]"
>
{label}
</span>
</span>
</Tag>
</Magnetic>
)
}'use client'
import { useRef } from 'react'
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from 'motion/react'
type MagneticProps = React.ComponentProps<typeof motion.div> & {
strength?: number
contentStrength?: number
damping?: number
stiffness?: number
}
export function Magnetic({
strength = 0.35,
contentStrength = 0,
damping = 18,
stiffness = 180,
style,
...props
}: MagneticProps) {
const ref = useRef<HTMLDivElement>(null)
const reduced = useReducedMotion()
const rawX = useMotionValue(0)
const rawY = useMotionValue(0)
const spring = { damping, stiffness, mass: 0.4 }
const x = useSpring(
useTransform(rawX, (v) => v * strength),
spring,
)
const y = useSpring(
useTransform(rawY, (v) => v * strength),
spring,
)
// A second, stiffer offset published as --magnetic-x/y. Descendants that opt in
// move by strength + contentStrength and settle first, so they read as elastic.
const contentSpring = { damping: damping + 4, stiffness: stiffness * 1.6, mass: 0.3 }
const contentX = useSpring(
useTransform(rawX, (v) => v * contentStrength),
contentSpring,
)
const contentY = useSpring(
useTransform(rawY, (v) => v * contentStrength),
contentSpring,
)
const varX = useTransform(contentX, (v) => `${v}px`)
const varY = useTransform(contentY, (v) => `${v}px`)
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (reduced || event.pointerType !== 'mouse') return
const rect = ref.current?.getBoundingClientRect()
if (!rect) return
rawX.set(event.clientX - (rect.left + rect.width / 2))
rawY.set(event.clientY - (rect.top + rect.height / 2))
}
const reset = () => {
rawX.set(0)
rawY.set(0)
}
return (
<motion.div
ref={ref}
onPointerMove={onPointerMove}
onPointerLeave={reset}
onPointerCancel={reset}
style={{
x,
y,
display: 'inline-flex',
...(contentStrength ? { '--magnetic-x': varX, '--magnetic-y': varY } : null),
...style,
}}
{...props}
/>
)
}