Magnetic Button

Three pill buttons attract toward the pointer inside an invisible radius, their labels leading slightly for parallax, flood with colour from the entry point, and spring back with an elastic snap on leave.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1331 words of exact values — rewritten live as you tune the Playground.

prompt.md · Magnetic Button
### EFFECT: Magnetic Button
**What it does (visual):** A dark stage with a small title top-left and three pill buttons centred in the middle: an outlined hairline pill ("View the archive"), a large primary pill in the accent colour #d4ff3a ("Start a project →") and a 64px circular icon button ("↓"). Each button sits inside an invisible halo 48–72px (× 1) wider than itself. When the pointer enters a halo, the button starts drifting toward the pointer — it moves 0.3 of the distance between its centre and the pointer, and its label moves 0.5, so the text leads the pill slightly (a parallax feel). Movement is smooth and slightly lagged (0.6s `power3.out` catch-up). When the pointer actually crosses onto the pill, a circle of the opposite colour blooms from exactly the point where the pointer entered and floods the pill in 0.5s (the accent pill floods with the page background and its label turns #d4ff3a; the outlined pill floods with the text colour; the icon circle floods #d4ff3a); the label colour inverts and the arrow nudges 4px right. When the pointer leaves the pill, the circle shrinks back toward the exit point; when it leaves the halo, the button and label spring back to rest over 1s with the `elastic.out(1, 0.4)` ease (a visible elastic wobble by default). On touch devices the buttons are static with a simple pressed state.

**Tech stack:**
- GSAP 3.13+ core only (`gsap.quickTo`, `elastic.out`).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: a sans for the labels (demo uses Inter Tight 500), a serif for the stage title, a mono for the hint.

**HTML structure:**
```html
<section class="mag-stage">
  <header class="mag-stage__head"><p class="mag-stage__label">Button · Magnetic</p><h1 class="mag-stage__title">Buttons that lean in</h1></header>
  <div class="mag-stage__row">
    <div class="mag mag--outline" data-radius="48">
      <button class="mag__btn mag__btn--outline" type="button"><span class="mag__fill" aria-hidden="true"></span><span class="mag__label">View the archive</span></button>
    </div>
    <div class="mag mag--primary" data-radius="72">
      <button class="mag__btn mag__btn--primary" type="button"><span class="mag__fill" aria-hidden="true"></span><span class="mag__label">Start a project <span class="mag__arrow" aria-hidden="true">→</span></span></button>
    </div>
    <div class="mag mag--icon" data-radius="56">
      <button class="mag__btn mag__btn--icon" type="button" aria-label="Scroll to next section"><span class="mag__fill" aria-hidden="true"></span><span class="mag__label" aria-hidden="true">↓</span></button>
    </div>
  </div>
  <p class="mag-stage__hint">Approach a button slowly — it starts moving before you touch it.</p>
</section>
```
`.mag` is the magnetic zone (the halo), `.mag__btn` the visible pill, `.mag__fill` the colour circle, `.mag__label` the text that leads.

