Home

Letter Cascade

This is a per-letter reveal animation I built entirely with CSS - no JavaScript timing loop, no animation library, just a single custom property doing the heavy lifting.

Each letter gets its own --index, set inline via style={{ '--index': i }} on the span. That value feeds animation-delay: calc(0.03s * var(--index)), so instead of every letter starting at once, each one waits a little longer than the last - letter 0 starts immediately, letter 9 starts 270ms later. That’s what gives you the cascade instead of a flat pop-in.

One detail I almost missed: animation-fill-mode: backwards. Without it, every letter flashes into its final position for a frame before its delay kicks in, which looks broken. With backwards, each letter just holds still - translated out of view - until its turn comes up. The easing, a cubic-bezier(.075, .82, .165, 1) curve, slows down hard right at the end so it settles instead of overshooting and bouncing.

LetterCascade.tsx

import styles from './styles.module.css'
const TEXT = 'Animations'
export function LetterCascade() {
return (
<h1 aria-label={TEXT} className={styles.h1}>
{[...TEXT].map((letter, index) => (
<span
aria-hidden={'true'}
key={`${index}-${letter}`}
className={styles.letter}
style={{ '--index': index }}
>
{letter}
</span>
))}
</h1>
)
}

styles.module.css

.h1 {
font-size: clamp(32px, 5vw, 48px);
font-weight: 600;
letter-spacing: -0.05em;
overflow: hidden;
color: #0a0a0a;
}
.letter {
display: inline-block;
animation: yTranslate 1.3s cubic-bezier(.075, .82, .165, 1);
animation-delay: calc(0.03s * var(--index));
animation-fill-mode: backwards;
}
@keyframes yTranslate {
from {
transform: translateY(100%)
}
to {
transform: translateY(0);
}
}
@media (prefers-color-scheme: dark) {
.h1 {
color: #fafafa;
}
}
@media (prefers-reduced-motion: reduce) {
.letter {
animation: none;
}
}

It also respects prefers-reduced-motion - if you’ve told your OS you’d rather skip animations, you just get the final text straight away, no cascade, no fuss.