← Landing
Zubeyr··Componentsbuttons

Magnetic 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 motion from npm.
  • →Files land in components/landing/magnetic-button.

Installing by hand? Add the dependencies, then copy the source code below.

terminal.sh
pnpm add motion

Usage

example.tsx
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

magnetic-button.tsx
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>
  )
}
magnetic.tsx
'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}
    />
  )
}