MUST

·

Hover · micro

Buttons & links

Button hovers are where a site either feels considered or feels like a template, and the whole thing is decided in about 400 milliseconds. Three details do most of the work. A magnetic button needs an invisible catch area larger than the button — padding plus an equal negative margin — so it reacts before the cursor arrives; without it you have to already be on the button for it to move, which defeats the point. The label should lag the button at a lower strength, because two speeds read as an object and one speed reads as a sticker. And the fourth technique here is deliberately not GSAP: an underline that swaps transform-origin is forty bytes of CSS with no listeners, and reaching for JavaScript there is the mistake, not the shortcut.

Open the live demo →

How to build

Panel + script

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

quickTo · Timeline

Build time

45 min for all four. Ten minutes each on the next project.

No-code?

The fill and the swap are buildable with Hover interactions in the panel. Magnetic is not — Webflow's Mouse Move interaction has no easing model, so it snaps to the pointer instead of chasing it.

01

Webflow structure

element · class · attribute

<i>01 · magnetic</i> <b>Div</b> <i>btn_magnetic</i> <u>data-magnetic="0.38"</u> ← padding 2.25rem, margin -2.25rem <b>Link Block</b> <i>button</i> <b>Text</b> <i>button_label</i> ← moves at a LOWER strength <i>02 · slanted fill</i> <b>Link Block</b> <i>button is-slant</i> ← overflow hidden, isolation: isolate <b>Div</b> <i>button_fill</i> ← absolute, inset -8% -14%, z-index -1 <b>Text</b> <i>button_text</i> <i>03 · character swap</i> <b>Link Block</b> <i>button</i> <b>Div</b> <i>swap</i> ← position relative, overflow hidden <b>Div</b> <i>swap_row</i> ← display flex <b>Text</b> <i>swap_char</i> × n ← one per character <b>Div</b> <i>swap_row is-dup</i> <u>aria-hidden="true"</u> ← absolute, same characters <i>04 · underline — no JavaScript at all</i> <b>Link</b> <i>link_underline</i> ← ::after, scaleX(0), origin right

02

Styles to set in the Designer

btn_magnetic | <code>padding: 2.25rem; margin: -2.25rem;</code> and <code>display: inline-block</code>. The padding is the catch area; the negative margin means the layout never notices it exists. button | <code>will-change: transform</code>. The script owns <code>x</code>/<code>y</code>, so never set a transform on this class in the Designer. button_fill | <code>position: absolute; inset: -8% -14%; z-index: -1;</code> with <code>transform: translateY(135%) skewY(-6deg)</code>. Over-sizing it means the skewed corners still cover the button. button is-slant | <code>overflow: hidden</code> plus <code>isolation: isolate</code> — the isolate is what lets <code>z-index: -1</code> sit behind the label without falling behind the button itself. swap | <code>position: relative; overflow: hidden; vertical-align: bottom;</code>. Its height is the clip window, so it must match the line-height exactly. swap_char | <code>display: inline-block</code> — an inline element cannot be transformed, so without this nothing moves at all. link_underline::after | <code>transform: scaleX(0); transform-origin: right;</code>, and on hover <code>scaleX(1); transform-origin: left;</code>. That origin swap is the entire effect.

03

Building it with the Interactions panel

no code, free plan
  1. Slanted fill: select the button (.is-slant) → Interactions → trigger Mouse hover → target button_fill. Mouse in: Move Y 0%, Skew 0°. Resting state (Mouse out): Move Y 135%, Skew -6°. Duration 0.5s, Ease Expo Out.
  2. Character swap: same Mouse hover trigger on the button, targeting both .swap_row elements — real row Move Y -100% staggered ~0.018s per character, duplicate row Move Y 0% on the same stagger, Mouse out reverses it.
  3. Underline: skip the panel entirely. Set link_underline::after's hover state as a plain Combo Class in the Designer's style panel — scaleX(0) → scaleX(1) with the origin swap. It's CSS, and reaching for an interaction here only adds overhead.
  4. For both hover interactions, set Mouse out to reverse the same timeline rather than building a second animation — one "in", never two.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • Magnetic pull isn't buildable at all: Webflow's Mouse move interaction snaps the target directly to the pointer's coordinates — there's no easing model, so there's no way to make the button lag and chase the cursor. That one needs quickTo.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

