Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/docs/content/1.component/magnetic-button.mdx
Original file line number Diff line number Diff line change
@@ -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 `<button>`, so `onClick`, `type`,
`disabled`, and refs all work. Driven by GSAP.

::component-preview{name="magnetic-button"}
::

## Installation

::tabs
:::tabs-item{label="CLI" icon="i-lucide-terminal"}

::install-tabs{item="magnetic-button"}
::

:::
:::tabs-item{label="Manual" icon="i-lucide-clipboard"}

::component-source{name="magnetic-button"}
::

:::
::

## Usage

::tabs
:::tabs-item{label="React" icon="i-lucide-atom"}

```tsx [app/page.tsx]
import MagneticButton from '@/components/tweenui/magnetic-button';

export default function Page() {
return (
<MagneticButton strength={0.4} field={80} onClick={() => console.log('clicked')}>
Get started
</MagneticButton>
);
}
```

:::
::

While the cursor is in the field the button carries a `data-active` attribute,
so restyle that state with Tailwind — for example
`className="bg-black text-white data-[active]:bg-white data-[active]:text-black"`.

## Props

Extends every native `<button>` attribute (`onClick`, `type`, `disabled`, `ref`, …).

| Prop | Type | Default | Description |
| ------------ | --------------------------------- | ---------- | ------------------------------------------------------------------ |
| `children` | `ReactNode` | — | Button label. |
| `strength` | `number` | `0.35` | How strongly the button follows the cursor, from `0` to `1`. |
| `field` | `number` | `60` | Distance around the button, in pixels, where the pull begins. |
| `parallax` | `number` | `0.35` | Extra drift of the label on top of the button's own. |
| `hoverScale` | `number` | `1.05` | Scale while the cursor is inside the field. |
| `duration` | `number` | `0.6` | How long the button takes to catch up with the cursor, in seconds. |
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | Native button type. |
| `className` | `string` | — | Merged with the base styles. |

## Accessibility

::note
It renders a native `<button>`, so keyboard and screen-reader behaviour come for
free, and keyboard focus shows the active colors with a visible ring. The pull
is skipped entirely on touch devices, for disabled buttons, and under
`prefers-reduced-motion: reduce`.
::
2 changes: 1 addition & 1 deletion apps/docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
import { withDocora } from 'docora/next';

const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.68.113'],
allowedDevOrigins: ['192.168.68.123'],
};

export default withDocora(nextConfig);
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
17 changes: 17 additions & 0 deletions apps/docs/public/r/magnetic-button-demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "magnetic-button-demo",
"type": "registry:example",
"title": "Magnetic Button Demo",
"description": "Usage examples for Magnetic Button.",
"registryDependencies": [
"https://tween-ui.vercel.app/r/magnetic-button.json"
],
"files": [
{
"path": "examples/magnetic-button-demo.tsx",
"type": "registry:example",
"content": "import MagneticButton from '@/components/tweenui/magnetic-button';\n\nexport default function Page() {\n return (\n <MagneticButton strength={0.4} field={80} onClick={() => console.log('clicked')}>\n Get started\n </MagneticButton>\n );\n}"
}
]
}
24 changes: 24 additions & 0 deletions apps/docs/public/r/magnetic-button.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"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.",
"dependencies": [
"gsap",
"@gsap/react"
],
"registryDependencies": [],
"cssVars": {
"theme": {}
},
"files": [
{
"path": "components/tweenui/magnetic-button.tsx",
"type": "registry:component",
"target": "components/tweenui/magnetic-button.tsx",
"content": "'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"
}
],
"docs": "Docs, props and examples: https://tween-ui.vercel.app/component/magnetic-button"
}
24 changes: 24 additions & 0 deletions apps/docs/public/r/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/registry/__index__.generated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const demos: Record<string, ComponentType> = {
'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')),
Expand Down
1 change: 1 addition & 0 deletions apps/docs/registry/__media__.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const discoveredMedia: Record<string, RegistryMedia> = {
'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"},
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/registry/__sources__.generated.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions apps/docs/registry/demos/magnetic-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';

import MagneticButton from '@/registry/tweenui/magnetic-button';

export default function MagneticButtonDemo() {
return <MagneticButton>Get started</MagneticButton>;
}
33 changes: 33 additions & 0 deletions apps/docs/registry/registry-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,39 @@ export default function Example() {

export default function Example() {
return <GlowButton onClick={() => {}}>Get started</GlowButton>;
}`,
},
},
{
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 <MagneticButton onClick={() => {}}>Get started</MagneticButton>;
}`,
},
},
Expand Down
136 changes: 136 additions & 0 deletions apps/docs/registry/tweenui/magnetic-button.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const labelRef = useRef<HTMLSpanElement>(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 (
<button
ref={setRef}
type={type}
disabled={disabled}
className={cn(
'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]',
className
)}
{...props}
>
<span ref={labelRef} className="inline-block will-change-transform">
{children}
</span>
</button>
);
}
Loading
Loading