Animateable
Updated on August 22, 2024Source codeTests
Animateable is a class that enriches an array of keyframes, allowing it to:
- Compute intermediate frames between keyframes at a rate of 60 frames per second, passing frame data to a callback function you provide
- Customize the animation by giving it a duration, a timing function, and a number of iterations it should repeat, and indicating whether it should alternate or just progress in one direction
- Store the number of completed iterations
- Play, pause, or reverse the animation
- Seek to a specific frame
- Restart the animation while it's playing or reversing
- Store the status of the animation (e.g.
playing,reversing,paused, etc.) - Store the elapsed time, remaining time, and time progress of the animation
In other words, Animateable implements all the main features of CSS @keyframes animations in JavaScript, then adds lots of methods to help you control the animation itself.
Animateable is also very similar to the Web Animations API. The main difference is that Animateable focuses on exposing arbitrary interpolated values to you at 60 frames per second, while the Web Animation API focuses on updating element styles, and does not expose interpolated values.
Construct an Animateable instance
To construct an Animateable instance, use the Animateable constructor, which accepts two parameters:
keyframesoptionsAnimateable instance. See the Animateable constructor options section for more guidance.How to format keyframes
keyframes is an Array, and each individual keyframe in the array is an Object. Keyframe objects can have the following properties:
progressA number between 0 and 1 indicating the time through the animation sequence at which the keyframe occurs.
The progress property is exactly like percentage progress in CSS @keyframe animations, except that it's between 0 and 1 instead of 0 and 100.
propertiesSpecifies properties and values for the keyframe, which Animateable will reference when computing frames between keyframes.
Each property can be any valid Object property. Properties are not required to be valid CSS properties.
Values can be Numbers, Strings, or Arrays. See the How property types are animated section for more guidance on how to format your values so that they get animated properly.
timingCustomizes the timing function used to interpolate values as progress is made toward the next keyframe.
See the How to format timing section for more guidance on formatting the timing array.
Just like the animation-timing-function CSS property, any timing specified on the last keyframe will have no effect.
If timing is not specified on the keyframe itself, Animateable will use the default timing function, which can be customized using the timing option in the constructor. See the Animateable constructor options section for more info about the global timing function.
How property types are animated
Values inside the properties object of each keyframe can be Numbers, Strings, or Arrays. See the table below for more guidance on how each property type is handled when Animateable creates new animation frames.
Animateable...NumberArrayAnimateable slices the array, starting from the 0 index and stopping at the interpolated index.StringAssumes the string is a color in any format that can be passed to the CSS color-mix function. Animateable uses the createMix pipe to interpolate a color between the colors of two consecutive keyframes.
You can use different formats across keyframes, even if it's the same property on your properties object.
Don't pass the percentage option with your color string—Animateable doesn't support it, and if you're using Animateable with TypeScript, it will cause a type error.
Colors are interpolated in the oklch space by default, but you can use the options parameter of the play and reverse methods to customize that. See Animate options for more guidance.
When factoring the animation's time progress and timing function into these interpolations, Animateable uses BezierEasing.
How to format timing
In individual keyframes and in the Animateable constructor's options object, the timing property's value should be an Array of four Numbers. In order, those numbers should be:
- The
xcoordinate of the first control point - The
ycoordinate of the first control point - The
xcoordinate of the second control point - The
ycoordinate of the second control point
In other words, the array should contain exactly what you would normally pass to the cubic-bezier() function in CSS.
For example:
// This timing array produces the easeInOutQuad curve
// from easings.net
[
0.455, 0.030, // Point 1
0.515, 0.955, // Point 2
]
cubic-bezier() examples abound on the internet, so it should be relatively easy to find and copy/paste control points. But for an even smoother experience, you can import pre-built timings from @baleada/logic:
import { materialStandard } from '@baleada/logic'
const instance = new Animateable(
myKeyframes,
{ timing: materialStandard }
)
Here's a list of the available timing arrays in Baleada Logic:
linear0.00, 0.00, 1.00, 1.00Animateable constructor options
duration0timing[0,0,1,1]Customizes the global timing function used by Animateable to compute values between frames. The default timing function is linear.
See the How to format timing section for more guidance on formatting the timing array.
iterations1Indicates the number of iterations the animation will repeat when playing or reversing.
The minimum is 1, and you can pass true to make the animation iterate infinitely.
alternatesfalseIndicates whether or not the animation will alternate back and forth, or only proceed in one direction.
When alternates is true, each full back-and-forth cycle is considered 1 iteration.
State and methods
keyframesA shallow copy (Array) of the keyframes array passed to the constructor.
If you assign a value directly to keyframes, a setter will pass the new value to setKeyframes.
playbackRateA number indicating the playback rate of the animation. Defaults to 1.
If you assign a value directly to playbackRate, a setter will pass the new value to setPlaybackRate.
statusAnimateable instance. See the How methods affect status, and vice-versa section for more information.iterationstimeelapsed and remaining. Both keys' values are numbers indicating the time elapsed and time remaining in milliseconds.progressAn Object with two keys: time and animation. Both keys' values are numbers between 0 and 1 indicating the time progress and animation progress of the animation.
In other words, progress.time and progress.animation are the x and y coordinates of the current point on the global timing function's easing curve.
setKeyframes(keyframes)Animateable instance's keyframeskeyframes (Array)Animateable instancesetPlaybackRate(playbackRate)0.Animateable instanceplay(effect, options)play requires an effect function to handle individual frames. Your effect will be called 60 times per second and will receive the current frame as its only argument.
See the How to handle frames section for more guidance.
play also accepts an optional options parameter. See the Animate options section for more guidance.
Animateable instance.reverse(effect, options)reverse requires an effect function to handle individual frames. Your effect will be called 60 times per second and will receive the current frame as its only argument.
See the How to handle frames section for more guidance.
reverse also accepts an optional options parameter. See the Animate options section for more guidance.
Animateable instance.pause()Animateable instance.seek(progress, options)Seeks to a specific time progress in the animation. If status is playing or reversing, the animation will continue progressing in the same direction after seeking to the time progress.
If your animation is supposed to repeat for more than one iteration, you can pass a time progress that is greater than 1 to seek to a specific iteration. For example, to seek halfway through the third iteration, you can call seek(2.5).
Can't be called until the DOM is available.
seek Accepts two parameters: a time progress to seek to, and an options object with an effect property, passing a function to handle the frame(s) that will be computed.
The progress parameter is always required, but the options.effect is only required if the animation is not currently playing or reversing.
Animateable instance.restart()Restarts the animation, using the same effect that was previously passed to play or reverse to handle frames.
restart does nothing when the animation is not currently playing or reversing.
Can't be called until the DOM is available.
Animateable instance.stop()Animateable instance.Animate options
As mentioned above the play and reverse methods each accept an optional options object as their second parameter.
Here's a breakdown of the available options:
interpolateCustomizes the way Animateable interpolates values.
The interpolate object currently has one property: color. interpolate.color is an object, and its properties include method, which can set any valid color interpolation method (omitting the in keyword), and options for the createMix pipe that Animateable uses under the hood to interpolate colors.
The only default value in the interpolate object is interpolate.color.method, which is set to oklch.
How methods affect status, and vice-versa
Each Animateable instance maintains a status property that allows it to take appropriate action based on the methods you call, in what order you call them, and when you call them.
At any given time, status will always be one of the following values:
readyplayingplayedreversingreversedpausedsoughtstopped
There's a lot of complexity involved in the way each status is achieved (it's affected by which methods you call, in what order you call them, and exactly when you call them), but you likely will never need to worry about that. status is available to you if you feel you need it, but for all intended use cases, it's an implementation detail, and you can ignore it.
The only thing you may want to be aware of is how status affects your ability to call certain methods—some methods can be called at any time, and some can only be called when status has a specific value.
The table below has a full breakdown:
status is...setKeyframesplayplayingreversereversingpauseplaying or reversingseekrestartplaying or reversingstopOr, just remember:
- You can't
playwhile the animation is already playing, and likewise, you can'treversewhile the animation is already reversing. - You can only
pauseandrestartwhile the animation is playing or reversing - You can
setKeyframes,seek, andstopat any time. Just remember thatsetKeyframeswill alwaysstopthe animation, and if you callseekwhile an animation is progressing, the animation will continue progressing after it seeks to the time progress you specified.
How to handle frames
Finally, the good stuff!
The first step to handling frames is to pass an effect function to the play, reverse or seek methods when you call them. Animateable will call that function at a rate of 60 frames per second, passing the current frame as the first argument.
Each frame is an Object with a properties property and a timestamp property.
The timestamp property indicates the number of milliseconds since time origin. The value of properties is an Object, whose keys include all of the properties from the properties objects in your keyframes.
The value of each those keys is an object with two properties: progress and interpolated. The interpolated property holds the interpolated value for that specific frame. The progress property holds an object with time and animation properties, indicating the time progress and animation progress between the previous and next keyframes for that property.
// Example frame
{
properties: {
scale: {
progress: { time: 0.5, animation: 0.5 },
interpolated: 10,
},
color: {
progress: { time: 0.25, animation: 0.5 },
interpolated: 'oklch(0.499997 0.0000248993 11.8942)',
},
},
timestamp: 12345,
}
For a simpler example, imagine you passed these keyframes:
[
{
progress: 0,
properties: { myProperty: 0 }
},
{
progress: 1,
properties: { myProperty: 100 }
}
]
After you call the play method, the first frame for your effect function would look like this:
{
properties: {
myProperty: {
interpolated: 0,
progress: { time: 0, animation: 0 }
}
},
timestamp: 1000
}
Assuming you're using the default linear timing function, this is the frame your effect function would receive exactly halfway through the animation:
{
properties: {
myProperty: {
interpolated: 50,
progress: { time: 0.5, animation: 0.5 }
}
},
timestamp: 1500
}
And this is the last frame your effect would receive:
{
properties: {
myProperty: {
interpolated: 100,
progress: { time: 1, animation: 1 }
}
},
timestamp: 2000
}
Things get slightly more complex when your keyframes don't just start at progress: 0 and end at progress: 1. Consider the following keyframes:
[
{
progress: 0,
properties: { myProperty: 0 }
},
{
progress: .5,
properties: { myProperty: 25 }
},
{
progress: 1,
properties: { myProperty: 100 }
}
]
Assuming you're using the default linear timing function, this is the frame your effect function would receive exactly one quarter of the way through the animation, when the Animateable instance's progress.time is 0.25:
{
properties: {
myProperty: {
interpolated: 12.5,
progress: {
// Halfway between the previous and next keyframes
time: 0.5,
// With linear timing, animation progress equals
// time progress
animation: 0.5,
},
},
timestamp: 1250
}
This is the frame your effect function would receive exactly halfway through the animation, when the Animateable instance's progress.time is 0.5:
{
properties: {
myProperty: {
interpolated: 25,
progress: {
// Time and animation progress reset to 0
// when you reach a keyframe
time: 0,
animation: 0,
},
},
timestamp: 1500
}
Here's the frame at three quarters progress, when the Animateable instance's progress.time is 0.75:
{
properties: {
myProperty: {
interpolated: 62.5,
progress: {
time: 0.5,
animation: 0.5,
},
},
timestamp: 1750
}
And this is the last frame your effect would receive, when the Animateable instance's progress.time is 1:
{
properties: {
myProperty: {
interpolated: 100,
progress: {
time: 1,
animation: 1,
},
},
timestamp: 2000
}
So what should you do with that frame inside your effect function? The intention behind Animateable is that you'll assign interpolated values to the styles of an element.
Take this effect function for example:
const el = document.querySelector('#el')
function frameEffect ({
properties: { myProperty: { interpolated } }
}) {
el.style.transform = `translateX(${interpolated}%)`
}
That effect function translates an element to the right by a percentage value determined by your frame data. As the animation progresses, the element could move from 0% to 100%.
In the same effect function, you could set additional styles with the exact same frame data, if you wanted:
const el = document.querySelector('#el')
function frameEffect ({
properties: { myProperty: { interpolated } }
}) {
el.style.transform = `translateX(${interpolated}%)`
el.style.backgroundColor =
`rgb(255, 255, ${interpolated / 100 * 255})`
}
That effect function would move the element to the right and steadily change its background color at the same time.
progress values are less useful, but are exactly what you will need if you ever want to visualize the progress of individual keyframe-to-keyframe transitions (time and animation progress are the x and y coordinates of the current point on an easing curve).
Note that if you have multiple properties in your keyframes, every property will be included in every frame's data, even if its interpolated value hasn't changed.
Take these keyframes for example:
[
// translateX
{
progress: 0,
properties: { translateX: 0 }
},
{
progress: 1,
properties: { translateX: 100 }
},
// blueChannel (of rgb color)
{
progress: 0.5,
properties: { blueChannel: 0 }
},
{
progress: 1,
properties: { blueChannel: 255 }
},
]
Given those keyframes, and assuming you're still using the default linear timing function, here's the frame you would receive when the Animateable instance's progress.time is 0.25:
{
properties: {
translateX: {
interpolated: 25,
progress: { time: 0.25, animation: 0.25 },
},
blueChannel: {
interpolated: 0,
// `progress` will be meaningless numbers, because
// the `blueChannel` interpolation doesn't start
// until `progress.time` is `0.5`
progress: { ... },
},
},
timestamp: ...,
}
Here's what you would get when the Animateable instance's progress.time is 0.5:
{
properties: {
translateX: {
interpolated: 50,
// Halfway through its keyframe transition
progress: { time: 0.5, animation: 0.5 },
},
blueChannel: {
interpolated: 0,
// Starting its first keyframe transition
progress: { time: 0, animation: 0 },
},
},
timestamp: ...,
}
And here's what you would get when the Animateable instance's progress.time is 0.75:
{
properties: {
translateX: {
interpolated: 75,
// 3/4 of the way through its keyframe transition
progress: { time: 0.75, animation: 0.75 },
},
blueChannel: {
interpolated: 127.5,
// Halfway through its keyframe transition
progress: { time: 0.5, animation: 0.5 },
},
},
timestamp: ...,
}
And here's the final frame, when Animateable instance's progress.time is 1:
{
properties: {
translateX: {
interpolated: 100,
progress: { time: 1, animation: 1 },
},
blueChannel: {
interpolated: 255,
progress: { time: 1, animation: 1 },
},
},
timestamp: ...,
}
The important thing to remember is that all properties are included in every frame, even if their interpolated value doesn't change, and regardless of how your keyframes are ordered and organized.
And that covers all of the basic concepts! But what we haven't covered yet is how to handle strings and arrays that you pass to your keyframes.
As explained in the How property types are animated section, strings are always assumed to be colors. So, you can set them to any color property on an element:
const el1 = document.querySelector('#el1'),
el2 = document.querySelector('#el2'),
keyframes = [
// white to indigo
{
progress: 0,
properties: { whiteToIndigo: "#fff" },
},
{
progress: 1,
properties: { whiteToIndigo: 'hsl(246.8, 60.8%, 60%)' }
},
// indigo to white
{
progress: 0,
properties: { indigoToWhite: 'hsl(246.8, 60.8%, 60%)' },
},
{
progress: 1,
properties: { indigoToWhite: '#fff' }
},
]
function frameEffect ({
properties: { whiteToIndigo, indigoToWhite }
}) {
el1.style.color = indigoToWhite.interpolated
el1.style.backgroundColor = whiteToIndigo.interpolated
el2.style.color = whiteToIndigo.interpolated
el2.style.backgroundColor = indigoToWhite.interpolated
}
Note that you don't have to use the same color format between keyframes—you can freely mix and match different formats.
Arrays are primarily intended to be used to achieve the "typewriter" affect, although there are probably other cool things you can do with them.
Here's an example of how the typewriter effect would work:
const el1 = document.querySelector('#el1'),
keyframes = [
// write 'Baleada'
{
progress: 0,
properties: { word: [] },
},
{
progress: 1,
properties: { word: 'baleada'.split('') }
},
]
function frameEffect ({
properties: { word: { interpolated } }
}) {
el1.style.textContent = interpolated.join('')
}
Given those keyframes and that frame effect, your Animateable instance would progressively change the text content of your element, making it look like the word "Baleada" is being typed across the screen.
That's a lot of info to digest! Here's an editable demo if you want to play around and get a better feel for how Animateable works.
Using with TypeScript
Animateable will type-check your keyframes to ensure that you're not including color-mix optional percentages in your colors:
import { Animateable } from '@baleada/logic'
const animateable = new Animateable([
{
progress: 0,
properties: {
num: 0,
arr: [],
// This color works fine
color: 'red',
},
},
{
progress: 1,
properties: {
num: 42,
arr: 'Baleada'.split(''),
// This color will throw a type error, because it's not
// supposed to include the percentage option.
color: 'blue 100%',
},
},
])
Animateable also exports a no-op defineAnimateableKeyframes function that you can use to type-check your keyframes.
import { defineAnimateableKeyframes } from '@baleada/logic'
const keyframes = defineAnimateableKeyframes([...])
API design compliance
options object.keyframessetKeyframesset<Property> methodsplaybackRate, setPlaybackRatestatus, request, iterations, time, progressplay, reverse, pause, seek, restart, stopstop methodableanimate (private method)