Skip to content
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>
  );
}

Props

PropTypeDefaultDescription
variant"solid" | "outline" | "ghost""solid"Visual style.
size"sm" | "md" | "lg""md"Height, padding and font size.
strengthnumber14Max drift toward the pointer in px. 0 turns the magnet off.
loadingbooleanfalseShows a spinner, sets aria-busy and ignores clicks.
loadingLabelstring"Loading"Screen-reader text while loading.
leftIcon / rightIconReactNode—Optional icons around the label.
fullWidthbooleanfalseStretch to the parent's width.
...restButtonHTMLAttributes—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.