{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magnetic-button","type":"registry:component","title":"Magnetic Button","description":"A button that leans toward the cursor and snaps back like a spring.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/magnetic-button/magnetic-button.tsx","type":"registry:component","target":"components/dimaac/magnetic-button.tsx","content":"\"use client\";\n\nimport { useRef, type CSSProperties, type MouseEventHandler, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A button that leans toward the cursor and snaps back with an elastic ease. The label leans a\n// little further than the button (a parallax), and a circle of colour grows from the exact spot\n// the pointer came in. Keyboards get no magnet (just a centred fill and a focus ring) and touch\n// screens get the fill on press only. It is a real <button> or <a>, so it behaves like one.\n\nexport type MagneticButtonProps = {\n  /** The label. Text, or text with an icon. */\n  children: ReactNode;\n  /** How far the button leans toward the cursor. 0 = not at all, 1 = all the way to the cursor. */\n  strength?: number;\n  /** How much further the label leans than the button, as a share of the button's own lean. */\n  labelStrength?: number;\n  /** How far outside the button, in pixels, the magnet starts to pull. */\n  radius?: number;\n  /** The colour of the circle that fills the button. Any CSS colour. */\n  fill?: string;\n  /** The label colour while the fill is showing. Any CSS colour. */\n  fillTextColor?: string;\n  /** \"solid\" is a filled pill, \"outline\" a ring, \"ghost\" a soft tint with no ring. */\n  variant?: \"solid\" | \"outline\" | \"ghost\";\n  /** Render a <button> or a link. A link needs an href. */\n  as?: \"button\" | \"a\";\n  /** Where the link goes. Only used when as=\"a\". */\n  href?: string;\n  /** Link target, e.g. \"_blank\". Only used when as=\"a\". */\n  target?: string;\n  /** Link rel. A _blank link gets \"noopener noreferrer\" by default. */\n  rel?: string;\n  /** The button type. Only used when as=\"button\". */\n  type?: \"button\" | \"submit\" | \"reset\";\n  /** Turns the button off. Only used when as=\"button\". */\n  disabled?: boolean;\n  /** Runs on click or on Enter and Space. */\n  onClick?: MouseEventHandler<HTMLElement>;\n  /** Accessible name, if the visible label is not enough. */\n  ariaLabel?: string;\n  /** Extra classes for the outer wrapper (layout: margins, width). */\n  className?: string;\n  /** Extra classes for the button itself (size, font, radius). */\n  buttonClassName?: string;\n};\n\n// Colours come from CSS variables so a page can theme the button without touching the code:\n//   --mb-solid-bg / --mb-solid-fg for the solid variant (default: dark pill, light text)\nconst variantClasses = {\n  solid: \"bg-[var(--mb-solid-bg,#171412)] text-[var(--mb-solid-fg,#f7f3ee)]\",\n  outline: \"bg-transparent\",\n  ghost: \"bg-[color-mix(in_srgb,currentColor_8%,transparent)]\",\n} as const;\n\nexport function MagneticButton({\n  children,\n  strength = 0.35,\n  labelStrength = 0.5,\n  radius = 80,\n  fill = \"#e8532f\",\n  fillTextColor = \"#ffffff\",\n  variant = \"solid\",\n  as = \"button\",\n  href,\n  target,\n  rel,\n  type = \"button\",\n  disabled = false,\n  onClick,\n  ariaLabel,\n  className = \"\",\n  buttonClassName = \"\",\n}: MagneticButtonProps) {\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const buttonRef = useRef<HTMLElement | null>(null);\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const fillRef = useRef<HTMLSpanElement>(null);\n\n  // Read on every pointer move, so changing them never rebuilds the effect\n  const settings = useRef({ strength, labelStrength, radius });\n  settings.current = { strength, labelStrength, radius };\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const wrapper = wrapperRef.current;\n      const button = buttonRef.current;\n      const label = labelRef.current;\n      const circle = fillRef.current;\n      if (!wrapper || !button || !label || !circle || !contextSafe) return;\n\n      const reducedQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      // The magnet is for a mouse or a trackpad. Touch screens have no hover to follow.\n      const hoverQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n\n      gsap.set(circle, { xPercent: -50, yPercent: -50, scale: 0 });\n\n      // ---- The fill circle ----\n      let filled = false;\n      const grow = (x: number, y: number) => {\n        if (filled || disabled) return;\n        filled = true;\n        // The label colour change is plain CSS (see data-filled in the class list)\n        button.dataset.filled = \"true\";\n        const { width, height } = button.getBoundingClientRect();\n        // Big enough to cover the farthest corner, wherever the pointer came in\n        const size = Math.hypot(Math.max(x, width - x), Math.max(y, height - y)) * 2;\n        gsap.set(circle, { width: size, height: size, x, y });\n        if (reducedQuery.matches) {\n          gsap.to(circle, { scale: 1, opacity: 1, duration: 0.15, overwrite: true });\n          return;\n        }\n        gsap.to(circle, { scale: 1, duration: 0.6, ease: \"power3.out\", overwrite: true });\n      };\n      const shrink = (x: number, y: number) => {\n        if (!filled) return;\n        filled = false;\n        delete button.dataset.filled;\n        if (reducedQuery.matches) {\n          gsap.to(circle, { scale: 0, duration: 0.15, overwrite: true });\n        } else {\n          // The circle slides out to where the pointer left, then closes there\n          gsap.to(circle, { x, y, scale: 0, duration: 0.45, ease: \"power3.inOut\", overwrite: true });\n        }\n      };\n\n      const local = (event: PointerEvent) => {\n        const rect = button.getBoundingClientRect();\n        return { x: event.clientX - rect.left, y: event.clientY - rect.top };\n      };\n\n      const onEnter = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"touch\") return;\n        const p = local(event);\n        grow(p.x, p.y);\n      });\n      const onLeave = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"touch\") return;\n        const p = local(event);\n        shrink(p.x, p.y);\n      });\n\n      // Touch: no hover, so the fill grows while the finger is down\n      const onDown = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"touch\") {\n          const p = local(event);\n          grow(p.x, p.y);\n        }\n        if (!reducedQuery.matches && !disabled) gsap.to(button, { scale: 0.96, duration: 0.2, ease: \"power2.out\", overwrite: \"auto\" });\n      });\n      const onUp = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"touch\") {\n          const p = local(event);\n          shrink(p.x, p.y);\n        }\n        gsap.to(button, { scale: 1, duration: reducedQuery.matches ? 0 : 0.6, ease: \"elastic.out(1, 0.4)\", overwrite: \"auto\" });\n      });\n\n      // Keyboard: a focus ring (CSS) plus the fill from the middle\n      const onFocus = contextSafe(() => {\n        if (button.matches(\":focus-visible\")) grow(button.clientWidth / 2, button.clientHeight / 2);\n      });\n      const onBlur = contextSafe(() => shrink(button.clientWidth / 2, button.clientHeight / 2));\n\n      // ---- The magnet ----\n      let attracted = false;\n      const limitX = gsap.utils.clamp(-20, 20);\n      const limitY = gsap.utils.clamp(-9, 9);\n\n      const release = () => {\n        if (!attracted) return;\n        attracted = false;\n        const reduced = reducedQuery.matches;\n        const options = reduced ? { duration: 0 } : { duration: 1.2, ease: \"elastic.out(1, 0.3)\" };\n        gsap.to(button, { x: 0, y: 0, overwrite: \"auto\", ...options });\n        gsap.to(label, { x: 0, y: 0, overwrite: \"auto\", ...options });\n        // Stay above neighbours until the spring has mostly settled\n        gsap.set(wrapper, { zIndex: 0, delay: reduced ? 0 : 0.7, overwrite: true });\n      };\n\n      const onMove = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"touch\" || !hoverQuery.matches || reducedQuery.matches || disabled) return;\n        // The wrapper never moves, so measuring it gives a steady centre to pull toward\n        const rect = wrapper.getBoundingClientRect();\n        const dx = event.clientX - (rect.left + rect.width / 2);\n        const dy = event.clientY - (rect.top + rect.height / 2);\n        // Distance from the pointer to the button's edge (0 when the pointer is over it)\n        const outsideX = Math.max(Math.abs(dx) - rect.width / 2, 0);\n        const outsideY = Math.max(Math.abs(dy) - rect.height / 2, 0);\n        const { strength: pull, labelStrength: extra, radius: reach } = settings.current;\n        if (Math.hypot(outsideX, outsideY) > reach) {\n          release();\n          return;\n        }\n        if (!attracted) gsap.set(wrapper, { zIndex: 1, overwrite: true });\n        attracted = true;\n        gsap.to(button, { x: dx * pull, y: dy * pull, duration: 0.45, ease: \"power3.out\", overwrite: \"auto\" });\n        // The label's own lean is on top of the button's, so it travels further (but stays inside the pill)\n        gsap.to(label, { x: limitX(dx * pull * extra), y: limitY(dy * pull * extra), duration: 0.45, ease: \"power3.out\", overwrite: \"auto\" });\n      });\n      const onWindowLeave = contextSafe((event: MouseEvent) => {\n        if (!event.relatedTarget) release();\n      });\n\n      button.addEventListener(\"pointerenter\", onEnter);\n      button.addEventListener(\"pointerleave\", onLeave);\n      button.addEventListener(\"pointerdown\", onDown);\n      button.addEventListener(\"pointerup\", onUp);\n      button.addEventListener(\"pointercancel\", onUp);\n      button.addEventListener(\"focus\", onFocus);\n      button.addEventListener(\"blur\", onBlur);\n      window.addEventListener(\"pointermove\", onMove, { passive: true });\n      document.documentElement.addEventListener(\"mouseleave\", onWindowLeave);\n\n      return () => {\n        button.removeEventListener(\"pointerenter\", onEnter);\n        button.removeEventListener(\"pointerleave\", onLeave);\n        button.removeEventListener(\"pointerdown\", onDown);\n        button.removeEventListener(\"pointerup\", onUp);\n        button.removeEventListener(\"pointercancel\", onUp);\n        button.removeEventListener(\"focus\", onFocus);\n        button.removeEventListener(\"blur\", onBlur);\n        window.removeEventListener(\"pointermove\", onMove);\n        document.documentElement.removeEventListener(\"mouseleave\", onWindowLeave);\n        delete button.dataset.filled;\n      };\n    },\n    { scope: wrapperRef, dependencies: [as, disabled], revertOnUpdate: true },\n  );\n\n  const safeRel = rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n  const shared = {\n    ref: (node: HTMLElement | null) => {\n      buttonRef.current = node;\n    },\n    onClick,\n    \"aria-label\": ariaLabel,\n    className: `relative isolate inline-flex min-h-[52px] cursor-pointer items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-[15px] leading-none font-medium tracking-[-0.01em] whitespace-nowrap no-underline select-none outline-offset-4 transition-colors duration-300 data-[filled=true]:text-[var(--mb-fill-text)] focus-visible:outline-2 focus-visible:outline-[var(--mb-focus,currentColor)] ${variantClasses[variant]} ${disabled ? \"cursor-not-allowed opacity-50\" : \"\"} ${buttonClassName}`,\n    style: { \"--mb-fill-text\": fillTextColor, touchAction: \"manipulation\", WebkitTapHighlightColor: \"transparent\", willChange: \"transform\" } as CSSProperties,\n  };\n\n  const content = (\n    <>\n      {/* The ring (outline variant). It sits under the fill so the fill covers it. */}\n      {variant === \"outline\" && <span aria-hidden className=\"pointer-events-none absolute inset-0 -z-20 rounded-full border-[1.5px] border-current opacity-80\" />}\n      {/* The fill circle: decorative, so hidden from screen readers */}\n      <span ref={fillRef} aria-hidden className=\"pointer-events-none absolute top-0 left-0 -z-10 rounded-full\" style={{ background: fill, width: 0, height: 0, transform: \"scale(0)\" }} />\n      <span ref={labelRef} className=\"relative inline-flex items-center gap-2\" style={{ willChange: \"transform\" }}>\n        {children}\n      </span>\n    </>\n  );\n\n  return (\n    <span ref={wrapperRef} className={`relative inline-flex ${className}`}>\n      {as === \"a\" ? (\n        <a {...shared} href={disabled ? undefined : href} target={target} rel={safeRel} aria-disabled={disabled || undefined}>\n          {content}\n        </a>\n      ) : (\n        <button {...shared} type={type} disabled={disabled}>\n          {content}\n        </button>\n      )}\n    </span>\n  );\n}\n"}],"categories":["buttons","gsap","button","magnetic","hover","cta"],"docs":"import { MagneticButton } from \"@/components/dimaac/magnetic-button\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/magnetic-button","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/magnetic-button","credits":[{"by":"Siren Media","url":"https://www.lummi.ai/photo/minimalist-stucco-building-gurez","what":"Photo","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"}]}}