"use client";
/**
* @author: @dorianbaffier
* @description: Attract Button
* @version: 2.0.0
* @date: 2026-10-16
* @license: MIT
* @website: https://kokonutui.com
* @github: https://github.com/kokonut-labs/kokonutui
*/
import {Magnet} from "lucide-react";
import {
type ComponentProps,
type FocusEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useRef,
} from "react";
import { cn } from "@/lib/utils";
interface AttractButtonProps extends ComponentProps<"button"> {
/** Filing density. 1 places a filing roughly every 12px; 2 doubles it. */
density?: number;
/** How far, in px, the field of filings extends past the button's edges. */
radius?: number;
/** Classes for the wrapper that holds the button and its field. */
containerClassName?: string;
}
interface Filing {
x: number;
y: number;
/** Rest direction in doubled-angle space (filings have no head or tail). */
restX: number;
restY: number;
/** Unit vector from the filing toward the button's center. */
inX: number;
inY: number;
/** 0 at the button's edge, 1 at the field's outer edge. */
t: number;
fade: number;
len: number;
theta: number;
omega: number;
near: number;
}
interface Controls {
setFocus: (on: boolean) => void;
touchStart: (event: PointerEvent) => void;
}
const BASE_SPACING = 12;
const FILING_LENGTH = 7;
const LINE_WIDTH = 1.25;
const CLEARANCE = 6;
// Rotational spring: slightly under-damped so filings settle with a small,
// physical overshoot rather than snapping.
const STIFFNESS = 240;
const DAMPING = 21;
const PRESENCE_TAU = 0.07;
const PULL_TAU = 0.09;
const REST_ALPHA = 0.3;
const HALF_PI = Math.PI / 2;
const hash = (x: number, y: number, seed: number) => {
const s = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43_758.5453;
return s - Math.floor(s);
};
const smoothstep = (a: number, b: number, v: number) => {
const k = Math.min(1, Math.max(0, (v - a) / (b - a)));
return k * k * (3 - 2 * k);
};
/** Shortest difference between two undirected angles, in (-π/2, π/2]. */
const lineDelta = (target: number, current: number) => {
const d = (target - current + HALF_PI) % Math.PI;
return (d < 0 ? d + Math.PI : d) - HALF_PI;
};
export default function AttractButton({
className,
containerClassName,
children,
density = 1,
radius = 64,
disabled,
onPointerDown,
onFocus,
onBlur,
...props
}: AttractButtonProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const controlsRef = useRef<Controls | null>(null);
const disabledRef = useRef(Boolean(disabled));
disabledRef.current = Boolean(disabled);
useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!(wrapper && canvas && ctx)) {
return;
}
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
const spacing = BASE_SPACING / Math.max(0.25, density);
let filings: Filing[] = [];
let width = 0;
let height = 0;
let box = { left: 0, top: 0, right: 0, bottom: 0, cx: 0, cy: 0 };
let ink = "currentColor";
let raf = 0;
let last = 0;
let visible = true;
let reduced = motionQuery.matches;
const pointer = { x: 0, y: 0 };
let presence = 0;
let presenceTarget = 0;
let pull = 0;
let pullTarget = 0;
let focused = false;
let touchId: number | null = null;
const readInk = () => {
ink = getComputedStyle(canvas).color;
};
const layout = () => {
const bw = wrapper.offsetWidth;
const bh = wrapper.offsetHeight;
width = bw + radius * 2;
height = bh + radius * 2;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
box = {
left: radius,
top: radius,
right: radius + bw,
bottom: radius + bh,
cx: radius + bw / 2,
cy: radius + bh / 2,
};
// A bar magnet laid along the button: north pole right, south pole left.
const poleOffset = Math.max(bw / 2 - bh / 3, bw / 4);
const nx = box.cx + poleOffset;
const sx = box.cx - poleOffset;
filings = [];
const cols = Math.ceil(width / spacing);
const rows = Math.ceil(height / spacing);
for (let row = 0; row <= rows; row++) {
for (let col = 0; col <= cols; col++) {
const x = col * spacing + (hash(col, row, 1) - 0.5) * spacing * 0.45;
const y = row * spacing + (hash(col, row, 2) - 0.5) * spacing * 0.45;
const dx = Math.max(box.left - x, 0, x - box.right);
const dy = Math.max(box.top - y, 0, y - box.bottom);
const edge = Math.hypot(dx, dy);
const t = edge / radius;
if (edge < CLEARANCE || t > 1) {
continue;
}
const ndx = x - nx;
const ndy = y - box.cy;
const sdx = x - sx;
const sdy = y - box.cy;
const nd3 = Math.max(Math.hypot(ndx, ndy) ** 3, 1);
const sd3 = Math.max(Math.hypot(sdx, sdy) ** 3, 1);
const bx = ndx / nd3 - sdx / sd3;
const by = ndy / nd3 - sdy / sd3;
const rest = Math.atan2(by, bx);
const cdx = box.cx - x;
const cdy = box.cy - y;
const cd = Math.hypot(cdx, cdy) || 1;
filings.push({
x,
y,
restX: Math.cos(rest * 2),
restY: Math.sin(rest * 2),
inX: cdx / cd,
inY: cdy / cd,
t,
fade: 1 - smoothstep(0.4, 1, t),
len: FILING_LENGTH * (0.8 + hash(col, row, 3) * 0.4),
theta: rest,
omega: 0,
near: 0,
});
}
}
};
const targetAngle = (f: Filing) => {
let vx = f.restX;
let vy = f.restY;
f.near = 0;
if (pull > 0.001) {
const w = pull * 0.6 * (1 - f.t * 0.4);
const a = Math.atan2(f.inY, f.inX) * 2;
vx = vx * (1 - w) + Math.cos(a) * w;
vy = vy * (1 - w) + Math.sin(a) * w;
}
if (presence > 0.001) {
const dx = pointer.x - f.x;
const dy = pointer.y - f.y;
const sigma = radius * 0.75;
const w = presence * 0.92 * Math.exp(-(dx * dx + dy * dy) / sigma ** 2);
f.near = w;
if (w > 0.001) {
const a = Math.atan2(dy, dx) * 2;
vx = vx * (1 - w) + Math.cos(a) * w;
vy = vy * (1 - w) + Math.sin(a) * w;
}
}
return Math.atan2(vy, vx) / 2;
};
const draw = () => {
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = ink;
ctx.lineWidth = LINE_WIDTH;
ctx.lineCap = "round";
for (const f of filings) {
const alpha = f.fade * (REST_ALPHA + f.near * 0.4 + pull * 0.2);
if (alpha < 0.01) {
continue;
}
const shift = pull * 2.5 * (1 - f.t);
const x = f.x + f.inX * shift;
const y = f.y + f.inY * shift;
const hx = (Math.cos(f.theta) * f.len) / 2;
const hy = (Math.sin(f.theta) * f.len) / 2;
ctx.globalAlpha = Math.min(1, alpha);
ctx.beginPath();
ctx.moveTo(x - hx, y - hy);
ctx.lineTo(x + hx, y + hy);
ctx.stroke();
}
ctx.globalAlpha = 1;
};
const ease = (value: number, target: number, dt: number, tau: number) => {
const next = value + (target - value) * (1 - Math.exp(-dt / tau));
return Math.abs(target - next) < 0.001 ? target : next;
};
const step = (now: number) => {
const dt = Math.min((now - last) / 1000, 1 / 30);
last = now;
presence = ease(presence, presenceTarget, dt, PRESENCE_TAU);
pull = ease(pull, pullTarget, dt, PULL_TAU);
let active = presence !== presenceTarget || pull !== pullTarget;
for (const f of filings) {
const diff = lineDelta(targetAngle(f), f.theta);
f.omega += (STIFFNESS * diff - DAMPING * f.omega) * dt;
f.theta += f.omega * dt;
if (Math.abs(diff) > 0.002 || Math.abs(f.omega) > 0.02) {
active = true;
}
}
draw();
raf = active ? requestAnimationFrame(step) : 0;
};
const kick = () => {
if (reduced || !visible || raf) {
return;
}
last = performance.now();
raf = requestAnimationFrame(step);
};
const settleNow = () => {
cancelAnimationFrame(raf);
raf = 0;
presence = 0;
presenceTarget = 0;
pullTarget = focused && !disabledRef.current ? 1 : 0;
pull = pullTarget;
for (const f of filings) {
f.theta = Math.atan2(f.restY, f.restX) / 2;
f.omega = 0;
f.near = 0;
}
draw();
};
const track = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const inField = x >= 0 && y >= 0 && x <= width && y <= height;
const inButton =
x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
if (inField) {
pointer.x = x;
pointer.y = y;
}
const nextPresence = inField ? 1 : 0;
const nextPull = (inButton || focused) && !disabledRef.current ? 1 : 0;
if (
inField ||
nextPresence !== presenceTarget ||
nextPull !== pullTarget
) {
presenceTarget = nextPresence;
pullTarget = nextPull;
kick();
}
};
const release = () => {
presenceTarget = 0;
pullTarget = focused && !disabledRef.current ? 1 : 0;
kick();
};
const handleMove = (event: PointerEvent) => {
if (event.pointerType === "touch") {
if (event.pointerId === touchId) {
track(event.clientX, event.clientY);
}
return;
}
track(event.clientX, event.clientY);
};
const handleUp = (event: PointerEvent) => {
if (event.pointerId === touchId) {
touchId = null;
release();
}
};
const handleOut = (event: PointerEvent) => {
if (!event.relatedTarget && event.pointerType !== "touch") {
release();
}
};
let listening = false;
const listen = (on: boolean) => {
if (on === listening) {
return;
}
listening = on;
if (on) {
window.addEventListener("pointermove", handleMove, { passive: true });
window.addEventListener("pointerup", handleUp);
window.addEventListener("pointercancel", handleUp);
document.addEventListener("pointerout", handleOut);
window.addEventListener("blur", release);
return;
}
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp);
document.removeEventListener("pointerout", handleOut);
window.removeEventListener("blur", release);
};
controlsRef.current = {
setFocus: (on) => {
focused = on;
if (on && presenceTarget === 0) {
pointer.x = box.cx;
pointer.y = box.cy;
}
pullTarget = on && !disabledRef.current ? 1 : 0;
kick();
},
touchStart: (event) => {
if (reduced) {
return;
}
touchId = event.pointerId;
track(event.clientX, event.clientY);
},
};
const redraw = () => {
readInk();
draw();
};
const resizeObserver = new ResizeObserver(() => {
layout();
readInk();
if (reduced) {
draw();
} else {
settleNow();
}
});
resizeObserver.observe(wrapper);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
listen(visible && !reduced);
if (!visible) {
settleNow();
}
});
intersectionObserver.observe(canvas);
const handleMotionChange = () => {
reduced = motionQuery.matches;
listen(visible && !reduced);
settleNow();
};
motionQuery.addEventListener("change", handleMotionChange);
// Repaint in the new ink when the theme changes, even while settled.
darkQuery.addEventListener("change", redraw);
const themeObserver = new MutationObserver(redraw);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
return () => {
cancelAnimationFrame(raf);
listen(false);
resizeObserver.disconnect();
intersectionObserver.disconnect();
themeObserver.disconnect();
motionQuery.removeEventListener("change", handleMotionChange);
darkQuery.removeEventListener("change", redraw);
controlsRef.current = null;
};
}, [density, radius]);
// Touch and pen-on-screen run the field only while pressed.
const handlePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
onPointerDown?.(event);
if (event.pointerType === "touch") {
controlsRef.current?.touchStart(event.nativeEvent);
}
};
// Keyboard focus pulls the field in too; mouse focus does not hold it.
const handleFocus = (event: FocusEvent<HTMLButtonElement>) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) {
controlsRef.current?.setFocus(true);
}
};
const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {
onBlur?.(event);
controlsRef.current?.setFocus(false);
};
return (
<div
className={cn("relative inline-flex", containerClassName)}
ref={wrapperRef}
>
<span
aria-hidden="true"
className="pointer-events-none absolute"
style={{ inset: -radius }}
>
<canvas
className="block size-full text-zinc-900 dark:text-zinc-400"
ref={canvasRef}
/>
</span>
<button
type="button"
{...props}
className={cn(
"relative inline-flex h-10 min-w-36 cursor-pointer select-none items-center justify-center gap-2 rounded-md px-4",
"font-medium text-sm",
"bg-zinc-900 text-zinc-50 hover:bg-zinc-800 active:bg-zinc-700",
"dark:bg-zinc-100 dark:text-zinc-900 dark:active:bg-zinc-300 dark:hover:bg-white",
"transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
"outline-hidden focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-zinc-100 dark:focus-visible:ring-offset-zinc-950",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={disabled}
onBlur={handleBlur}
onFocus={handleFocus}
onPointerDown={handlePointerDown}
>
<Magnet aria-hidden="true" className="size-4 shrink-0" />
{children ?? "Get started"}
</button>
</div>
);
}
Attract Button
By Kokonut UI
Try the interaction, copy the React source files, or download the complete example.
About Attract Button
Attract Button is an animated React and Tailwind CSS button by Kokonut UI. Try the interaction, copy the React source, or download the example with creator credit.
- Effects and categories
- Hover effects, Animated
- Using this example
- Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.
Usage, Tailwind and licensing questions
Read the source files
import-examples/attract-button.tsx
import AttractButton from "@/components/kokonutui/attract-button";
export default function ImportedDemo(){return <>
<AttractButton>Connect account</AttractButton>
</>}components/kokonutui/attract-button.tsx
"use client";
/**
* @author: @dorianbaffier
* @description: Attract Button
* @version: 2.0.0
* @date: 2026-10-16
* @license: MIT
* @website: https://kokonutui.com
* @github: https://github.com/kokonut-labs/kokonutui
*/
import {Magnet} from "lucide-react";
import {
type ComponentProps,
type FocusEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useRef,
} from "react";
import { cn } from "@/lib/utils";
interface AttractButtonProps extends ComponentProps<"button"> {
/** Filing density. 1 places a filing roughly every 12px; 2 doubles it. */
density?: number;
/** How far, in px, the field of filings extends past the button's edges. */
radius?: number;
/** Classes for the wrapper that holds the button and its field. */
containerClassName?: string;
}
interface Filing {
x: number;
y: number;
/** Rest direction in doubled-angle space (filings have no head or tail). */
restX: number;
restY: number;
/** Unit vector from the filing toward the button's center. */
inX: number;
inY: number;
/** 0 at the button's edge, 1 at the field's outer edge. */
t: number;
fade: number;
len: number;
theta: number;
omega: number;
near: number;
}
interface Controls {
setFocus: (on: boolean) => void;
touchStart: (event: PointerEvent) => void;
}
const BASE_SPACING = 12;
const FILING_LENGTH = 7;
const LINE_WIDTH = 1.25;
const CLEARANCE = 6;
// Rotational spring: slightly under-damped so filings settle with a small,
// physical overshoot rather than snapping.
const STIFFNESS = 240;
const DAMPING = 21;
const PRESENCE_TAU = 0.07;
const PULL_TAU = 0.09;
const REST_ALPHA = 0.3;
const HALF_PI = Math.PI / 2;
const hash = (x: number, y: number, seed: number) => {
const s = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43_758.5453;
return s - Math.floor(s);
};
const smoothstep = (a: number, b: number, v: number) => {
const k = Math.min(1, Math.max(0, (v - a) / (b - a)));
return k * k * (3 - 2 * k);
};
/** Shortest difference between two undirected angles, in (-π/2, π/2]. */
const lineDelta = (target: number, current: number) => {
const d = (target - current + HALF_PI) % Math.PI;
return (d < 0 ? d + Math.PI : d) - HALF_PI;
};
export default function AttractButton({
className,
containerClassName,
children,
density = 1,
radius = 64,
disabled,
onPointerDown,
onFocus,
onBlur,
...props
}: AttractButtonProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const controlsRef = useRef<Controls | null>(null);
const disabledRef = useRef(Boolean(disabled));
disabledRef.current = Boolean(disabled);
useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!(wrapper && canvas && ctx)) {
return;
}
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
const spacing = BASE_SPACING / Math.max(0.25, density);
let filings: Filing[] = [];
let width = 0;
let height = 0;
let box = { left: 0, top: 0, right: 0, bottom: 0, cx: 0, cy: 0 };
let ink = "currentColor";
let raf = 0;
let last = 0;
let visible = true;
let reduced = motionQuery.matches;
const pointer = { x: 0, y: 0 };
let presence = 0;
let presenceTarget = 0;
let pull = 0;
let pullTarget = 0;
let focused = false;
let touchId: number | null = null;
const readInk = () => {
ink = getComputedStyle(canvas).color;
};
const layout = () => {
const bw = wrapper.offsetWidth;
const bh = wrapper.offsetHeight;
width = bw + radius * 2;
height = bh + radius * 2;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
box = {
left: radius,
top: radius,
right: radius + bw,
bottom: radius + bh,
cx: radius + bw / 2,
cy: radius + bh / 2,
};
// A bar magnet laid along the button: north pole right, south pole left.
const poleOffset = Math.max(bw / 2 - bh / 3, bw / 4);
const nx = box.cx + poleOffset;
const sx = box.cx - poleOffset;
filings = [];
const cols = Math.ceil(width / spacing);
const rows = Math.ceil(height / spacing);
for (let row = 0; row <= rows; row++) {
for (let col = 0; col <= cols; col++) {
const x = col * spacing + (hash(col, row, 1) - 0.5) * spacing * 0.45;
const y = row * spacing + (hash(col, row, 2) - 0.5) * spacing * 0.45;
const dx = Math.max(box.left - x, 0, x - box.right);
const dy = Math.max(box.top - y, 0, y - box.bottom);
const edge = Math.hypot(dx, dy);
const t = edge / radius;
if (edge < CLEARANCE || t > 1) {
continue;
}
const ndx = x - nx;
const ndy = y - box.cy;
const sdx = x - sx;
const sdy = y - box.cy;
const nd3 = Math.max(Math.hypot(ndx, ndy) ** 3, 1);
const sd3 = Math.max(Math.hypot(sdx, sdy) ** 3, 1);
const bx = ndx / nd3 - sdx / sd3;
const by = ndy / nd3 - sdy / sd3;
const rest = Math.atan2(by, bx);
const cdx = box.cx - x;
const cdy = box.cy - y;
const cd = Math.hypot(cdx, cdy) || 1;
filings.push({
x,
y,
restX: Math.cos(rest * 2),
restY: Math.sin(rest * 2),
inX: cdx / cd,
inY: cdy / cd,
t,
fade: 1 - smoothstep(0.4, 1, t),
len: FILING_LENGTH * (0.8 + hash(col, row, 3) * 0.4),
theta: rest,
omega: 0,
near: 0,
});
}
}
};
const targetAngle = (f: Filing) => {
let vx = f.restX;
let vy = f.restY;
f.near = 0;
if (pull > 0.001) {
const w = pull * 0.6 * (1 - f.t * 0.4);
const a = Math.atan2(f.inY, f.inX) * 2;
vx = vx * (1 - w) + Math.cos(a) * w;
vy = vy * (1 - w) + Math.sin(a) * w;
}
if (presence > 0.001) {
const dx = pointer.x - f.x;
const dy = pointer.y - f.y;
const sigma = radius * 0.75;
const w = presence * 0.92 * Math.exp(-(dx * dx + dy * dy) / sigma ** 2);
f.near = w;
if (w > 0.001) {
const a = Math.atan2(dy, dx) * 2;
vx = vx * (1 - w) + Math.cos(a) * w;
vy = vy * (1 - w) + Math.sin(a) * w;
}
}
return Math.atan2(vy, vx) / 2;
};
const draw = () => {
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = ink;
ctx.lineWidth = LINE_WIDTH;
ctx.lineCap = "round";
for (const f of filings) {
const alpha = f.fade * (REST_ALPHA + f.near * 0.4 + pull * 0.2);
if (alpha < 0.01) {
continue;
}
const shift = pull * 2.5 * (1 - f.t);
const x = f.x + f.inX * shift;
const y = f.y + f.inY * shift;
const hx = (Math.cos(f.theta) * f.len) / 2;
const hy = (Math.sin(f.theta) * f.len) / 2;
ctx.globalAlpha = Math.min(1, alpha);
ctx.beginPath();
ctx.moveTo(x - hx, y - hy);
ctx.lineTo(x + hx, y + hy);
ctx.stroke();
}
ctx.globalAlpha = 1;
};
const ease = (value: number, target: number, dt: number, tau: number) => {
const next = value + (target - value) * (1 - Math.exp(-dt / tau));
return Math.abs(target - next) < 0.001 ? target : next;
};
const step = (now: number) => {
const dt = Math.min((now - last) / 1000, 1 / 30);
last = now;
presence = ease(presence, presenceTarget, dt, PRESENCE_TAU);
pull = ease(pull, pullTarget, dt, PULL_TAU);
let active = presence !== presenceTarget || pull !== pullTarget;
for (const f of filings) {
const diff = lineDelta(targetAngle(f), f.theta);
f.omega += (STIFFNESS * diff - DAMPING * f.omega) * dt;
f.theta += f.omega * dt;
if (Math.abs(diff) > 0.002 || Math.abs(f.omega) > 0.02) {
active = true;
}
}
draw();
raf = active ? requestAnimationFrame(step) : 0;
};
const kick = () => {
if (reduced || !visible || raf) {
return;
}
last = performance.now();
raf = requestAnimationFrame(step);
};
const settleNow = () => {
cancelAnimationFrame(raf);
raf = 0;
presence = 0;
presenceTarget = 0;
pullTarget = focused && !disabledRef.current ? 1 : 0;
pull = pullTarget;
for (const f of filings) {
f.theta = Math.atan2(f.restY, f.restX) / 2;
f.omega = 0;
f.near = 0;
}
draw();
};
const track = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const inField = x >= 0 && y >= 0 && x <= width && y <= height;
const inButton =
x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
if (inField) {
pointer.x = x;
pointer.y = y;
}
const nextPresence = inField ? 1 : 0;
const nextPull = (inButton || focused) && !disabledRef.current ? 1 : 0;
if (
inField ||
nextPresence !== presenceTarget ||
nextPull !== pullTarget
) {
presenceTarget = nextPresence;
pullTarget = nextPull;
kick();
}
};
const release = () => {
presenceTarget = 0;
pullTarget = focused && !disabledRef.current ? 1 : 0;
kick();
};
const handleMove = (event: PointerEvent) => {
if (event.pointerType === "touch") {
if (event.pointerId === touchId) {
track(event.clientX, event.clientY);
}
return;
}
track(event.clientX, event.clientY);
};
const handleUp = (event: PointerEvent) => {
if (event.pointerId === touchId) {
touchId = null;
release();
}
};
const handleOut = (event: PointerEvent) => {
if (!event.relatedTarget && event.pointerType !== "touch") {
release();
}
};
let listening = false;
const listen = (on: boolean) => {
if (on === listening) {
return;
}
listening = on;
if (on) {
window.addEventListener("pointermove", handleMove, { passive: true });
window.addEventListener("pointerup", handleUp);
window.addEventListener("pointercancel", handleUp);
document.addEventListener("pointerout", handleOut);
window.addEventListener("blur", release);
return;
}
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp);
document.removeEventListener("pointerout", handleOut);
window.removeEventListener("blur", release);
};
controlsRef.current = {
setFocus: (on) => {
focused = on;
if (on && presenceTarget === 0) {
pointer.x = box.cx;
pointer.y = box.cy;
}
pullTarget = on && !disabledRef.current ? 1 : 0;
kick();
},
touchStart: (event) => {
if (reduced) {
return;
}
touchId = event.pointerId;
track(event.clientX, event.clientY);
},
};
const redraw = () => {
readInk();
draw();
};
const resizeObserver = new ResizeObserver(() => {
layout();
readInk();
if (reduced) {
draw();
} else {
settleNow();
}
});
resizeObserver.observe(wrapper);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
listen(visible && !reduced);
if (!visible) {
settleNow();
}
});
intersectionObserver.observe(canvas);
const handleMotionChange = () => {
reduced = motionQuery.matches;
listen(visible && !reduced);
settleNow();
};
motionQuery.addEventListener("change", handleMotionChange);
// Repaint in the new ink when the theme changes, even while settled.
darkQuery.addEventListener("change", redraw);
const themeObserver = new MutationObserver(redraw);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
return () => {
cancelAnimationFrame(raf);
listen(false);
resizeObserver.disconnect();
intersectionObserver.disconnect();
themeObserver.disconnect();
motionQuery.removeEventListener("change", handleMotionChange);
darkQuery.removeEventListener("change", redraw);
controlsRef.current = null;
};
}, [density, radius]);
// Touch and pen-on-screen run the field only while pressed.
const handlePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
onPointerDown?.(event);
if (event.pointerType === "touch") {
controlsRef.current?.touchStart(event.nativeEvent);
}
};
// Keyboard focus pulls the field in too; mouse focus does not hold it.
const handleFocus = (event: FocusEvent<HTMLButtonElement>) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) {
controlsRef.current?.setFocus(true);
}
};
const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {
onBlur?.(event);
controlsRef.current?.setFocus(false);
};
return (
<div
className={cn("relative inline-flex", containerClassName)}
ref={wrapperRef}
>
<span
aria-hidden="true"
className="pointer-events-none absolute"
style={{ inset: -radius }}
>
<canvas
className="block size-full text-zinc-900 dark:text-zinc-400"
ref={canvasRef}
/>
</span>
<button
type="button"
{...props}
className={cn(
"relative inline-flex h-10 min-w-36 cursor-pointer select-none items-center justify-center gap-2 rounded-md px-4",
"font-medium text-sm",
"bg-zinc-900 text-zinc-50 hover:bg-zinc-800 active:bg-zinc-700",
"dark:bg-zinc-100 dark:text-zinc-900 dark:active:bg-zinc-300 dark:hover:bg-white",
"transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
"outline-hidden focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-zinc-100 dark:focus-visible:ring-offset-zinc-950",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={disabled}
onBlur={handleBlur}
onFocus={handleFocus}
onPointerDown={handlePointerDown}
>
<Magnet aria-hidden="true" className="size-4 shrink-0" />
{children ?? "Get started"}
</button>
</div>
);
}
lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
demo.tsx
export { default as default } from "@/import-examples/attract-button";CSS
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
"Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-zinc-50: oklch(98.5% 0 none);
--color-zinc-100: oklch(96.7% 0.001 286.375);
--color-zinc-300: oklch(87.1% 0.006 286.286);
--color-zinc-400: oklch(70.5% 0.015 286.067);
--color-zinc-700: oklch(37% 0.013 285.805);
--color-zinc-800: oklch(27.4% 0.006 286.033);
--color-zinc-900: oklch(21% 0.006 285.885);
--color-zinc-950: oklch(14.1% 0.005 285.823);
--color-white: #fff;
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--font-weight-medium: 500;
--radius-md: calc(var(--radius) - 2px);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.pointer-events-none {
pointer-events: none;
}
.\!visible {
visibility: visible !important;
}
.visible {
visibility: visible;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.block {
display: block;
}
.inline-flex {
display: inline-flex;
}
.size-4 {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.size-full {
width: 100%;
height: 100%;
}
.h-10 {
height: calc(var(--spacing) * 10);
}
.min-w-36 {
min-width: calc(var(--spacing) * 36);
}
.shrink-0 {
flex-shrink: 0;
}
.cursor-pointer {
cursor: pointer;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.rounded-md {
border-radius: var(--radius-md);
}
.bg-zinc-900 {
background-color: var(--color-zinc-900);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.text-zinc-50 {
color: var(--color-zinc-50);
}
.text-zinc-900 {
color: var(--color-zinc-900);
}
.outline-hidden {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.transition-colors {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-150 {
--tw-duration: 150ms;
transition-duration: 150ms;
}
.ease-\[cubic-bezier\(0\.23\,1\,0\.32\,1\)\] {
--tw-ease: cubic-bezier(0.23,1,0.32,1);
transition-timing-function: cubic-bezier(0.23,1,0.32,1);
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
@media (hover: hover) {
.hover\:bg-zinc-800:hover {
background-color: var(--color-zinc-800);
}
}
.focus-visible\:ring-2:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.focus-visible\:ring-zinc-900:focus-visible {
--tw-ring-color: var(--color-zinc-900);
}
.focus-visible\:ring-offset-2:focus-visible {
--tw-ring-offset-width: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
}
.focus-visible\:ring-offset-white:focus-visible {
--tw-ring-offset-color: var(--color-white);
}
.active\:bg-zinc-700:active {
background-color: var(--color-zinc-700);
}
.disabled\:pointer-events-none:disabled {
pointer-events: none;
}
.disabled\:opacity-50:disabled {
opacity: 50%;
}
.dark\:bg-zinc-100:where(.dark, .dark *) {
background-color: var(--color-zinc-100);
}
.dark\:text-zinc-400:where(.dark, .dark *) {
color: var(--color-zinc-400);
}
.dark\:text-zinc-900:where(.dark, .dark *) {
color: var(--color-zinc-900);
}
@media (hover: hover) {
.dark\:hover\:bg-white:where(.dark, .dark *):hover {
background-color: var(--color-white);
}
}
.dark\:focus-visible\:ring-zinc-100:where(.dark, .dark *):focus-visible {
--tw-ring-color: var(--color-zinc-100);
}
.dark\:focus-visible\:ring-offset-zinc-950:where(.dark, .dark *):focus-visible {
--tw-ring-offset-color: var(--color-zinc-950);
}
.dark\:active\:bg-zinc-300:where(.dark, .dark *):active {
background-color: var(--color-zinc-300);
}
}
:root {
--background: #fff;
--foreground: #111;
--primary: #171717;
--primary-foreground: #fff;
--secondary: #eee;
--secondary-foreground: #171717;
--muted: #eee;
--muted-foreground: #666;
--accent: #eee;
--accent-foreground: #171717;
--border: #ddd;
--input: #ddd;
--ring: #777;
--destructive: #dc2626;
--destructive-foreground: #fff;
--popover: #fff;
--popover-foreground: #111;
--success: #15803d;
--success-foreground: #fff;
--radius: .625rem;
--hairline: 1px;
}
.dark {
--background: #090909;
--foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a3a3a3;
--accent: #262626;
--accent-foreground: #fafafa;
--border: #333;
--input: #333;
--popover: #171717;
--popover-foreground: #fafafa;
--color-fd-background: #000;
--color-fd-card: #0a0a0a;
--color-fd-popover: #0a0a0a;
--color-fd-border: rgb(255 255 255 / 0.14);
--color-fd-accent: rgb(255 255 255 / 0.07);
}
:root {
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
:root {
--leaf: #1fa834;
}
.dark {
--leaf: #3bb44b;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@property --tw-duration {
syntax: "*";
inherits: false;
}
@property --tw-ease {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-font-weight: initial;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-duration: initial;
--tw-ease: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
}
}
}