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:
keyframes
options
Animateable
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:
progress
A 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
.
properties
Specifies 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.
timing
Customizes 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
...Number
Array
Animateable
slices the array, starting from the 0
index and stopping at the interpolated index.String
Assumes 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
x
coordinate of the first control point - The
y
coordinate of the first control point - The
x
coordinate of the second control point - The
y
coordinate 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:
linear
0.00, 0.00, 1.00, 1.00
Animateable
constructor options
duration
0
timing
[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.
iterations
1
Indicates 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.
alternates
false
Indicates 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
keyframes
A 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
.
playbackRate
A 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
.
status
Animateable
instance. See the How methods affect status, and vice-versa section for more information.iterations
time
elapsed
and remaining
. Both keys' values are numbers indicating the time elapsed and time remaining in milliseconds.progress
An 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 keyframes
keyframes
(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:
interpolate
Customizes 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:
ready
playing
played
reversing
reversed
paused
sought
stopped
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...setKeyframes
play
playing
reverse
reversing
pause
playing
or reversing
seek
restart
playing
or reversing
stop
Or, just remember:
- You can't
play
while the animation is already playing, and likewise, you can'treverse
while the animation is already reversing. - You can only
pause
andrestart
while the animation is playing or reversing - You can
setKeyframes
,seek
, andstop
at any time. Just remember thatsetKeyframes
will alwaysstop
the animation, and if you callseek
while 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.keyframes
setKeyframes
set<Property>
methodsplaybackRate
, setPlaybackRate
status
, request
, iterations
, time
, progress
play
, reverse
, pause
, seek
, restart
, stop
stop
methodable
animate
(private method)