DEV Community

Tia Lovelace
Tia Lovelace

Posted on

Temporal State Coordination: A Timeline of a Timeline

Long, long ago, back when they were relatively new, I decided to have a go at writing a DOM animation library.

I called it 'Twixt'. Between, tween, betwixt... I know, right?

It didn't matter. It was only for personal use. The existing libraries left me unsatisfied in some way or other, and CSS transition was so new and incomplete it was still vendor-prefixed, if memory serves.

It followed principles that I appreciated more back then than I do today, of declarative code; clean, descriptive objects that tell the engine what I want to animate, and how. It suited me as a hobbyist, more interested in solving problems than finding and applying what exists. The original wheel was terrible.

As with any animation library, I'd give it the information it needs. Element, property, from, to, duration. Optional easing function. It did what I needed. Fade this in. Slide that there.

Sticking points and feature wishlists presented themselves as I used it. I added support for concurrent tweens, each with optional predelay, driven by one central, invisible timer. I added the option to provide a callback instead of an element and a property, so that I could tween more things on that same synchronising timer. I could tween state for canvas rendering, audio volume, anything, all perfectly in sync.

twixt.animate({
    from: audio.volume,
    to: 0,
    duration: 500,
    callback: function(v){
        audio.volume = v
    }
});
Enter fullscreen mode Exit fullscreen mode

It was shaping up to be quite powerful, for its time. I used it for all sorts; particle effects, game engines, snazzy notifications, you name it. As I did so, as I was passing in elements for some effects, callbacks for others, something dawned on me.

It was just as expressive, just as verbose, and more cognitively streamlined to perform all effects, including CSS manipulation, through that callback option. Those callbacks were, after all, simpler expressions of what would be produced by the internal logic that would read those element and property arguments. The API could be simplified.

There were trade-offs, of course. Now I had to be generally conscious of what properties still needed the vendor prefix, but if I wanted to use CSS outside of Twixt - and I did - I ought to be conscious of those anyway, or there were libraries dedicated to solving that. I had to manually combine transform chains, but if I wanted to harness the power of an ordered transform chain I'd have to manually combine them anyway. It's easy enough to write a quick reusable function to combine them in whichever way we need. They were edge cases, and the callback-centric approach allowed me to solve them like any other edge case - by doing what it does, synchronising tweens, and letting me do the rest.

Off I went, animating this and that with the new streamlined API. As I used it to synchronise more and more complex systems, those feature wishlists grew more advanced. A particle system wanted discrete, disposable 'timelines' that come and go. A game engine begged for a way to sync tweens with its own time progression system. The foundation was there in its central timer, but these features would mean significant rethinking and refactoring.

This led to the penultimate incarnation: timeline.ts. A typed, composable, seekable set of tweens and timestamped events, packaged in an object with its own driving timer. The API was overhauled to meet its new purpose-identity:

const tl = new Timeline();
tl.tween(
    0, // start
    500, // duration
    (v) => element.style.opacity = v, // apply
    1, // from value
    0, // to value
    easers.easeIn
);

tl.at(500, () => element.remove());

// tl.seek(400);
tl.play();
Enter fullscreen mode Exit fullscreen mode

I no longer conceptualised the library as an animation system. Now it was a temporal state coordination engine. An intuitive means to synchronise complex patterns of state with a dimension of time. Not just time. Input. An easy-to-read, easy-to-write model for creating complex functions of (n) => state.

That conceptual shift opened doors for new ways of approaching the API's design, new ways of approaching temporal state composition. I still felt it could be more.

I credit RxJS for inspiration for Timeline's new emission transform model. RxJS itself wasn't quite suited, but it wasn't too far off. The model needed some tweaking to fit a synchronised state engine without undue complexity and overhead. I'd already been considering ways to introduce the concept of ranges to Timeline, but I'd struggled to imagine a model that would justify the fundamental rewrite it would entail until I realised a range could emit a progression, and that could be chained to emit a composed tween.

So I rewrote Timeline from scratch, as a parametric transformation pipeline.

const tl = new Timeline();
const oneSecondIn = tl.point(1000);
const firstFiveSeconds = tl.range(0, 5000);

oneSecondIn
    .apply(() => console.log("One second has passed"));

// naive use of progression emissions:
firstFiveSeconds
    .apply(v => 
        console.log(`We are ${v * 100}% through the first five seconds`)
    );

// apply transformation:
const percentEmitter = firstFiveSeconds
    .map(progress => progress * 100);

// further transform:
const percentStringEmitter = percentEmitter
    .map(Math.floor)
    .map(v => v + "%");

// use it
percentStringEmitter
    .map(percent => `Loading: ${percent}%`)
    .apply(msg => document.title = msg);
// twice
percentStringEmitter
    .apply(v => progressBar.style.width = v);

// state isn't just visual:
tl.range(1000, 500)
    .ease(easers.easeInOut)
    .tween(0, 255)
    .apply(v => microcontroller.setPWM(v));

const twoSecondsIn = oneSecondIn.delta(1000);
tl.seek(twoSecondsIn);
tl.seek(firstFiveSeconds.end, 3000, "easeInOut");
Enter fullscreen mode Exit fullscreen mode

Ranges emit a value between 0 and 1 as 'time' passes through them. Each step in the transform chain is a distinct, immutable emitter, each listening to its parent and emitting a value of its own.

The tween() emitter will interpolate numbers, number arrays, tokens within strings and blendable objects, to keep your code as expressive as possible. Point events can include undo logic, for those fancy backward seeks. There are subtle touches to its logic that mean every seek gives a predictable, deterministic result.

I've found this to be a powerful mental and logical model for synchronising complex states over time, and I'm proud of where this long study of API design has led me.

I wrote Twixt because I wanted to include unique, performant, predictable animations in my web designs, but as I've used it and refined it I've found so many other things it can conveniently synchronise. Composed attack animations with specific sub-frame moments of infliction. XP metres that glow and swell as they fill. Ridiculously easy spritesheet animation logic. Real-time scheduling in backend logic. All with just a few lines of clean, clear code.

// drive with real-time
setInterval(() => timeline.seek(Date.now()), 1000);

// schedule maintenance
timeline
    .point(someDate.getTime())
    .apply(() => downForMaintenance = true);

// graduate config parameters over a period
timeline
    .range(someDate.getTime(), 3600000)
    .tween(1000, 50)
    .apply(v => server.rateLimit = v);
Enter fullscreen mode Exit fullscreen mode

If this summary glance of Timeline strikes your interest, you can read more about it at npmjs - @xtia/timeline or play around with the string tweening demo at StackBlitz. If I hear it's helped someone with a tricky synchronisation issue I'll go to bed happy.

Top comments (0)