Button with CSS Character Stagger

JavaScript splits the button label into individual character spans with incrementing transition-delay values. On hover each character slides up via translateY, revealing a text-shadow duplicate below it. The background panel simultaneously shrinks its inset.

cssjavascripthoverbuttoncharacterstaggeranimation

Code

index.html
html
<a href="#" aria-label="Staggering button" class="btn-animate-chars">
  <div class="btn-animate-chars__bg"></div>
  <span data-button-animate-chars="" class="btn-animate-chars__text">Staggering Button</span>
</a>
styles.css
css
.btn-animate-chars {
  color: #131313;
  cursor: pointer;
  border-radius: .25em;
  flex-grow: 1;
  justify-content: center;
  align-items: center;
  max-width: 12em;
  padding: 1em;
  font-size: 1em;
  line-height: 1;
  text-decoration: none;
  display: flex;
  position: relative;
}

.btn-animate-chars__text {
  white-space: nowrap;
  line-height: 1.3;
}

/* Characters */
.btn-animate-chars [data-button-animate-chars] {
  overflow: hidden;
  position: relative;
  display: inline-block;
}

.btn-animate-chars [data-button-animate-chars] span {
  display: inline-block;
  position: relative;
  text-shadow: 0px 1.3em currentColor;
  transform: translateY(0em) rotate(0.001deg);
  transition: transform 0.6s cubic-bezier(0.625, 0.05, 0, 1);
}

.btn-animate-chars:hover [data-button-animate-chars] span {
  transform: translateY(-1.3em) rotate(0.001deg);
}

/* Background */
.btn-animate-chars__bg {
  background-color: #efeeec;
  border-radius: .25em;
  position: absolute;
  inset: 0;
  transition: inset 0.6s cubic-bezier(0.625, 0.05, 0, 1);
}

.btn-animate-chars:hover .btn-animate-chars__bg {
  inset: 0.125em;
}
script.js
javascript
function initButtonCharacterStagger() {
  const offsetIncrement = 0.01; // Transition offset increment in seconds
  const buttons = document.querySelectorAll('[data-button-animate-chars]');

  buttons.forEach(button => {
    const text = button.textContent; // Get the button's text content
    button.innerHTML = ''; // Clear the original content

    [...text].forEach((char, index) => {
      const span = document.createElement('span');
      span.textContent = char;
      span.style.transitionDelay = `${index * offsetIncrement}s`;

      // Handle spaces explicitly
      if (char === ' ') {
        span.style.whiteSpace = 'pre'; // Preserve space width
      }

      button.appendChild(span);
    });
  });
}

// Initialize Button Character Stagger Animation
document.addEventListener('DOMContentLoaded', () => {
  initButtonCharacterStagger();
});

Guide

How It Works

The script reads the text content of each [data-button-animate-chars] element, clears it, and re-inserts each character as an individual span with an incrementing transition-delay. CSS then handles the hover animation with translateY and text-shadow.

Stagger Speed

Adjust offsetIncrement in the script to change the delay between each character. The default is 0.01s. Increase it for a more pronounced stagger, decrease it for near-simultaneous movement.

Text Duplicate

Each span uses text-shadow: 0px 1.3em currentColor as an invisible duplicate sitting 1.3em below. The container clips overflow, so the shadow is hidden at rest. On hover translateY(-1.3em) pulls the shadow into view while the original exits above.

Background Shrink

The .btn-animate-chars__bg div transitions its inset from 0 to 0.125em on hover, creating a subtle shrink effect that adds depth to the interaction.

GSAP SplitText

If your project already uses GSAP SplitText, you can replace the custom splitting script with it and apply transition-delay values via GSAP instead.