The wrapper, not the button | data-magnetic | 0.38 | How hard it is pulled. 0.3–0.4 for a primary CTA, 0.2 for secondary. Above 0.5 the button escapes the cursor and becomes annoying to click.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { const fine = matchMedia("(hover: hover) and (pointer: fine)").matches; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; // ── 1 · magnetic ──────────────────────────────────────────────────── if (fine && !reduced) { document.querySelectorAll("[data-magnetic]").forEach(area => { const btn = area.querySelector(".button"); const label = btn.querySelector(".button_label"); const strength = parseFloat(area.dataset.magnetic) || 0.35; const xTo = gsap.quickTo(btn, "x", { duration: 0.7, ease: "power3" }); const yTo = gsap.quickTo(btn, "y", { duration: 0.7, ease: "power3" }); const lxTo = gsap.quickTo(label, "x", { duration: 0.9, ease: "power3" }); const lyTo = gsap.quickTo(label, "y", { duration: 0.9, ease: "power3" }); // Listen on the AREA, move the BUTTON. That gap is the whole trick. area.addEventListener("mousemove", e => { const r = btn.getBoundingClientRect(); const dx = e.clientX - (r.left + r.width / 2); const dy = e.clientY - (r.top + r.height / 2); xTo(dx * strength); yTo(dy * strength); lxTo(dx * strength * 0.35); lyTo(dy * strength * 0.35); // label lags }); area.addEventListener("mouseleave", () => { xTo(0); yTo(0); lxTo(0); lyTo(0); }); }); } // ── 2 · slanted fill ──────────────────────────────────────────────── document.querySelectorAll(".is-slant").forEach(btn => { const fill = btn.querySelector(".button_fill"); // The Designer's transform reaches GSAP as a matrix, which has no // percentages — normalise once or every later yPercent stacks on it. gsap.set(fill, { y: 0, yPercent: 135, skewY: -6 }); if (reduced) return; const tl = gsap.timeline({ paused: true }) .to(fill, { yPercent: 0, skewY: 0, duration: 0.5, ease: "expo.out" }); btn.addEventListener("mouseenter", () => { btn.classList.add("is-on"); tl.timeScale(1).play(); }); btn.addEventListener("mouseleave", () => { btn.classList.remove("is-on"); tl.timeScale(1.5).reverse(); }); }); // ── 3 · character swap ────────────────────────────────────────────── document.querySelectorAll(".swap").forEach(swap => { const a = swap.querySelectorAll(".swap_row:not(.is-dup) .swap_char"); const b = swap.querySelectorAll(".is-dup .swap_char"); gsap.set(b, { yPercent: 100 }); if (reduced) return; // Built ONCE and replayed. Never build a timeline inside a hover handler. const tl = gsap.timeline({ paused: true, defaults: { duration: 0.4, ease: "power3.inOut" } }) .to(a, { yPercent: -100, stagger: 0.018 }, 0) .to(b, { yPercent: 0, stagger: 0.018 }, 0); const host = swap.closest(".button") || swap; host.addEventListener("mouseenter", () => tl.play()); host.addEventListener("mouseleave", () => tl.reverse()); }); // 4 · the underline is CSS. There is no JavaScript for it, on purpose. }); </script>

07

Gotchas that will cost you an hour

  1. Listen on the wrapper, move the button. Binding mousemove to the button itself means it only reacts once you are already on it — which is exactly when magnetism stops being useful.
  2. Padding plus an equal negative margin. Give the catch area real padding and it pushes everything around it. The negative margin cancels the layout effect and keeps the hit area.
  3. A skewed fill needs more than 100% of clearance. skewY(-6deg) lifts one side of the fill by roughly 15px, so at translateY(110%) that corner pokes back inside the button and you get a permanent white sliver on one edge. Push the resting offset to 135%.
  4. Cap the strength at about 0.4. Higher feels dramatic in isolation and infuriating in use — the button dodges the cursor and people miss the click.
  5. display: inline-block on every character span. Inline elements ignore transforms entirely, so the swap silently does nothing and looks like a GSAP bug.
  6. Build hover timelines once, outside the handler. Rebuilding on every mouseenter leaves a pile of timelines racing on the same nodes; after ten hovers the button starts flickering.
  7. Gate on (hover: hover). On a touch device mousemove fires once on tap and leaves the button permanently offset, mid-pull.
  8. Do not JavaScript the underline. A CSS transform-origin swap has no listener, no cleanup, no reflow and survives a blocked script. Reaching for GSAP there costs you performance and buys nothing.
08

Push it further

  • Add a data-magnetic-rotate that tilts the button a couple of degrees toward the cursor — one more quickTo, and it reads as physical.
  • Wire the button's hover into the cursor system's link state so the cursor and the button respond as one thing.
  • Give the slanted fill a second layer at a 40ms offset for a two-tone sweep. Same timeline, one extra div.

Webflow Motion Lab — GSAP 3.15, all plugins free.

3

/ 30