Always cooking something
← All posts
May 18, 2026·6 min read

A Minimal Timeline for Looping Text Animation

CanvasAnimationCreative Coding

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.

Interactive · Cycle visualizer
A
phase: pausep = 0.000
enterholdexitpause
enter35f  ·  583ms
hold50f  ·  833ms
exit28f  ·  467ms
pause35f  ·  583ms
total cycle: 148f  ·  2.5s at 60 fps

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].

js
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.

p is always 0 or 1 at rest. At the start of the pause phase p = 0, at the end of the hold phase p = 1. Your CSS resets cleanly between loops. No residual transforms, no invisible fractional values.

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:

js
// 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.

Demo · Easing comparison on the same timing
A
ease.out
A
ease.outBack
A
ease.bounce

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:

js
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.

Interactive · Stagger
stagger8f  ·  133ms per letter
0: all together20: full cascade
The stagger offset also controls the loop phase. Letter 0 and letter 5 are at different points in their cycle at any given frame. When stagger is large enough, the word always has some letters entering, some holding, some exiting. The word breathes continuously rather than pulsing as a unit.

Wiring it to requestAnimationFrame

The loop itself is minimal. A frame counter increments every tick. The rest is handled by cyc():

js
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.

Timeline vs spring: when to use which. Use a timeline when you know exactly how the animation should feel and when it should happen: entrance sequences, choreographed reveals, looping ambient motion. Use spring physics when the animation needs to respond to unpredictable input: cursor position, drag, scroll velocity. Springs react; timelines perform.

newsletter

Stay in the loop

New experiments, articles, and tools — straight to your inbox. No spam, unsubscribe anytime.