CSS Magnetic Button

A button with a dramatic lift and shadow on hover using CSS transitions and @starting-style.

Published July 13, 2026 Intermediate 8 min read

A CSS magnetic button lifts off the page when the pointer reaches it, using a transform for the movement and a growing box-shadow for the height. The spring in it comes from cubic-bezier(0.34, 1.56, 0.64, 1), whose second control point sits above 1 so the button travels slightly past its target and settles back. Three transitions run at once on different curves, which is what stops the shadow flickering while the button springs.

The buttons below also animate in on first paint through @starting-style. Two more builds follow: a full set of interaction states including press and keyboard focus, and a version where one rule serves four colors through a single custom property. The same easing family drives the elastic bounce.

Hover to lift

HTML

<div class="mag-btns">
  <button class="mag-btn mag-btn-primary">Primary</button>
  <button class="mag-btn mag-btn-sky">Blue</button>
  <button class="mag-btn mag-btn-ghost">Ghost</button>
</div>

CSS

.mag-btns {
  display: flex;
  gap: 1.25rem;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  padding: 1rem .5rem;
}

.mag-btn {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  padding: .75rem 1.75rem;
  border: none;
  border-radius: 8px;
  font-family: system-ui, sans-serif;
  font-size: .95rem;
  font-weight: 600;
  cursor: pointer;
  /* the springy Y2 above 1.0 gives the lift its overshoot */
  transition:
    transform .3s cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow .3s ease,
    background .2s;
}

.mag-btn:hover {
  transform: translateY(-6px) scale(1.04);
}

.mag-btn-primary {
  background: #b8ff57;
  color: #0c0c0d;
  box-shadow: 0 2px 8px rgba(184, 255, 87, .2);
}

.mag-btn-primary:hover {
  box-shadow: 0 16px 40px rgba(184, 255, 87, .35);
}

.mag-btn-sky {
  background: #38bdf8;
  color: #0a0a0b;
  box-shadow: 0 2px 8px rgba(56, 189, 248, .2);
}

.mag-btn-sky:hover {
  box-shadow: 0 16px 40px rgba(56, 189, 248, .35);
}

.mag-btn-ghost {
  background: transparent;
  color: #f0f0f0;
  border: 2px solid #2a2a2d;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .1);
}

.mag-btn-ghost:hover {
  border-color: #88888f;
  box-shadow: 0 16px 40px rgba(0, 0, 0, .2);
}

/* where the buttons animate in from on first paint */
@starting-style {
  .mag-btn {
    transform: translateY(10px);
    opacity: 0;
  }
}

Other ways to build it

Every state a button actually has

A lift on hover covers one of four states. Tab to the buttons below and the same lift happens on :focus-visible, with an outline alongside it because a shadow change alone is a weak signal for anyone with low vision. Press one and :active pushes it down past its resting position with a short ease, since a press should feel like it landed rather than like it sprang. The disabled button drops the transition entirely, because a control that still reacts to the pointer while refusing to do anything is worse than one that sits still.

HTML

<div class="mag-btns">
  <button class="mg-state">Hover</button>
  <button class="mg-state">Tab to me</button>
  <button class="mg-state">Press me</button>
  <button class="mg-state" disabled>Disabled</button>
</div>

CSS

.mg-state {
  padding: .75rem 1.75rem;
  border: none;
  border-radius: 8px;
  background: #b8ff57;
  color: #0c0c0d;
  font-size: .95rem;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(184, 255, 87, .2);
  transition: transform .3s cubic-bezier(0.34, 1.56, 0.64, 1),
              box-shadow .3s ease;
}

/* keyboard focus gets the same lift as the pointer */
.mg-state:hover,
.mg-state:focus-visible {
  transform: translateY(-6px) scale(1.04);
  box-shadow: 0 16px 40px rgba(184, 255, 87, .35);
}

.mg-state:focus-visible {
  outline: 2px solid #f0f0f0;
  outline-offset: 3px;
}

/* a press lands, so no overshoot and a much shorter duration */
.mg-state:active {
  transform: translateY(1px) scale(0.98);
  box-shadow: 0 1px 3px rgba(184, 255, 87, .3);
  transition: transform .08s ease-out, box-shadow .08s ease-out;
}

.mg-state:disabled {
  background: #2a2a2d;
  color: #88888f;
  cursor: not-allowed;
  box-shadow: none;
  transition: none;
}

.mg-state:disabled:hover {
  transform: none;
  box-shadow: none;
}

