CSS animations cover the simple cases. Spring physics covers reactive, cursor-driven motion. But there is a third category: a letter that rises into view, pauses, sinks back down, waits, and repeats. You want precise control over timing, not forces. You want it to loop cleanly, with a stagger that ripples across the word.
You do not need a library for this. One function is enough. It maps a frame counter to a progress value between 0 and 1. You decide what to do with that value.
The four phases of a loop
Every looping animation has the same structure, regardless of what it animates. A letter starts at a resting state, moves to a target state, holds there, returns, waits, then starts over. Four phases, four numbers:
- →enter. The letter moves from its start state to its target. This is where you feel the easing: a smooth arrival, an overshoot, or a bounce.
- →hold. The letter rests at the target. This is the moment of legibility, emphasis, or pause.
- →exit. The letter returns to its start state. Often simpler than the entrance: a clean ease-in is enough.
- →pause. The letter rests at start before the next loop. Without this, the animation feels rushed.
Adjust the four sliders below. The bar shows how the cycle divides its time across the phases. The letter animates with the current values in real time.
One function, one number
The whole implementation fits in twelve lines. Given a frame counter f, a per-letter offset, and the four phase durations, it returns a single progress value p in the range [0, 1].
function cyc(f, offset, enter, hold, exit, pause, eIn = ease.out, eOut = ease.in) {
if (f < offset) return 0 // stagger delay — wait at start
const total = enter + hold + exit + pause
const t = (f - offset) % total // position within current cycle
if (t < enter) return eIn(t / enter)
if (t < enter + hold) return 1
if (t < enter + hold + exit) return 1 - eOut((t - enter - hold) / exit)
return 0
}When f is before the letter's offset, it returns 0: the stagger delay. After that, it computes where in the cycle the current frame falls. The modulo operator % total makes it loop automatically.
The two easing parameters are optional. eIn shapes the entrance, eOut shapes the exit. They both receive a linear 0–1 value and return a curved one. You can pass different functions to give enter and exit completely different characters.
Mapping p to any property
p is just a number. You decide what it means for each animation. The most common pattern is to interpolate between a start value and a target value:
// Rise from below
ch.style.opacity = p
ch.style.transform = `translateY(${(1 - p) * 32}px)`
// Scale from nothing with a quick fade
ch.style.opacity = Math.min(1, p * 2.5)
ch.style.transform = `scale(${Math.max(0, p)})`
// Variable font weight (requires a variable font)
const wght = 150 + p * 710 // 150 → 860
ch.style.fontVariationSettings = `'wght' ${wght.toFixed(0)}`
// 3D flip on the horizontal axis
ch.style.transform = `perspective(600px) rotateX(${(1 - p) * -90}deg)`
// Blur emerge
ch.style.opacity = p
ch.style.filter = `blur(${((1 - p) * 12).toFixed(1)}px)`The same p drives all of them. You are not writing animation logic. You are writing a mapping from progress to appearance.
Easing shapes the character
The entrance easing is where personality lives. The three most useful variants for text are:
- →ease.out
1 - (1-t)³. The default. Fast start, gradual finish. Feels confident and clean. - →ease.outBack
cubic overshoot. Overshoots the target then settles back. The letter arrives with a snap and intent. - →ease.bounce
multi-pass decay. Simulates a physical bounce on arrival. Use it for drop animations where the letter lands.
Same timing, same properties, different easing. The letter below uses the same enter/hold/exit/pause in all three cases. Only the entrance function changes.
Stagger is just an offset
Stagger is not a separate concept. It is the offset parameter. Multiply the letter index by a fixed number of frames and each character starts its cycle later than the previous one:
const STAGGER = 8 // frames between each letter's start
loop(() => {
f++
chars.forEach((ch, i) => {
const p = cyc(f, i * STAGGER, ENTER, HOLD, EXIT, PAUSE)
ch.style.opacity = p
ch.style.transform = `translateY(${(1 - p) * 32}px)`
})
})At stagger 0, all letters move together. As you increase it, the first letter finishes its entrance before the second has even started. The motion reads as a wave. Drag the slider to feel the difference.
Wiring it to requestAnimationFrame
The loop itself is minimal. A frame counter increments every tick. The rest is handled by cyc():
function split() {
const el = document.querySelector('.text')
const raw = el.textContent
el.textContent = ''
return raw.split('').map(ch => {
const s = document.createElement('span')
s.className = 'char'
s.textContent = ch
el.appendChild(s)
return s
})
}
function anim_01() {
const chars = split()
const STAGGER = 7, ENTER = 36, HOLD = 55, EXIT = 28, PAUSE = 38
let f = 0
loop(() => {
f++
chars.forEach((ch, i) => {
const p = cyc(f, i * STAGGER, ENTER, HOLD, EXIT, PAUSE)
ch.style.opacity = p
ch.style.transform = `translateY(${(1 - p) * 34}px)`
})
})
}There is no animation state to manage, no event listeners to clean up, no interpolation to track. The frame counter is the only mutable variable. Every other value, including where each letter is right now, is computed directly from f.