A library for handing animations combining page position and time, written at SamKnows.
For a full demo at 60fps, see: samknows.com (archive.org), or for a simpler demo, check out this article I wrote.
$ npm install --save transform-when
- Blurs the line the between reactive and time-based animations, allowing you to combine variables such as page position, time, and user actions.
- Uses a number of techniques to ensure extremely high performance: both on
desktop and on mobile.
- Uses pure functions to intelligently know when a property is going to change without having to call the transform function first.
- Calculates every value to set, and then sets them all in one go, effectively making layout thrashing impossible.
- Stores property values and compares changes against the old values to ensure that it actually is a change before setting it: sort of like a virtual DOM, but without a virtual DOM.
- Uses
requestAnimationFrame
to ensure that it is only ran when necessary.
- It is powerful. You can make complicated animations with this library.
- Because it is low-level and doesn't contain any knowledge of the stuff it is animating (that bit is left up to you), it's extremely lightweight: minified and gzipped, the whole library is under 10KB
- Works with both HTML elements and SVG elements.
- Tested in IE11+.
const Transformer = require('transform-when');
new Transformer([
{
el: document.querySelector('.my-element'),
styles: [
['opacity', function (y) {
if (y > 600) {
return 0;
}
return 1;
}]
]
}
]);
The above code sets up a fairly simple transformer: it sets the opacity of the
element to 0 if window.scrollY
reaches more than 600, and back to 1 if the
user scrolls back up above 600px again.
In addition to styles, transform-when can animate attrs, and
transforms. transforms is a helper function, and will set the transform
style on HTML elements and the transform
property on SVG elements.
Let's take a look at a longer example that uses all three:
const Transformer = require('transform-when');
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
styles: [
['opacity', function (y) {
// This function animates the opacity from 1 to 0 between 500px and 600px:
// we'll explore it some more later.
return Transformer.transform([500, 600], [1, 0], y);
}]
],
attrs: [
['class', function (y) {
return 'my-element' + (y > 500 && y < 600 ? ' animating' : '');
}]
],
transforms: [
['scale', function (y) {
return Transformer.transform([500, 600], [1, 0.5], y);
}]
]
}
]);
That code would take the element (or elements) matching .my-element
, and then
when the user scrolls between 500px and 600px, it would animate the opacity
from 1 to 0, animate the scale from 1 to 0.5, and apply the animating
class.
- The
Transformer
function takes either a transform object or an array of them. - Each transform object should have an
el
property containing an element (or NodeList) and some properties to animate: styles, attrs, and transforms. - Properties use transform functions to calculate what values should be changed. Transform functions should be pure functions (without side effects), and request only the arguments requested so they can be heavily optimised.
That's it.
The transform functions above only have one argument, y
, but if you were to
change that to x
or i
, you would get a different number. This is because
transform-when uses the arguments to detect when a property needs to be changed
before actually calling the transform function: if the only argument is y
and
the y position of the page hasn't changed since the function was last called,
then it doesn't bother to call the transform function.
This is what makes transform-when so performant - but it means that transform
functions should be pure as much as possible (if you want something to be
random, don't worry - read the section on i
below).
There are (currently) four different arguments you can request: y
, x
, i
and actions
.
The x
and y
values are simply window.scrollX
and window.scrollY
(or the
IE equivalents)—how far down or along the page the user has scrolled.
i
starts at 0, and increases by 1 for each frame—effectively, it's the frame
number. This is useful for animating by time.
If you want the actual time or a duration, you can calculate that yourself
using Date.now()
.
If you want an impure transform function—say, you want to change it a bit
randomly—request the i
argument and the transform function will be called
every time.
Sometimes you want a break in the normal animation: say, if a user clicks on something, or if a certain position on the page is reached. transform-when has a concept of actions: these can be triggered, and then play for a given amount of time.
You trigger them using the .trigger()
method, and they're passed in using an
actions
argument:
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
transforms: [
['rotate', function (actions) {
// actions === { spin: x } where x is a number between 0 and 1
if (actions.spin) {
return 360 * actions.spin;
}
return 0;
}, 'deg']
]
}
]);
transforms.trigger('spin', 2000);
Multiple actions can be triggered at the same time.
The .trigger()
function returns a promise which resolves when the action
completes. It uses native promises, and will return undefined
when
window.Promise
is undefined.
It's possible to add your own variables.
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
styles: [
['opacity', function (myCustomVariable) {
}]
]
}
]);
transforms.addVariable('myCustomVariable', function () {
// Return what you want `myCustomVariable` to equal
});
The transform function is still only called when the variable is changed - except for the way it is generated, custom variables are treated exactly the same as scroll position, time and user actions.
Minifiers will, by default, break transform-when if they rename variables. The way around this is to wrap the function in an array saying what variables you need:
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
transforms: [
['rotate', ['actions', function (actions) {
if (actions.spin) {
return 360 * actions.spin;
}
return 0;
}], 'deg']
]
}
]);
The minifier won't touch the string, and transform-when will look at that instead.
In a transform function, this
refers to the transform object. This allows
you to store stuff like scales on the transform object:
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
colorScale: chroma.scale(['red', 'blue']).domain([500, 600]),
styles: [
['color', function (y) {
return this.colorScale(y);
}]
]
}
]);
There are three types of properties, styles
, attrs
and transforms
. The
first two are both pretty simple: they just set styles and attributes of an
element. Be careful animating attributes and styles that aren't the opacity:
they are more expensive to animate than transforms and opacity, and might make
your animation jerky.
Each takes an array of three things: the property (style or attribute) to animate, the transform functions, and optionally the unit to use - it's better to let transform-when handle adding the unit, because it will also round the number for you.
Let's take a look at an example:
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
styles: [
['padding', function (y) {
return Transformer.transform([500, 600], [20, 50], y);
}, 'px']
],
attrs: [
['class', function (y) {
return 'my-element' + (y > 500 && y < 600 ? ' animating' : '');
}]
],
}
]);
That animates the padding of an element from 20px to 50px, and adds the
animating
class.
Transforms are a little trickier.
CSS or SVG transforms are all set on one property. For example, a CSS transform
could be scaleY(0.5) translate(10px 20px)
and an SVG transform could be
scale(1 0.5) translate(10 20)
. Transforms are the reason for the slightly
strange syntax using arrays for properties, not objects: order is important.
Translating an element then scaling it is pretty different to scaling it and
then translating it.
transform-when looks at the array, turning each property into part of the transform attribute (for SVG) or style (for HTML elements).
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
transforms: [
['scale', function (y) {
return Transformer.transform([500, 600], [1, 1.5], y);
}],
['translateX', function (y) {
return Transformer.transform([500, 600], [0, 50], y);
}, 'px']
]
}
]);
That would return scale(1) translateX(0px)
when the y position of the page is
500px, scale(1.5) translateX(50px)
when the y position of the page is 600px,
and transition between the two.
Because the library doesn't have any knowledge of the properties it is
animating, remember to specify units when required for CSS transforms, and
don't try to use scaleY
on an SVG!
Sometimes it's necessary to animate multiple properties at the same time with the same value—for example, for CSS vendor prefixes. It isn't necessary to specify two different properties with the same transform functions (and it would be pretty inefficient, too): you can just specify the property as an array:
const transformer = new Transformer([
{
el: mock,
styles: [
[['clip-path', 'webkit-clip-path'], function (i) {
return 'circle(50px at 0% 100px)';
}]
]
}
]);
transform-when provides a couple functions to help with animating values
between two different points: Transformer.transform()
, and
Transformer.transformObj()
. If you're familiar with d3,
Transformer.transform()
work pretty similar to d3's scale functions.
Both functions map a domain to a range: for example, if you want to animate the scale of an element from 1 to 2 between the y positions of 500px and 600px, you could do it like this:
const scale = (x) => (2 - 1) * (y - 500) / (600 - 500) + 1;
That gets complicated. Instead, you can use one of the helpers:
Transformer.transform([500, 600], [1, 2], y);
A simple scale function with three arguments, domain, range, and value. Takes the value and converts it into a new number.
Transformer.transform([400, 600], [1, 0], 400); // 1
Transformer.transform([400, 600], [1, 0], 500); // 0.5
Transformer.transform([400, 600], [1, 0], 600); // 0
If only given two arguments, it'll return a function that can be called with the final value, but there is no performance advantage to doing this:
const myTransform = Transformer.transform([400, 600], [1, 0]);
myTransform(400); // 1
myTransform(500); // 0.5
myTransform(600); // 0
A slightly more complicated, more powerful version of the previous function. It takes an object with input values and output values to allow scales with multiple stages:
const myTransform = Transformer.transformObj({
400: 1,
600: 0,
1000: 0,
1200: 1
});
myTransform(0); // 1
myTransform(400); // 1
myTransform(500); // 0.5
myTransform(600); // 0
If the y position of the page were passed in and the result used as an opacity, the above code would make the element start visible, then fade it out between 400px and 600px, then fade it back in again between 1000px and 1200px.
This function also takes two more arguments, loopBy
and easing
.
This argument allows you to specify a point after which the animation should repeat itself. For example, if you want to animate the scale from 0.5 to 1 and back again over time, you could do this:
const scaleTransform = Transformer.transformObj({
0: 0.5,
30: 1
}, 60);
scaleTransform(i); // Animates from 0.5 to 1 and back repeatedly as i increases
Transformer.transformObj()
has basic support for easings. You can either pass
in the name of the easing—you can find the built in ones here—or you
can pass in you own easing function.
Unlike standard easing functions, they're given one argument and return one number: both percentages (number between 0 and 1).
For example, a quadratic ease in (easeInQuad
) looks like this:
const easeInQuad = (x) => x * x;
Pull requests adding other easings very welcome!
Transform objects also accept another property, visible
. This should be two
numbers where when the y position of the page is outside of these values, the
element will not be animated. This helps ensure that if you have a lot of
elements on the page, the ones that aren't being displayed aren't wasting
resources.
const transforms = new Transformer([
{
el: document.querySelector('.my-element'),
visible: [0, 600],
styles: [
['opacity', function (y) {
return Transformer.transform([500, 600], [1, 0], y);
}]
]
}
]);
You can also set the property on everything at once using the setVisible()
method:
transforms.setVisible([500, 600]);
It's possible to stop and start the animation using the stop()
and start()
methods. Stopping the animation will leave the currently animated properties
exactly where they are, and stop i
from increasing. Starting it again will
resume things from where they were when the animation was stopped.
The following will pause the animation for a second:
transforms.stop();
setTimeout(function () {
transforms.start();
}, 1000);
There's also a reset()
method for when you want to stop an animation and
restore the transform and element displays to what they were to start off with
(styles and attributes will be left as they were). This is useful if you need
to reinitialise the animate when the window is resized:
let transforms;
function init() {
if (transforms) {
transforms.reset();
}
transforms = new Transformer([ ... ]);
}
init();
window.addEventListener('resize', debounce(init));
By default, transform-when gets the scroll positions from the window
, but
this isn't always what you want. To change it, just change the scrollElement
property to contain a selector for the element you want to get the scroll
position of instead:
transforms.scrollElement = '.my-scroll-element';
The default behaviour of i
is to increase by 1 on each frame, up to a maximum
of 60 times. On most monitors, this just means that i
will be the number of
the frame, because most monitors don't go above 60fps. On monitors that are
capable of a higher fps such as gaming monitors, however, this means that i
won't necessarily be a whole number. If the monitor runs at 120fps, i
will
increase by about 0.5 120 times a second.
This is configurable! There are three options, belowOptimal
and
aboveOptimal
, each of which can be set to "count" (to increase by 1 each
frame) or "time" (to increase so that i
increases by 60 per second). By
default, belowOptimal
is set to "count" and aboveOptimal
is set to "time".
You may want to change belowOptimal
to "time". You probably don't want to
change aboveOptimal
to "count".
transforms.iIncrease.belowOptimal = 'time';
You can also configure the optimal FPS. By default it's 60, but you can change it:
transforms.iIncrease.optimalFps = 120;
🎉
Released under the MIT license.