Elastic Pulse Button (Bouncy)

On hover, dynamically calculates a proportional stretch deformation from the element's font-size and plays a GSAP elastic animation. Touch devices are skipped automatically, and a 500ms hover lock prevents spam triggering.

gsaphoverelasticbuttonanimation

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>

Code

index.html
html
<a data-elastic-pulse-btn="" href="#" class="elastic-pulse-btn">
  <div data-elastic-pulse-target="" class="elastic-pulse-btn__content">
    <div class="elastic-pulse-btn__icon">
      <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none"><path d="M20.5 10.5H3.5V21.5H20.5V10.5Z" stroke="currentColor" stroke-width="2"></path><path d="M12 14.5V17" stroke="currentColor" stroke-width="2"></path><path d="M18 10.5V8C18 6.4087 17.3679 4.88258 16.2426 3.75736C15.1174 2.63214 13.5913 2 12 2C10.4087 2 8.88258 2.63214 7.75736 3.75736C6.63214 4.88258 6 6.4087 6 8V10.5" stroke="currentColor" stroke-width="2"></path></svg>
    </div>
    <div class="elastic-pulse-btn__text">
      <span class="elastic-pulse-btn__span">Button with Icon</span>
    </div>
  </div>
</a>
styles.css
css
.elastic-pulse-btn {
  cursor: pointer;
  text-decoration: none;
  position: relative;
}

.elastic-pulse-btn__content {
  color: #fff;
  background-color: #ff8a4f;
  border-radius: 50em;
  justify-content: center;
  align-items: center;
  padding: .5em .75em;
  display: flex;
  position: relative;
  box-shadow: inset 0 -.25em .375em 0 #97133433;
}

.elastic-pulse-btn__icon {
  justify-content: center;
  align-items: center;
  width: 1.5em;
  height: 1.5em;
  padding: .25em;
  display: flex;
}

.elastic-pulse-btn__text {
  padding: .25em;
}

.elastic-pulse-btn__span {
  white-space: nowrap;
  font-size: 1em;
  font-weight: 500;
  line-height: 1.2;
  display: block;
}
script.js
javascript
function initElasticPulseButton() {
  // Skip on touch devices
  if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) return;

  document.querySelectorAll('[data-elastic-pulse-btn]').forEach(btn => {
    const target = btn.querySelector('[data-elastic-pulse-target]') || btn;
    let hoverLocked = false;

    btn.addEventListener('mouseenter', () => {
      if (hoverLocked) return;
      hoverLocked = true;
      setTimeout(() => { hoverLocked = false; }, 500);

      const el = target;
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      const fs = parseFloat(getComputedStyle(el).fontSize);
      const stretch = 0.75 * fs;
      const sx = (w + stretch) / w;
      const sy = (h - stretch * 0.33) / h;

      if (el._pulseTl && el._pulseTl.kill) el._pulseTl.kill();
      el._pulseTl = gsap.timeline()
        .to(el, { scaleX: sx, scaleY: sy, duration: 0.1, ease: 'power1.out' })
        .to(el, { scaleX: 1, scaleY: 1, duration: 1, ease: 'elastic.out(1, 0.3)' });
    });
  });
}

// Initialize Elastic Pulse Button (Bouncy)
document.addEventListener('DOMContentLoaded', () => {
  initElasticPulseButton();
});

Guide

Container

Add data-elastic-pulse-btn to the element that should listen for the mouseenter event. This is the hover trigger.

Target

Add data-elastic-pulse-target to a child element to pulse that child instead of the container. If omitted, the container itself is animated.

Stretch

The deformation amount is calculated from the element's computed font-size, so the bounce scales proportionally for both small and large buttons. Adjust the multiplier to control intensity: const stretch = 0.75 * fs;

Hover Lock

A boolean lock prevents the animation from re-triggering for 500ms after it starts, avoiding janky rapid-fire bounces on fast cursor movement.

Touch Devices

The entire function exits early on touch-only devices (hover: none and pointer: coarse), so no unnecessary listeners are attached on mobile.