buttonWeek 01Added
Magnetic Button
A call-to-action button that drifts toward the pointer and lights up under it. Three variants, three sizes, icon slots, loading and disabled states.
Live preview
- hover
- focus-visible
- active
- disabled
- loading
- reduced motion
- touch
Quality checks
- Chromium · light passed
- Chromium · dark passed
- Firefox · light passed
- Firefox · dark passed
- WebKit · light passed
- WebKit · dark passed
Each run loads the page at 375, 768 and 1280 px (no horizontal overflow), fails on any console error and runs an axe WCAG 2.1 AA scan. Accessibility violations across all runs: 0 · last run .
Code
import { MagneticButton } from "@/components/ui/magnetic-button";
export function Hero() {
const [saving, setSaving] = useState(false);
return (
<div className="flex gap-3">
<MagneticButton size="lg" rightIcon={<ArrowRight />}>
Get started
</MagneticButton>
<MagneticButton
variant="outline"
loading={saving}
loadingLabel="Saving changes"
onClick={() => setSaving(true)}
>
Save changes
</MagneticButton>
</div>
);
}"use client";
import {
forwardRef,
useCallback,
useEffect,
useRef,
type ButtonHTMLAttributes,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/cn";
export type MagneticButtonVariant = "solid" | "outline" | "ghost";
export type MagneticButtonSize = "sm" | "md" | "lg";
export interface MagneticButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style of the button. */
variant?: MagneticButtonVariant;
/** Height, padding and font size. */
size?: MagneticButtonSize;
/** How far (px) the button can drift toward the pointer. 0 turns the magnet off. */
strength?: number;
/** Shows a spinner, sets aria-busy and blocks clicks. */
loading?: boolean;
/** Text read by screen readers while loading. */
loadingLabel?: string;
/** Icon placed before the label. */
leftIcon?: ReactNode;
/** Icon placed after the label. */
rightIcon?: ReactNode;
/** Stretch to the width of the parent. */
fullWidth?: boolean;
}
const variants: Record<MagneticButtonVariant, string> = {
solid:
"bg-accent text-accent-foreground shadow-[0_8px_24px_-8px_var(--accent)] hover:shadow-[0_12px_32px_-8px_var(--accent)]",
outline:
"border border-border bg-surface text-foreground hover:border-accent/60",
ghost: "bg-transparent text-foreground hover:bg-surface-2",
};
const sizes: Record<MagneticButtonSize, string> = {
sm: "h-9 px-4 text-sm gap-1.5",
md: "h-11 px-6 text-sm gap-2",
lg: "h-13 px-8 text-base gap-2.5",
};
/**
* A button that drifts toward the pointer and lights up where the pointer is.
* The magnet and glow switch off for touch input, disabled/loading states and
* users who prefer reduced motion.
*/
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(
function MagneticButton(
{
variant = "solid",
size = "md",
strength = 14,
loading = false,
loadingLabel = "Loading",
leftIcon,
rightIcon,
fullWidth = false,
disabled,
className,
children,
onPointerMove,
onPointerLeave,
onClick,
type = "button",
...rest
},
forwardedRef,
) {
const innerRef = useRef<HTMLButtonElement | null>(null);
const frame = useRef<number | null>(null);
const reducedMotion = useRef(false);
const inactive = disabled || loading;
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => (reducedMotion.current = query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => () => {
if (frame.current) cancelAnimationFrame(frame.current);
}, []);
const setRefs = useCallback(
(node: HTMLButtonElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const reset = () => {
const el = innerRef.current;
if (!el) return;
el.style.setProperty("--mx", "0px");
el.style.setProperty("--my", "0px");
el.style.setProperty("--glow", "0");
};
useEffect(() => {
if (inactive) reset();
}, [inactive]);
const handleMove = (event: PointerEvent<HTMLButtonElement>) => {
onPointerMove?.(event);
if (inactive || event.pointerType === "touch") return;
const el = event.currentTarget;
const rect = el.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const dx = (px / rect.width - 0.5) * 2;
const dy = (py / rect.height - 0.5) * 2;
const pull = reducedMotion.current ? 0 : strength;
if (frame.current) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
el.style.setProperty("--mx", `${dx * pull}px`);
el.style.setProperty("--my", `${dy * pull * 0.6}px`);
el.style.setProperty("--gx", `${px}px`);
el.style.setProperty("--gy", `${py}px`);
el.style.setProperty("--glow", "1");
});
};
const handleLeave = (event: PointerEvent<HTMLButtonElement>) => {
onPointerLeave?.(event);
if (frame.current) cancelAnimationFrame(frame.current);
reset();
};
return (
<button
ref={setRefs}
type={type}
disabled={disabled}
aria-disabled={inactive || undefined}
aria-busy={loading || undefined}
onPointerMove={handleMove}
onPointerLeave={handleLeave}
onClick={(event) => {
if (loading) {
event.preventDefault();
return;
}
onClick?.(event);
}}
className={cn(
"group relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-full font-medium",
"translate-x-(--mx) translate-y-(--my) [--mx:0px] [--my:0px] [--glow:0]",
"transition-[translate,box-shadow,background-color,border-color,scale] duration-300 ease-out",
"active:scale-[0.96] motion-reduce:transition-none",
"focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ring",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none",
loading && "cursor-progress",
variants[variant],
sizes[size],
fullWidth && "w-full",
className,
)}
{...rest}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-(--glow) transition-opacity duration-300"
style={{
background:
"radial-gradient(120px circle at var(--gx, 50%) var(--gy, 50%), color-mix(in oklab, white 35%, transparent), transparent 70%)",
}}
/>
<span
className={cn(
"inline-flex items-center gap-[inherit] transition-opacity",
loading && "opacity-0",
)}
>
{leftIcon ? <span aria-hidden className="shrink-0">{leftIcon}</span> : null}
<span>{children}</span>
{rightIcon ? (
<span
aria-hidden
className="shrink-0 transition-transform duration-300 group-hover:translate-x-0.5"
>
{rightIcon}
</span>
) : null}
</span>
{loading ? (
<span className="absolute inset-0 grid place-items-center">
<span
aria-hidden
className="size-4 animate-spin rounded-full border-2 border-current border-r-transparent"
/>
<span className="sr-only">{loadingLabel}</span>
</span>
) : null}
</button>
);
},
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "solid" | "outline" | "ghost" | "solid" | Visual style. |
| size | "sm" | "md" | "lg" | "md" | Height, padding and font size. |
| strength | number | 14 | Max drift toward the pointer in px. 0 turns the magnet off. |
| loading | boolean | false | Shows a spinner, sets aria-busy and ignores clicks. |
| loadingLabel | string | "Loading" | Screen-reader text while loading. |
| leftIcon / rightIcon | ReactNode | — | Optional icons around the label. |
| fullWidth | boolean | false | Stretch to the parent's width. |
| ...rest | ButtonHTMLAttributes | — | Any native button prop (onClick, type, disabled, aria-*). |
Build prompt
The final prompt used to generate this component.
Build a reusable React + TypeScript + Tailwind CSS v4 "MagneticButton" component for a Next.js App Router project.
Requirements:
- Client component that forwards its ref and spreads all native <button> props.
- Props (typed interface): variant ("solid" | "outline" | "ghost"), size ("sm" | "md" | "lg"), strength (px the button drifts toward the pointer, 0 = off), loading, loadingLabel, leftIcon, rightIcon, fullWidth.
- Magnetic effect: on pointer move, translate the button toward the cursor (x by strength, y by 60% of it) using CSS custom properties updated inside requestAnimationFrame; reset on pointer leave. A soft radial "glow" follows the cursor inside the button.
- Skip the magnet and glow for touch pointers, when disabled or loading, and when prefers-reduced-motion is set (listen for changes).
- States: hover, focus-visible ring with offset, active press scale, disabled (opacity + not-allowed cursor), loading (spinner centred over a hidden label, aria-busy, clicks ignored, sr-only loading text).
- Use theme tokens (bg-accent, text-accent-foreground, border-border, bg-surface) so it works in light and dark mode.
- No external dependencies. Keep it accessible and fully responsive; add a fullWidth option for mobile layouts.
Also write a demo showing all variants, sizes, a working async loading example, a disabled button, strength={0}, and a full-width button.Build notes
Problems found while building and testing it, how they were fixed, and the decisions that shaped it.
- Touch screens fire pointer moves while you scroll, which made the button jump on phones. The magnet now ignores pointerType "touch".
- Pointer moves write CSS variables inside requestAnimationFrame instead of React state, so the button never re-renders while it follows the cursor.
- The code samples on this page first used Shiki's default GitHub theme. The axe scan failed it at 4.09:1 contrast in Chromium and WebKit, so the site switched to the high-contrast GitHub themes.