25 CSS Text Animations 22 / 25
CSS Elastic Bounce Text Animation
A headline drops in with exaggerated squash-and-stretch physics — JavaScript assigns custom spring parameters per letter for natural variance.
The code
<div class="ta-22">
<div class="ta-22__stage">
<h2 class="ta-22__text" id="ta-22-text" aria-label="BOUNCE">BOUNCE</h2>
<button class="ta-22__btn" id="ta-22-replay">↺ Drop again</button>
</div>
</div> <div class="ta-22">
<div class="ta-22__stage">
<h2 class="ta-22__text" id="ta-22-text" aria-label="BOUNCE">BOUNCE</h2>
<button class="ta-22__btn" id="ta-22-replay">↺ Drop again</button>
</div>
</div>.ta-22, .ta-22 *, .ta-22 *::before, .ta-22 *::after {
margin: 0; padding: 0; box-sizing: border-box;
}
.ta-22 ::selection { background: #b45309; color: #fff; }
.ta-22 {
--bg: linear-gradient(180deg, #0f0a00, #1a0f00);
min-height: 100vh;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
font-family: 'Syne', 'Helvetica Neue', sans-serif;
overflow: hidden;
}
.ta-22__text {
font-size: clamp(3rem, 10vw, 6rem);
font-weight: 900;
letter-spacing: 0.05em;
display: flex;
justify-content: center;
}
.ta-22__char {
display: inline-block;
color: #fbbf24;
text-shadow: 0 4px 16px rgba(251,191,36,0.35);
opacity: 0;
animation: ta-22-drop 0.9s cubic-bezier(0.215, 0.61, 0.355, 1) forwards;
animation-delay: var(--delay, 0s);
transform-origin: center bottom;
}
@keyframes ta-22-drop {
0% { opacity: 1; transform: translateY(var(--fall, -80px)) scaleX(1) scaleY(1); }
55% { transform: translateY(0) scaleX(1.25) scaleY(0.72); }
70% { transform: translateY(-18px) scaleX(0.92) scaleY(1.1); }
83% { transform: translateY(0) scaleX(1.1) scaleY(0.9); }
92% { transform: translateY(-6px) scaleX(0.97) scaleY(1.04); }
100% { opacity: 1; transform: translateY(0) scaleX(1) scaleY(1); }
}
.ta-22__btn {
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
background: none;
border: 1px solid #451a03;
color: #92400e;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.ta-22__btn:hover { border-color: #fbbf24; color: #fbbf24; }
@media (prefers-reduced-motion: reduce) {
.ta-22__char { animation: none; opacity: 1; transform: none; }
} .ta-22, .ta-22 *, .ta-22 *::before, .ta-22 *::after {
margin: 0; padding: 0; box-sizing: border-box;
}
.ta-22 ::selection { background: #b45309; color: #fff; }
.ta-22 {
--bg: linear-gradient(180deg, #0f0a00, #1a0f00);
min-height: 100vh;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
font-family: 'Syne', 'Helvetica Neue', sans-serif;
overflow: hidden;
}
.ta-22__text {
font-size: clamp(3rem, 10vw, 6rem);
font-weight: 900;
letter-spacing: 0.05em;
display: flex;
justify-content: center;
}
.ta-22__char {
display: inline-block;
color: #fbbf24;
text-shadow: 0 4px 16px rgba(251,191,36,0.35);
opacity: 0;
animation: ta-22-drop 0.9s cubic-bezier(0.215, 0.61, 0.355, 1) forwards;
animation-delay: var(--delay, 0s);
transform-origin: center bottom;
}
@keyframes ta-22-drop {
0% { opacity: 1; transform: translateY(var(--fall, -80px)) scaleX(1) scaleY(1); }
55% { transform: translateY(0) scaleX(1.25) scaleY(0.72); }
70% { transform: translateY(-18px) scaleX(0.92) scaleY(1.1); }
83% { transform: translateY(0) scaleX(1.1) scaleY(0.9); }
92% { transform: translateY(-6px) scaleX(0.97) scaleY(1.04); }
100% { opacity: 1; transform: translateY(0) scaleX(1) scaleY(1); }
}
.ta-22__btn {
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
background: none;
border: 1px solid #451a03;
color: #92400e;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.ta-22__btn:hover { border-color: #fbbf24; color: #fbbf24; }
@media (prefers-reduced-motion: reduce) {
.ta-22__char { animation: none; opacity: 1; transform: none; }
}(function() {
const wrap = document.getElementById('ta-22-text');
const btn = document.getElementById('ta-22-replay');
if (!wrap) return;
const word = wrap.getAttribute('aria-label') || 'BOUNCE';
function build() {
wrap.innerHTML = '';
[...word].forEach((ch, i) => {
const span = document.createElement('span');
span.className = 'ta-22__char';
span.textContent = ch;
const fall = -(60 + Math.random() * 80);
const delay = i * 0.07 + Math.random() * 0.04;
span.style.setProperty('--fall', fall + 'px');
span.style.setProperty('--delay', delay + 's');
wrap.appendChild(span);
});
}
function replay() {
wrap.querySelectorAll('.ta-22__char').forEach(s => {
s.style.animation = 'none';
void s.offsetWidth;
s.style.animation = '';
});
build();
}
build();
if (btn) btn.addEventListener('click', replay);
})(); (function() {
const wrap = document.getElementById('ta-22-text');
const btn = document.getElementById('ta-22-replay');
if (!wrap) return;
const word = wrap.getAttribute('aria-label') || 'BOUNCE';
function build() {
wrap.innerHTML = '';
[...word].forEach((ch, i) => {
const span = document.createElement('span');
span.className = 'ta-22__char';
span.textContent = ch;
const fall = -(60 + Math.random() * 80);
const delay = i * 0.07 + Math.random() * 0.04;
span.style.setProperty('--fall', fall + 'px');
span.style.setProperty('--delay', delay + 's');
wrap.appendChild(span);
});
}
function replay() {
wrap.querySelectorAll('.ta-22__char').forEach(s => {
s.style.animation = 'none';
void s.offsetWidth;
s.style.animation = '';
});
build();
}
build();
if (btn) btn.addEventListener('click', replay);
})();How this works
JavaScript splits the headline into letter spans and assigns each one randomised CSS custom properties: --fall-height (how far above the frame each letter starts), --squash (how much it flattens on contact), and --delay (its stagger offset). The CSS ta-22-drop keyframe reads these values to produce a multi-step drop: fall from above, contact squash (scaleX(1.3) scaleY(0.7)), first bounce, smaller bounce, settle — mimicking a real rubber object's diminishing rebound energy.
The squash and stretch phases happen at precise keyframe percentages: 60% is the squash at contact, 75% is the first stretch upward, 85% is the second touch, 100% is settled. Each letter's unique --delay and --fall-height means no two letters have exactly the same entry, giving the illusion of independently dropped objects rather than a rigidly synchronised animation.
Customize
- Increase the fall height range in the JS by widening
fallRangefrom80to160for more dramatic drops from higher above the frame. - Reduce the squash intensity by changing the keyframe's
scaleX(1.3) scaleY(0.7)values closer to1for subtler deformation. - Add a shadow that compresses during squash: use a
box-shadowortext-shadowthat expands horizontally during the squash keyframe to suggest a contact shadow. - Change easing from the multi-step keyframe to a single
cubic-bezier(0.34, 1.56, 0.64, 1)for a simpler elastic feel that's easier to tune. - Trigger on scroll-entry using an
IntersectionObserverthat adds the animation class when the element becomes visible.
Watch out for
- The squash transform uses both
scaleXandscaleY— these must be on aninline-blockspan, not aninlineelement, or the scale has no effect. - Letters at different fall heights land at slightly different times even with the same delay, creating a subtle misalignment if
--fall-heightvariance is too large — cap at ±60px for controlled results. - Replay logic must reset the animation by briefly removing and re-adding the class, using
void el.offsetWidthas a forced reflow to flush the browser's animation cache.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| All | All | All | All |
CSS custom properties in animations are supported in all modern browsers. The JS split adds inline styles before animation triggers — works universally.