From 9a9df437acfc0f7a70c34da6170ffd5a73e35c6d Mon Sep 17 00:00:00 2001 From: zahid6999 Date: Wed, 30 Sep 2026 16:05:30 +0600 Subject: [PATCH] feat: add magnetic button component --- .../content/1.component/magnetic-button.mdx | 79 ++++++++++ apps/docs/next.config.ts | 2 +- .../components/magnetic-button-poster.webp | Bin 0 -> 1172 bytes .../components/magnetic-button-preview.mp4 | Bin 0 -> 41431 bytes apps/docs/public/r/magnetic-button-demo.json | 17 +++ apps/docs/public/r/magnetic-button.json | 24 ++++ apps/docs/public/r/registry.json | 24 ++++ apps/docs/registry/__index__.generated.tsx | 1 + apps/docs/registry/__media__.generated.ts | 1 + apps/docs/registry/__sources__.generated.ts | 12 ++ apps/docs/registry/demos/magnetic-button.tsx | 7 + apps/docs/registry/registry-ui.ts | 33 +++++ .../docs/registry/tweenui/magnetic-button.tsx | 136 ++++++++++++++++++ apps/docs/tests/magnetic-button.test.tsx | 105 ++++++++++++++ 14 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/1.component/magnetic-button.mdx create mode 100644 apps/docs/public/media/components/magnetic-button-poster.webp create mode 100644 apps/docs/public/media/components/magnetic-button-preview.mp4 create mode 100644 apps/docs/public/r/magnetic-button-demo.json create mode 100644 apps/docs/public/r/magnetic-button.json create mode 100644 apps/docs/registry/demos/magnetic-button.tsx create mode 100644 apps/docs/registry/tweenui/magnetic-button.tsx create mode 100644 apps/docs/tests/magnetic-button.test.tsx diff --git a/apps/docs/content/1.component/magnetic-button.mdx b/apps/docs/content/1.component/magnetic-button.mdx new file mode 100644 index 0000000..ed1c644 --- /dev/null +++ b/apps/docs/content/1.component/magnetic-button.mdx @@ -0,0 +1,79 @@ +--- +title: Magnetic Button +description: A pill button pulled toward the cursor, its label drifting further for depth. +icon: magnet +--- + +A pill button with a **magnetic field** around it. Once the cursor comes within +reach, the button **leans toward it** and grows a touch, while the label +**drifts a little further** for a sense of depth. Leave the field and it +**eases back to rest**. It is a real `\n );\n}\n" + } + ], + "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/magnetic-button" +} \ No newline at end of file diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index ebfd895..ae5fbd1 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -295,6 +295,30 @@ } ] }, + { + "name": "magnetic-button", + "type": "registry:ui", + "title": "Magnetic Button", + "description": "A pill button pulled toward the cursor once it comes near, its label drifting a little further for parallax depth, then easing back to rest when the cursor leaves the field.", + "files": [ + { + "path": "components/tweenui/magnetic-button.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "magnetic-button-demo", + "type": "registry:example", + "title": "Magnetic Button Demo", + "description": "Usage examples for Magnetic Button.", + "files": [ + { + "path": "examples/magnetic-button-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "logo-orbit", "type": "registry:ui", diff --git a/apps/docs/registry/__index__.generated.tsx b/apps/docs/registry/__index__.generated.tsx index 53b57d5..0caa627 100644 --- a/apps/docs/registry/__index__.generated.tsx +++ b/apps/docs/registry/__index__.generated.tsx @@ -22,6 +22,7 @@ export const demos: Record = { 'text-roll-button:default': dynamic(() => import('./demos/text-roll-button')), 'slide-arrow-button:default': dynamic(() => import('./demos/slide-arrow-button')), 'glow-button:default': dynamic(() => import('./demos/glow-button')), + 'magnetic-button:default': dynamic(() => import('./demos/magnetic-button')), 'logo-orbit:default': dynamic(() => import('./demos/logo-orbit')), 'logo-cycle:default': dynamic(() => import('./demos/logo-cycle')), 'logo-wave:default': dynamic(() => import('./demos/logo-wave')), diff --git a/apps/docs/registry/__media__.generated.ts b/apps/docs/registry/__media__.generated.ts index 47b00f3..c279374 100644 --- a/apps/docs/registry/__media__.generated.ts +++ b/apps/docs/registry/__media__.generated.ts @@ -19,6 +19,7 @@ export const discoveredMedia: Record = { 'text-roll-button': {"image":"/media/components/text-roll-button-poster.webp","video":"/media/components/text-roll-button-preview.mp4"}, 'slide-arrow-button': {"image":"/media/components/slide-arrow-button-poster.webp","video":"/media/components/slide-arrow-button-preview.mp4"}, 'glow-button': {"image":"/media/components/glow-button-poster.webp","video":"/media/components/glow-button-preview.mp4"}, + 'magnetic-button': {"image":"/media/components/magnetic-button-poster.webp","video":"/media/components/magnetic-button-preview.mp4"}, 'logo-orbit': {"image":"/media/components/logo-orbit-poster.webp","video":"/media/components/logo-orbit-preview.mp4"}, 'logo-cycle': {"image":"/media/components/logo-cycle-poster.webp","video":"/media/components/logo-cycle-preview.mp4"}, 'logo-wave': {"image":"/media/components/logo-wave-poster.webp","video":"/media/components/logo-wave-preview.mp4"}, diff --git a/apps/docs/registry/__sources__.generated.ts b/apps/docs/registry/__sources__.generated.ts index 954886a..c73d68b 100644 --- a/apps/docs/registry/__sources__.generated.ts +++ b/apps/docs/registry/__sources__.generated.ts @@ -79,6 +79,12 @@ export const usage: Record = { "codeHtml": "
import GlowButton from '@/components/tweenui/glow-button';\n\nexport default function Example() {\n  return <GlowButton onClick={() => {}}>Get started</GlowButton>;\n}
" } }, + "magnetic-button": { + "react": { + "code": "import MagneticButton from '@/components/tweenui/magnetic-button';\n\nexport default function Example() {\n return {}}>Get started;\n}", + "codeHtml": "
import MagneticButton from '@/components/tweenui/magnetic-button';\n\nexport default function Example() {\n  return <MagneticButton onClick={() => {}}>Get started</MagneticButton>;\n}
" + } + }, "logo-orbit": { "react": { "code": "import LogoOrbit from '@/components/tweenui/logo-orbit';\n\nconst logos = [\n { src: '/logos/slack.svg', alt: 'Slack' },\n { src: '/logos/figma.svg', alt: 'Figma' },\n { src: '/logos/github.svg', alt: 'GitHub' },\n];\n\nexport default function Example() {\n return ;\n}", @@ -328,6 +334,12 @@ export const manual: Record = { "codeHtml": "
'use client';\n\nimport type { ComponentPropsWithRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nfunction Chevron({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 18 18\"\n      className={className}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      aria-hidden=\"true\"\n    >\n      <path d=\"M6.75 13.5 11.25 9 6.75 4.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport type GlowButtonProps = ComponentPropsWithRef<'button'>;\n\nexport default function GlowButton({\n  children,\n  className,\n  type = 'button',\n  ...props\n}: GlowButtonProps) {\n  return (\n    <button\n      type={type}\n      className={cn(\n        'group relative isolate inline-flex h-12 cursor-pointer items-center justify-center gap-2 rounded-full px-6 text-sm font-medium text-white transition-transform duration-300 hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60 motion-reduce:transition-none motion-reduce:hover:translate-y-0 dark:ring-offset-[#12161F]',\n        className\n      )}\n      {...props}\n    >\n      {/* Gradient line peeking out along the bottom edge, its colors flowing\n          left to right, then the dark pill surface over it. */}\n      <style>{'@keyframes tween-glow-flow{to{background-position:-200% 0}}'}</style>\n      <span\n        className=\"absolute inset-x-0 top-[2px] -bottom-[2px] [animation:tween-glow-flow_4s_linear_infinite] rounded-full bg-[linear-gradient(90deg,#ff5a5a,#ffca3a,#8765ff,#30e3ff,#ff5a5a)] bg-[length:200%_100%] motion-reduce:[animation:none]\"\n        aria-hidden=\"true\"\n      />\n      <span className=\"absolute inset-0 rounded-full bg-[#12161f]\" aria-hidden=\"true\" />\n\n      <span className=\"relative z-10\">{children}</span>\n      <span className=\"relative z-10 flex size-[18px] overflow-hidden\">\n        <Chevron className=\"size-[18px] shrink-0 translate-x-0 stroke-white transition-transform duration-300 ease-in-out group-hover:translate-x-full motion-reduce:transition-none motion-reduce:group-hover:translate-x-0\" />\n        <Chevron className=\"size-[18px] shrink-0 -translate-x-[200%] stroke-white transition-transform duration-300 ease-in-out group-hover:-translate-x-full motion-reduce:hidden\" />\n      </span>\n\n      {/* Colorful glow that sharpens slightly on hover. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute -bottom-3 left-1/2 -z-10 flex -translate-x-1/2 items-center\"\n      >\n        <span className=\"h-6 w-8 rounded-full bg-[#ff5a5a] blur-lg transition-all duration-500 group-hover:blur-md motion-reduce:transition-none\" />\n        <span className=\"h-6 w-8 rounded-full bg-[#ffca3a] blur-lg transition-all duration-500 group-hover:blur-md motion-reduce:transition-none\" />\n        <span className=\"h-8 w-10 rounded-full bg-[#8765ff] blur-lg transition-all duration-500 group-hover:blur-md motion-reduce:transition-none\" />\n        <span className=\"h-6 w-7 rounded-full bg-[#30e3ff] blur-lg transition-all duration-500 group-hover:blur-md motion-reduce:transition-none\" />\n      </span>\n    </button>\n  );\n}\n
" } }, + "magnetic-button": { + "react": { + "code": "'use client';\n\nimport { useRef, type ComponentPropsWithRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\n// Touch has no hover to pull toward, and reduced motion asks for stillness.\nconst canMagnetize = () =>\n typeof window !== 'undefined' &&\n !window.matchMedia('(prefers-reduced-motion: reduce)').matches &&\n window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\nexport interface MagneticButtonProps extends ComponentPropsWithRef<'button'> {\n /** How strongly the button follows the cursor, as a share of its offset from center. */\n strength?: number;\n /** Distance around the button, in pixels, where the pull begins. */\n field?: number;\n /** Extra drift of the label on top of the button's own, for depth. */\n parallax?: number;\n /** Scale while the cursor is inside the field. */\n hoverScale?: number;\n /** How long the button takes to catch up with the cursor, in seconds. */\n duration?: number;\n}\n\nexport default function MagneticButton({\n children,\n className,\n type = 'button',\n strength = 0.35,\n field = 60,\n parallax = 0.35,\n hoverScale = 1.05,\n duration = 0.6,\n disabled,\n ref,\n ...props\n}: MagneticButtonProps) {\n const btnRef = useRef(null);\n const labelRef = useRef(null);\n\n // Merge the internal ref (needed for GSAP) with any forwarded ref.\n const setRef = (node: HTMLButtonElement | null) => {\n btnRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) ref.current = node;\n };\n\n useGSAP(\n () => {\n const btn = btnRef.current;\n const label = labelRef.current;\n if (!btn || !label || disabled || !canMagnetize()) return;\n\n const tween = { duration: Math.max(0.05, duration), ease: 'power3.out' };\n const btnX = gsap.quickTo(btn, 'x', tween);\n const btnY = gsap.quickTo(btn, 'y', tween);\n const btnScale = gsap.quickTo(btn, 'scale', tween);\n const labelX = gsap.quickTo(label, 'x', tween);\n const labelY = gsap.quickTo(label, 'y', tween);\n\n const pull = (x: number, y: number, scale: number) => {\n btnX(x);\n btnY(y);\n btnScale(scale);\n labelX(x * parallax);\n labelY(y * parallax);\n };\n\n const setActive = (active: boolean) => {\n if (active) btn.setAttribute('data-active', '');\n else btn.removeAttribute('data-active');\n };\n\n const release = () => {\n setActive(false);\n pull(0, 0, 1);\n };\n\n const onMove = (e: PointerEvent) => {\n // The button is the thing moving, so undo its current transform to\n // find where it rests — measuring the moved box would chase itself.\n const r = btn.getBoundingClientRect();\n const scale = Number(gsap.getProperty(btn, 'scale')) || 1;\n const cx = r.left + r.width / 2 - Number(gsap.getProperty(btn, 'x'));\n const cy = r.top + r.height / 2 - Number(gsap.getProperty(btn, 'y'));\n const halfW = r.width / scale / 2;\n const halfH = r.height / scale / 2;\n\n // Distance from the pointer to the resting edge — 0 when inside.\n const dx = Math.max(Math.abs(e.clientX - cx) - halfW, 0);\n const dy = Math.max(Math.abs(e.clientY - cy) - halfH, 0);\n if (Math.hypot(dx, dy) > Math.max(0, field)) return release();\n\n setActive(true);\n pull((e.clientX - cx) * strength, (e.clientY - cy) * strength, hoverScale);\n };\n\n window.addEventListener('pointermove', onMove, { passive: true });\n document.documentElement.addEventListener('pointerleave', release);\n window.addEventListener('blur', release);\n\n return () => {\n window.removeEventListener('pointermove', onMove);\n document.documentElement.removeEventListener('pointerleave', release);\n window.removeEventListener('blur', release);\n setActive(false);\n };\n },\n {\n scope: btnRef,\n dependencies: [strength, field, parallax, hoverScale, duration, disabled],\n revertOnUpdate: true,\n }\n );\n\n return (\n \n \n {children}\n \n \n );\n}\n", + "codeHtml": "
'use client';\n\nimport { useRef, type ComponentPropsWithRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\n// Touch has no hover to pull toward, and reduced motion asks for stillness.\nconst canMagnetize = () =>\n  typeof window !== 'undefined' &&\n  !window.matchMedia('(prefers-reduced-motion: reduce)').matches &&\n  window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\nexport interface MagneticButtonProps extends ComponentPropsWithRef<'button'> {\n  /** How strongly the button follows the cursor, as a share of its offset from center. */\n  strength?: number;\n  /** Distance around the button, in pixels, where the pull begins. */\n  field?: number;\n  /** Extra drift of the label on top of the button's own, for depth. */\n  parallax?: number;\n  /** Scale while the cursor is inside the field. */\n  hoverScale?: number;\n  /** How long the button takes to catch up with the cursor, in seconds. */\n  duration?: number;\n}\n\nexport default function MagneticButton({\n  children,\n  className,\n  type = 'button',\n  strength = 0.35,\n  field = 60,\n  parallax = 0.35,\n  hoverScale = 1.05,\n  duration = 0.6,\n  disabled,\n  ref,\n  ...props\n}: MagneticButtonProps) {\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const labelRef = useRef<HTMLSpanElement>(null);\n\n  // Merge the internal ref (needed for GSAP) with any forwarded ref.\n  const setRef = (node: HTMLButtonElement | null) => {\n    btnRef.current = node;\n    if (typeof ref === 'function') ref(node);\n    else if (ref) ref.current = node;\n  };\n\n  useGSAP(\n    () => {\n      const btn = btnRef.current;\n      const label = labelRef.current;\n      if (!btn || !label || disabled || !canMagnetize()) return;\n\n      const tween = { duration: Math.max(0.05, duration), ease: 'power3.out' };\n      const btnX = gsap.quickTo(btn, 'x', tween);\n      const btnY = gsap.quickTo(btn, 'y', tween);\n      const btnScale = gsap.quickTo(btn, 'scale', tween);\n      const labelX = gsap.quickTo(label, 'x', tween);\n      const labelY = gsap.quickTo(label, 'y', tween);\n\n      const pull = (x: number, y: number, scale: number) => {\n        btnX(x);\n        btnY(y);\n        btnScale(scale);\n        labelX(x * parallax);\n        labelY(y * parallax);\n      };\n\n      const setActive = (active: boolean) => {\n        if (active) btn.setAttribute('data-active', '');\n        else btn.removeAttribute('data-active');\n      };\n\n      const release = () => {\n        setActive(false);\n        pull(0, 0, 1);\n      };\n\n      const onMove = (e: PointerEvent) => {\n        // The button is the thing moving, so undo its current transform to\n        // find where it rests — measuring the moved box would chase itself.\n        const r = btn.getBoundingClientRect();\n        const scale = Number(gsap.getProperty(btn, 'scale')) || 1;\n        const cx = r.left + r.width / 2 - Number(gsap.getProperty(btn, 'x'));\n        const cy = r.top + r.height / 2 - Number(gsap.getProperty(btn, 'y'));\n        const halfW = r.width / scale / 2;\n        const halfH = r.height / scale / 2;\n\n        // Distance from the pointer to the resting edge — 0 when inside.\n        const dx = Math.max(Math.abs(e.clientX - cx) - halfW, 0);\n        const dy = Math.max(Math.abs(e.clientY - cy) - halfH, 0);\n        if (Math.hypot(dx, dy) > Math.max(0, field)) return release();\n\n        setActive(true);\n        pull((e.clientX - cx) * strength, (e.clientY - cy) * strength, hoverScale);\n      };\n\n      window.addEventListener('pointermove', onMove, { passive: true });\n      document.documentElement.addEventListener('pointerleave', release);\n      window.addEventListener('blur', release);\n\n      return () => {\n        window.removeEventListener('pointermove', onMove);\n        document.documentElement.removeEventListener('pointerleave', release);\n        window.removeEventListener('blur', release);\n        setActive(false);\n      };\n    },\n    {\n      scope: btnRef,\n      dependencies: [strength, field, parallax, hoverScale, duration, disabled],\n      revertOnUpdate: true,\n    }\n  );\n\n  return (\n    <button\n      ref={setRef}\n      type={type}\n      disabled={disabled}\n      className={cn(\n        'inline-flex h-12 cursor-pointer items-center justify-center rounded-full border border-transparent bg-[#045f64] px-6 text-sm font-medium whitespace-nowrap text-[#c6f56f] shadow-[0_1px_1px_rgba(16,24,40,0.16)] transition-colors duration-300 will-change-transform select-none focus-visible:bg-[#c6f56f] focus-visible:text-[#045f64] focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60 data-[active]:bg-[#c6f56f] data-[active]:text-[#045f64] motion-reduce:transition-none dark:ring-offset-[#12161F]',\n        className\n      )}\n      {...props}\n    >\n      <span ref={labelRef} className=\"inline-block will-change-transform\">\n        {children}\n      </span>\n    </button>\n  );\n}\n
" + } + }, "logo-orbit": { "react": { "code": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nfunction LogoImage({ src, srcDark, alt }: LogoOrbitLogo) {\n if (!srcDark) {\n return {alt};\n }\n return (\n <>\n {alt}\n \n \n );\n}\n\nexport interface LogoOrbitLogo {\n src: string;\n /** Optional mark shown under `.dark`. */\n srcDark?: string;\n alt: string;\n}\n\nexport interface LogoOrbitProps extends ComponentPropsWithoutRef<'div'> {\n /** Logos placed evenly around the ring. */\n logos: LogoOrbitLogo[];\n /** Ring diameter in pixels. */\n size?: number;\n /** Rotation speed multiplier (1 = one turn every 20s). */\n speed?: number;\n /** Pause the orbit while the pointer is over the ring. */\n pauseOnHover?: boolean;\n}\n\nexport default function LogoOrbit({\n logos,\n size = 320,\n speed = 1,\n pauseOnHover = true,\n className,\n ...props\n}: LogoOrbitProps) {\n const ringRef = useRef(null);\n const hubRef = useRef(null);\n const pivotRefs = useRef<(HTMLDivElement | null)[]>([]);\n const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n useGSAP(\n () => {\n const ring = ringRef.current;\n const hub = hubRef.current;\n if (!ring || !hub) return;\n\n ring.style.setProperty('--orbit-size', `${size}px`);\n\n const hubSize = hub.offsetWidth || 20;\n const hubOffset = (size - hubSize) / 2;\n gsap.set(hub, { x: hubOffset, y: hubOffset, rotation: 0 });\n\n const pivots = pivotRefs.current.filter((el): el is HTMLDivElement => el !== null);\n const items = itemRefs.current.filter((el): el is HTMLDivElement => el !== null);\n const count = pivots.length;\n if (count === 0) return;\n\n // Distribute the logos evenly around the ring and keep each one upright.\n const space = 360 / count;\n const pivotOriginY = size / 2 + 10;\n pivots.forEach((pivot, i) => {\n const pivotHalf = pivot.offsetWidth / 2 || 10;\n gsap.set(pivot, {\n rotation: i * space,\n transformOrigin: `${pivotHalf}px ${pivotOriginY}px`,\n });\n });\n items.forEach((item, i) => {\n gsap.set(item, { rotation: -i * space, transformOrigin: 'center center' });\n });\n\n if (prefersReducedMotion()) return;\n\n const timeScale = Math.min(20, Math.max(0.05, speed));\n const tl = gsap.timeline({ repeat: -1 });\n tl.to(hub, { rotation: 360, duration: 20, ease: 'none' });\n tl.to(items, { rotation: '-=360', duration: 20, ease: 'none' }, 0);\n tl.timeScale(timeScale);\n\n if (!pauseOnHover) return;\n\n const pause = () => tl.pause();\n const resume = () => tl.resume();\n ring.addEventListener('pointerenter', pause);\n ring.addEventListener('pointerleave', resume);\n return () => {\n ring.removeEventListener('pointerenter', pause);\n ring.removeEventListener('pointerleave', resume);\n };\n },\n { scope: ringRef, dependencies: [size, speed, logos.length, pauseOnHover] }\n );\n\n return (\n \n
\n {logos.map((logo, i) => (\n {\n pivotRefs.current[i] = el;\n }}\n data-orbit-pivot\n className=\"absolute top-[calc(var(--orbit-size)*-0.5)]\"\n >\n {\n itemRefs.current[i] = el;\n }}\n className=\"absolute top-[-20px] grid size-16 place-items-center overflow-hidden rounded-full border border-[#045f64]/10 bg-white p-3 shadow-[0_1px_2px_rgba(16,24,40,0.12)] dark:border-[#045f64]/40 dark:bg-[#12161F]\"\n >\n \n
\n \n ))}\n \n \n );\n}\n", diff --git a/apps/docs/registry/demos/magnetic-button.tsx b/apps/docs/registry/demos/magnetic-button.tsx new file mode 100644 index 0000000..a94e332 --- /dev/null +++ b/apps/docs/registry/demos/magnetic-button.tsx @@ -0,0 +1,7 @@ +'use client'; + +import MagneticButton from '@/registry/tweenui/magnetic-button'; + +export default function MagneticButtonDemo() { + return Get started; +} diff --git a/apps/docs/registry/registry-ui.ts b/apps/docs/registry/registry-ui.ts index 4bdc8e1..c9a776e 100644 --- a/apps/docs/registry/registry-ui.ts +++ b/apps/docs/registry/registry-ui.ts @@ -443,6 +443,39 @@ export default function Example() { export default function Example() { return {}}>Get started; +}`, + }, + }, + { + name: 'magnetic-button', + type: 'component', + title: 'Magnetic Button', + description: + 'A pill button pulled toward the cursor once it comes near, its label drifting a little further for parallax depth, then easing back to rest when the cursor leaves the field.', + isNew: true, + media: {}, + dependencies: ['gsap', '@gsap/react'], + registryDependencies: [], + cssVars: {}, + files: [ + { + path: 'registry/tweenui/magnetic-button.tsx', + target: 'components/tweenui/magnetic-button.tsx', + kind: 'react', + }, + ], + variants: [ + { + id: 'default', + label: 'Preview', + reactSource: 'registry/tweenui/magnetic-button.tsx', + }, + ], + usage: { + react: `import MagneticButton from '@/components/tweenui/magnetic-button'; + +export default function Example() { + return {}}>Get started; }`, }, }, diff --git a/apps/docs/registry/tweenui/magnetic-button.tsx b/apps/docs/registry/tweenui/magnetic-button.tsx new file mode 100644 index 0000000..8ed3691 --- /dev/null +++ b/apps/docs/registry/tweenui/magnetic-button.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useRef, type ComponentPropsWithRef } from 'react'; +import { useGSAP } from '@gsap/react'; +import gsap from 'gsap'; +import { cn } from '@/lib/utils'; + +gsap.registerPlugin(useGSAP); + +// Touch has no hover to pull toward, and reduced motion asks for stillness. +const canMagnetize = () => + typeof window !== 'undefined' && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches && + window.matchMedia('(hover: hover) and (pointer: fine)').matches; + +export interface MagneticButtonProps extends ComponentPropsWithRef<'button'> { + /** How strongly the button follows the cursor, as a share of its offset from center. */ + strength?: number; + /** Distance around the button, in pixels, where the pull begins. */ + field?: number; + /** Extra drift of the label on top of the button's own, for depth. */ + parallax?: number; + /** Scale while the cursor is inside the field. */ + hoverScale?: number; + /** How long the button takes to catch up with the cursor, in seconds. */ + duration?: number; +} + +export default function MagneticButton({ + children, + className, + type = 'button', + strength = 0.35, + field = 60, + parallax = 0.35, + hoverScale = 1.05, + duration = 0.6, + disabled, + ref, + ...props +}: MagneticButtonProps) { + const btnRef = useRef(null); + const labelRef = useRef(null); + + // Merge the internal ref (needed for GSAP) with any forwarded ref. + const setRef = (node: HTMLButtonElement | null) => { + btnRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }; + + useGSAP( + () => { + const btn = btnRef.current; + const label = labelRef.current; + if (!btn || !label || disabled || !canMagnetize()) return; + + const tween = { duration: Math.max(0.05, duration), ease: 'power3.out' }; + const btnX = gsap.quickTo(btn, 'x', tween); + const btnY = gsap.quickTo(btn, 'y', tween); + const btnScale = gsap.quickTo(btn, 'scale', tween); + const labelX = gsap.quickTo(label, 'x', tween); + const labelY = gsap.quickTo(label, 'y', tween); + + const pull = (x: number, y: number, scale: number) => { + btnX(x); + btnY(y); + btnScale(scale); + labelX(x * parallax); + labelY(y * parallax); + }; + + const setActive = (active: boolean) => { + if (active) btn.setAttribute('data-active', ''); + else btn.removeAttribute('data-active'); + }; + + const release = () => { + setActive(false); + pull(0, 0, 1); + }; + + const onMove = (e: PointerEvent) => { + // The button is the thing moving, so undo its current transform to + // find where it rests — measuring the moved box would chase itself. + const r = btn.getBoundingClientRect(); + const scale = Number(gsap.getProperty(btn, 'scale')) || 1; + const cx = r.left + r.width / 2 - Number(gsap.getProperty(btn, 'x')); + const cy = r.top + r.height / 2 - Number(gsap.getProperty(btn, 'y')); + const halfW = r.width / scale / 2; + const halfH = r.height / scale / 2; + + // Distance from the pointer to the resting edge — 0 when inside. + const dx = Math.max(Math.abs(e.clientX - cx) - halfW, 0); + const dy = Math.max(Math.abs(e.clientY - cy) - halfH, 0); + if (Math.hypot(dx, dy) > Math.max(0, field)) return release(); + + setActive(true); + pull((e.clientX - cx) * strength, (e.clientY - cy) * strength, hoverScale); + }; + + window.addEventListener('pointermove', onMove, { passive: true }); + document.documentElement.addEventListener('pointerleave', release); + window.addEventListener('blur', release); + + return () => { + window.removeEventListener('pointermove', onMove); + document.documentElement.removeEventListener('pointerleave', release); + window.removeEventListener('blur', release); + setActive(false); + }; + }, + { + scope: btnRef, + dependencies: [strength, field, parallax, hoverScale, duration, disabled], + revertOnUpdate: true, + } + ); + + return ( + + ); +} diff --git a/apps/docs/tests/magnetic-button.test.tsx b/apps/docs/tests/magnetic-button.test.tsx new file mode 100644 index 0000000..e55d5db --- /dev/null +++ b/apps/docs/tests/magnetic-button.test.tsx @@ -0,0 +1,105 @@ +import { createRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import gsap from 'gsap'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import MagneticButton from '@/registry/tweenui/magnetic-button'; + +// A fine pointer with motion allowed, unless a test says otherwise. +const setMedia = ({ reduced = false, fine = true } = {}) => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('reduce') ? reduced : fine, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +}; + +// jsdom lays nothing out, so give the button a 100×40 box at (100, 100). +const placeButton = (btn: HTMLElement) => { + btn.getBoundingClientRect = () => + ({ left: 100, top: 100, right: 200, bottom: 140, width: 100, height: 40 }) as DOMRect; +}; + +const movePointer = (clientX: number, clientY: number) => + window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY })); + +describe('Magnetic Button', () => { + // restoreAllMocks also resets the matchMedia stub, so rebuild it per test. + beforeEach(() => setMedia()); + afterEach(() => vi.restoreAllMocks()); + + it('renders a button with its label', () => { + render(Get started); + expect(screen.getByRole('button', { name: /get started/i })).toBeInTheDocument(); + }); + + it('defaults to type="button"', () => { + render(Go); + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); + }); + + it('fires onClick and forwards native props', () => { + const onClick = vi.fn(); + render( + + Go + + ); + const btn = screen.getByTestId('magnet'); + fireEvent.click(btn); + expect(onClick).toHaveBeenCalledTimes(1); + expect(btn).toHaveClass('w-40'); + }); + + it('forwards a ref to the button element', () => { + const ref = createRef(); + render(Go); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it('activates inside the field and releases outside it', () => { + render(Go); + const btn = screen.getByRole('button'); + placeButton(btn); + + movePointer(230, 120); // 30px right of the edge + expect(btn).toHaveAttribute('data-active'); + + movePointer(400, 120); // 200px away + expect(btn).not.toHaveAttribute('data-active'); + }); + + it('pulls toward the cursor', () => { + const quickTo = vi.spyOn(gsap, 'quickTo'); + render(Go); + expect(quickTo).toHaveBeenCalledWith(screen.getByRole('button'), 'x', expect.any(Object)); + }); + + it('stays still on touch devices', () => { + setMedia({ fine: false }); + const quickTo = vi.spyOn(gsap, 'quickTo'); + render(Go); + expect(quickTo).not.toHaveBeenCalled(); + }); + + it('stays still under reduced motion', () => { + setMedia({ reduced: true }); + render(Go); + const btn = screen.getByRole('button'); + placeButton(btn); + movePointer(150, 120); + expect(btn).not.toHaveAttribute('data-active'); + }); + + it('stays still while disabled', () => { + render(Go); + const btn = screen.getByRole('button'); + placeButton(btn); + movePointer(150, 120); + expect(btn).not.toHaveAttribute('data-active'); + }); +});