@media (prefers-reduced-motion: reduce) {
  .mg-state { transition: box-shadow .2s ease; }
  .mg-state:hover,
  .mg-state:focus-visible,
  .mg-state:active { transform: none; }
}

Four colors from one rule and one custom property

The background and both shadow states all derive from the same color, so storing that color once removes three declarations per variant. A custom property holding a comma separated RGB triple works here because rgba() accepts it directly: rgba(var(--btn-rgb), .35) resolves to a valid color with no extra syntax. Each theme is then one line. The alternative, writing out the background and two shadows for every color, is where a button ends up with a purple background and a green glow after somebody changes half of it.

HTML

<div class="mag-btns">
  <button class="mg-tint">Accent</button>
  <button class="mg-tint mg-tint-sky">Sky</button>
  <button class="mg-tint mg-tint-violet">Violet</button>
  <button class="mg-tint mg-tint-orange">Orange</button>
</div>

CSS

.mg-tint {
  /* a comma separated triple, so rgba() can take it whole */
  --btn-rgb: 184, 255, 87;

  padding: .75rem 1.75rem;
  border: none;
  border-radius: 8px;
  background: rgb(var(--btn-rgb));
  color: #0c0c0d;
  font-size: .95rem;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(var(--btn-rgb), .22);
  transition: transform .3s cubic-bezier(0.34, 1.56, 0.64, 1),
              box-shadow .3s ease;
}

.mg-tint:hover,
.mg-tint:focus-visible {
  transform: translateY(-6px) scale(1.04);
  box-shadow: 0 16px 40px rgba(var(--btn-rgb), .38);
}

/* one declaration per theme */
.mg-tint-sky    { --btn-rgb: 56, 189, 248; }
.mg-tint-violet { --btn-rgb: 192, 132, 252; }
.mg-tint-orange { --btn-rgb: 255, 144, 64; }

@media (prefers-reduced-motion: reduce) {
  .mg-tint:hover,
  .mg-tint:focus-visible { transform: none; }
}

How it works

The lift uses transform: translateY(-6px) scale(1.04) on :hover with a cubic-bezier(0.34, 1.56, 0.64, 1) timing function, where the fourth value above 1.0 creates the spring overshoot. A box-shadow with a large blur grows on hover so the button reads as floating above the surface. @starting-style supplies the values a transition needs before the element has ever been rendered, which is what lets the buttons animate in rather than appearing.

The shadow is doing more work than the transform. Six pixels of movement on its own reads as a nudge, and nobody would call it magnetic. What sells the height is the jump from 0 2px 8px at rest to 0 16px 40px on hover: a shadow that moves further from its object and blurs more is the only cue the eye has for distance in a flat interface. The shadow color is a translucent version of the button's own color rather than black, which keeps it from turning to gray mud on a dark surface.

Three properties transition at once with different settings: transform over 300ms on the springy curve, box-shadow over 300ms on a plain ease, and background over 200ms. Giving the shadow the overshoot too would push its blur radius past the target and back, which reads as a flicker rather than as a spring. Transforms are the only part of a lift that should overshoot, and the comma separated transition syntax exists precisely so each property can have its own duration and curve.

@starting-style fills a real gap. A transition needs a before value and an after value, and an element that has only just been rendered has no before, so its first appearance is never animated. Wrapping a rule in @starting-style supplies that missing first value, and here it fades the buttons up ten pixels as the page paints. It only applies on the element's first render, which is the point: it is an entrance, not a state.

Only transform runs on the compositor. box-shadow does not, so every frame of the lift repaints the button and the area its shadow covers. On three buttons that is invisible. On a grid of forty cards it is measurable. The cheaper build puts the large shadow on a pseudo-element that is always present at opacity: 0, then transitions the opacity instead, because opacity is composited and the shadow is rasterised once rather than on every frame.

The name oversells it. A genuinely magnetic button follows the pointer, which needs the pointer's coordinates, which CSS cannot read. What this is instead is a lift with a spring curve, triggered by hover, and it is a much better idea than the real thing: a control that moves toward the pointer also moves away from where the reader aimed, which makes it harder to hit. For a related treatment that stays put, see the ghost button fill sweep.

CSS properties used

