Interaction blueprint

Magnetic CTA

A restrained pointer pull makes an editorial call to action feel responsive, not slippery.

Playground

Playhead 0ms / 160ms

Build it

Choose a target and adapt the source to your interface.

85 lines
index.html
<span class="magnet-region"
  ><button class="magnet-control" type="button">
    <span>Explore</span>
  </button></span
>

<script>
  const region = document.querySelector('.magnet-region');
  const control = region.querySelector('button');
  const reset = () => {
    control.style.setProperty('--x', '0px');
    control.style.setProperty('--y', '0px');
  };
  region.addEventListener('pointermove', (event) => {
    if (
      event.pointerType !== 'mouse' ||
      matchMedia('(prefers-reduced-motion: reduce)').matches
    )
      return;
    const bounds = region.getBoundingClientRect();
    const x =
      Math.max(
        -1,
        Math.min(
          1,
          (event.clientX - bounds.left - bounds.width / 2) / (bounds.width / 2),
        ),
      ) * 10;
    const y =
      Math.max(
        -1,
        Math.min(
          1,
          (event.clientY - bounds.top - bounds.height / 2) /
            (bounds.height / 2),
        ),
      ) * 10;
    control.style.setProperty('--x', x + 'px');
    control.style.setProperty('--y', y + 'px');
  });
  region.addEventListener('pointerleave', reset);
  region.addEventListener('pointercancel', reset);
</script>

<style>
  .magnet-region {
    display: inline-block;
    padding: 16px;
  }
  .magnet-control {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    min-width: 140px;
    padding: 12px 20px;
    border: 0;
    border-radius: 6px;
    background: #175bc5;
    color: white;
    font: 14px system-ui;
    cursor: pointer;
    transform: translate(var(--x, 0px), var(--y, 0px));
    transition: transform 160ms cubic-bezier(0.16, 1, 0.3, 1);
  }
  .magnet-control span {
    display: block;
    transform: translate(
      calc(var(--x, 0px) * -0.2),
      calc(var(--y, 0px) * -0.2)
    );
    transition: transform 160ms cubic-bezier(0.16, 1, 0.3, 1);
  }
  .magnet-control:focus-visible {
    outline: 2px solid #175bc5;
    outline-offset: 5px;
  }
  @media (prefers-reduced-motion: reduce) {
    .magnet-control,
    .magnet-control span {
      transform: none;
      transition: none;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

160ms
01

Rest: stable CTA.

02

0–120ms: button follows pointer up to 10px.

03

120–160ms: label counterslides slightly; leave springs home.

Motion intent

A restrained pointer pull makes an editorial call to action feel responsive, not slippery.

This is direct manipulation feedback, so it completes within the immediate-response window.

The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.

Motion specification

Trigger
Pointer enters the CTA’s attraction radius.
Timing
160ms
Easing
cubic-bezier(.16, 1, .3, 1)

Animated properties

translatebackground-colorshadow

Implementation notes

  • Keep idle and attracted aligned to the same visual anchor.
  • Drive the transition from semantic state, not a timer, when the trigger is interactive.
  • Use translate, background-color, shadow and avoid layout work inside the animation loop.

Reduced motion

Swap to the final state instantly, preserving colour, label, and accessible state.

  • Use a minimum 44 by 44 pixel interactive target.
  • Expose the idle and attracted states with aria-pressed, aria-expanded, or an updated accessible label.
  • Keep visible keyboard focus independent from the animation.

Avoid

  • Do not add bounce unless a real object pickup or spring settle is part of the interaction.
  • Do not hide the final state from assistive technology.
  • Do not make the user wait for animation before the underlying state updates.

Related blueprints

Browse all blueprints