**CSS requirements:**
- `.mag-stage`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden;` `.mag-stage__row`: `display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .5rem 1rem;`
- `.mag`: `position: relative; display: inline-flex; padding: var(--radius, 48px);` — JS copies `data-radius × 1` into `--radius`; the padding IS the attraction radius.
- `.mag__btn`: `position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .6em; border-radius: 999px; overflow: hidden; isolation: isolate; white-space: nowrap; font-weight: 500; will-change: transform; transition: color .35s cubic-bezier(.2,.7,.2,1);` Focus: `outline: 2px solid var(--accent, #d4ff3a); outline-offset: 4px` on `:focus-visible`.
- `.mag__label`: `position: relative; z-index: 1; display: inline-flex; gap: .5em; will-change: transform;` `.mag__arrow { transition: transform .35s }`, `.is-hover .mag__arrow { transform: translateX(4px) }`.
- `.mag__fill`: `position: absolute; left: 0; top: 0; width: var(--fill-size); height: var(--fill-size); border-radius: 50%; transform: translate(-50%, -50%) scale(0); pointer-events: none; z-index: 0;` — JS sets `--fill-size` to `ceil(max(width, height) × 2.2)px`.
- Variants: `.mag__btn--primary { padding: 1.25rem 2.5rem; font-size: 1.05rem; background: var(--accent, #d4ff3a); color: var(--bg) }` fill `background: var(--bg)` (the page colour floods the accent pill), `.is-hover { color: var(--accent, #d4ff3a) }`. `.mag__btn--outline { padding: 1rem 1.9rem; font-size: .95rem; border: 1px solid color-mix(in srgb, var(--text) 28%, transparent) }` fill `var(--text)`, `.is-hover { color: var(--bg) }`. `.mag__btn--icon { width: 64px; height: 64px; background: var(--surface); border: 1px solid var(--line) }` fill `var(--accent, #d4ff3a)`, `.is-hover { color: var(--bg) }`.
- `@media (hover: none), (pointer: coarse)`: `.mag { padding: .25rem }`, `.mag__fill { display: none }`, `.mag__btn:active { transform: scale(.97) }`.
- Under 768px: column layout for the row, stage padding `1.5rem 5vw`.

**Animation spec (exact values):**
1. Constants: `BUTTON_STRENGTH = 0.3`, `LABEL_STRENGTH = 0.5`, `RADIUS_SCALE = 1`, `FOLLOW = { duration: 0.6, ease: 'power3.out' }`, `RELEASE = { duration: 1, ease: 'elastic.out(1, 0.4)' }`.
2. Entrance: `gsap.from('.mag-stage__title', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`; `gsap.from('.mag', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, delay: 0.25 })` (animate the zone, not the button, so it never fights the magnet).
3. Setup per zone: `--radius` = `data-radius × 1` px; `--fill-size = ceil(max(btn.offsetWidth, btn.offsetHeight) * 2.2)`; `gsap.set(fill, { xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0 })`.
4. Inside `gsap.matchMedia().add('(hover: hover) and (pointer: fine)', …)` only:
   - Zone `pointerenter` → create setters: `bx = gsap.quickTo(btn, 'x', FOLLOW)`, `by = gsap.quickTo(btn, 'y', FOLLOW)`, `lx = gsap.quickTo(label, 'x', FOLLOW)`, `ly = gsap.quickTo(label, 'y', FOLLOW)`.
   - Zone `pointermove` → `cx = clientX − buttonCentreX`, `cy = clientY − buttonCentreY`; `bx(cx * 0.3); by(cy * 0.3); lx(cx * (0.5 − 0.3)); ly(cy * (0.5 − 0.3))` (so the label's total travel is 0.5 of the offset).
   - Zone `pointerleave` → kill all four `setter.tween`s, then `gsap.to([btn, label], { x: 0, y: 0, duration: 1, ease: 'elastic.out(1, 0.4)', overwrite: true })`.
   - Button `pointerenter` → local entry point `(x, y)` relative to the button's top-left; `btn.classList.add('is-hover')`; `gsap.killTweensOf(fill)`; `gsap.set(fill, { x, y })`; `gsap.fromTo(fill, { scale: 0 }, { scale: 1, duration: 0.5, ease: 'expo.out' })`.
   - Button `pointerleave` → exit point `(x, y)`; remove `is-hover`; `gsap.killTweensOf(fill)`; `gsap.to(fill, { x, y, scale: 0, duration: 0.45, ease: 'power2.inOut' })`.

**Interaction:** Hover-driven. Entering the halo starts the pull before the pointer reaches the pill; the pill follows at 0.3, the label at 0.5. Crossing onto the pill floods it from the entry point and inverts the label colour (CSS transition 0.35s). Leaving the pill drains the fill toward the exit point; leaving the halo releases the magnet with the `elastic.out(1, 0.4)` return. Click works normally (no preventDefault). Keyboard users see a 2px #d4ff3a focus ring 4px outside the pill.

**Responsive behavior:** `gsap.matchMedia('(hover: hover) and (pointer: fine)')` means no listeners are attached on touch or stylus-coarse devices; CSS collapses the halo, hides the fill and gives `:active` a 0.97 scale. Under 768px the three buttons stack vertically and the hint aligns left. Pill sizes are in rem so they scale with root font size; nothing is wider than 375px.

**Accessibility:** With `prefers-reduced-motion: reduce` no JS magnet or fill runs; CSS gives each variant an instant hover state (primary → page-background fill with #d4ff3a label, outline → filled, icon → #d4ff3a) with transitions disabled. Buttons are real `<button>`s; the icon button carries an `aria-label`; fills and arrows are `aria-hidden`. The hover colour inversion keeps contrast ≥ 7:1 in both themes.

**Performance rules:** Only `transform` is animated (`x`, `y`, `scale`); `will-change: transform` sits on `.mag__btn`, `.mag__label` and `.mag__fill`. `quickTo` setters reuse a single tween per property; they are killed on leave and recreated on enter so a plain `gsap.to` never overwrites a live quickTo. Everything is inside `gsap.context()`; `ctx.revert()` removes the listeners (via the matchMedia cleanup) and restores transforms on unmount.

**Customization knobs:**
- `data-radius` per button (48/72/56) × `RADIUS_SCALE` (1) — how far away the pull starts.
- `BUTTON_STRENGTH` (0.3) / `LABEL_STRENGTH` (0.5) — 0.15/0.25 is subtle, 0.5/0.7 is cartoonish.
- `FOLLOW.duration` (0.6) — lag of the pull; `RELEASE.ease` — `elastic.out(1, 0.4)` (current); `elastic.out(1, 0.4)` wobbles, `back.out(2)` overshoots once.
- Accent colour (`#d4ff3a`) — primary pill, icon fill, focus ring.
- Fill timings: bloom 0.5s `expo.out`, drain 0.45s `power2.inOut`; fill colour per variant via the `.mag__fill` background.
- `--fill-size` multiplier (2.2) — raise to 2.6 for very wide pills.

**Acceptance criteria:**
- [ ] Button begins moving when the pointer enters the halo, before touching the pill.
- [ ] Label visibly leads the pill (travels further) during the pull.
- [ ] Fill circle starts exactly at the pointer's entry point and fully covers the pill, including corners.
- [ ] Leaving the halo produces a `elastic.out(1, 0.4)` return that settles within ~1s with no residual offset.
- [ ] Touch devices and reduced-motion users get static buttons with a working hover/pressed state.
- [ ] No console errors; focus ring visible via keyboard.

Source

Full source code

<section class="mag-stage">
  <header class="mag-stage__head">
    <p class="mag-stage__label">Button · Magnetic</p>
    <h1 class="mag-stage__title">Buttons that lean in</h1>
  </header>

  <div class="mag-stage__row">
    <div class="mag mag--outline" data-radius="48">
      <button class="mag__btn mag__btn--outline" type="button">
        <span class="mag__fill" aria-hidden="true"></span>
        <span class="mag__label">View the archive</span>
      </button>
    </div>

    <div class="mag mag--primary" data-radius="72">
      <button class="mag__btn mag__btn--primary" type="button">
        <span class="mag__fill" aria-hidden="true"></span>
        <span class="mag__label">Start a project <span class="mag__arrow" aria-hidden="true">→</span></span>
      </button>
    </div>

    <div class="mag mag--icon" data-radius="56">
      <button class="mag__btn mag__btn--icon" type="button" aria-label="Scroll to next section">
        <span class="mag__fill" aria-hidden="true"></span>
        <span class="mag__label" aria-hidden="true">↓</span>
      </button>
    </div>
  </div>

  <p class="mag-stage__hint">Approach a button slowly — it starts moving before you touch it.</p>
</section>