transform
translateY(-6px) scale(1.04) for the lift. Composited, so it costs no layout, and it is the only part of the effect that should carry the overshoot.
box-shadow
Grows from a tight 8px blur to a 40px one. The change in offset and blur is what reads as height. Tint it with the button color rather than black.
transition
Comma separated so each property gets its own duration and curve. One shared curve across all three is what makes a lift look wobbly.
transition-timing-function
cubic-bezier(0.34, 1.56, 0.64, 1). The fourth number above 1 sends the value past its target before it comes back.
@starting-style
Supplies the value a transition starts from on an element's very first render, which is otherwise undefined and therefore not animated.
custom properties
One --btn-rgb triple per color lets the background and both shadow states derive from a single declaration, which is the second variant below.

Browser support

FeatureChromeFirefoxSafariEdge
transition455.112
transform43.53.112
box-shadow43.5512
custom properties49311016
:focus-visible86415.486
prefers-reduced-motion746310.179

Figures come from Can I Use. @starting-style has no entry in that dataset yet, so it gets no row rather than an invented one. It fails safely: a browser that does not understand the at rule skips the whole block, and the buttons appear immediately instead of fading in, which nobody will notice. Everything else here has been supported for a decade or more.

Accessibility notes

Hover is not a state a keyboard can reach, and it does not exist on touch. Put :focus-visible alongside :hover in the same selector so the lift happens for keyboard users too, and keep a separate outline on focus rather than relying on the lift alone, since a shadow change is a weak indicator for anyone with low vision. The first variant below does both.

A button that grows by 4% and rises 6px moves its own hit target while the pointer is on it. That is fine at these values and becomes a real problem past roughly 10%, because a reader with a tremor or using a head pointer can lose the button by nudging it. Keep the scale small, and never let the transform take the button out from under the pointer entirely, which would produce a hover flicker as the button oscillates between states.

Under prefers-reduced-motion: reduce, drop the transform and keep the shadow or the background change. The reader still gets a clear signal about which control is active, and nothing on the page moves. Removing all feedback would be worse than the movement was.

What you can build with it

  • Primary calls to action. One lifting button per screen. The effect works because it is rare, and a page where every control lifts has no hierarchy left.
  • Pricing card selection. The recommended tier lifting slightly further than its neighbours, which does the same job as a badge without the extra text.
  • Download and upload triggers. The lift on hover and a compression on press give a file action a physical feel, which suits an operation with a real consequence.
  • Card grids. Tiles that rise on hover, using the pseudo-element shadow trick so forty cards do not repaint on every frame.
  • Floating action buttons. A control that already sits above the page, where a larger shadow on hover reinforces the layer it lives on.

Mistakes worth avoiding

  • Giving box-shadow the springy curve as well as the transform. The blur radius overshoots its target and comes back, which shows up as a flicker around the edge of the button rather than as a bounce.
  • Animating box-shadow on a large number of elements. It is a paint property, not a composited one, so a grid of cards repaints on every frame of every hover. Transition the opacity of a pseudo-element carrying the shadow instead.
  • Scaling far enough that the button moves out from under the pointer. The hover state ends, the button shrinks back, the pointer is over it again, and the button oscillates until the reader moves away.
  • Leaving the lift on :hover only. Keyboard users get nothing, and on touch the state either never fires or sticks after a tap until something else is touched.
  • Using a black shadow on a dark surface. There is nothing darker than the background to cast onto, so the shadow reads as a smudge. Tint it with the button's own color and raise the alpha instead.

Frequently asked questions

Can a CSS button really follow the cursor?
No. CSS has no access to pointer coordinates, so a genuine magnetic pull needs JavaScript. What CSS does well is the lift and spring on hover, which is the part readers actually notice, and it avoids the usability problem of a target that moves away from where they aimed.
What does @starting-style do?
It gives a transition a value to start from on an element's first render. Without it there is no before state, so the first appearance of an element is never animated no matter what transition you declare. It applies once, at that first render, and does nothing afterwards.
Why does my button flicker on hover?
The most likely cause is a scale large enough to move the button's edge past the pointer, which ends the hover, which shrinks it back under the pointer again. Keep the scale under about 1.06, or apply it to an inner element while the outer hit area stays fixed.
Is animating box-shadow slow?
It is a paint operation rather than a composited one, so it costs more than a transform. On a handful of buttons that is irrelevant. On a long list it is worth putting the expensive shadow on a pseudo-element and transitioning its opacity, which the compositor can handle.
How do I theme several buttons from one rule?
Store the color as a custom property holding a comma separated RGB triple, then build the background and both shadow states from it with rgba(var(--btn-rgb), .35). Each color variant is then a single declaration, which is the second variant on this page.