Implementing Web Animations with JavaScript

2025. 3. 4.

Implementing Web Animations with JavaScript
image by Firmbee

The Front-End ecosystem is becoming increasingly specialized and diverse, leading to higher complexity. This isn't surprising. I tend to reduce dependencies whenever possible, aiming to expand areas that don't change within the standard. JavaScript animations are no exception. While using open-source libraries can create complex and nice movements on the DOM, web projects that heavily rely on animations are rare. Adding another dependency for simple animations can be quite burdensome. Increasing dependencies for one-time use is inefficient not only in terms of size and learning costs but in various other aspects as well.

Implementing and controlling animations using web animations without additional tools is quite easy. Although it's still a Working Draft specification, most modern browsers have been supporting the majority of the spec since 2022. It's a fairly stable specification, so it's likely to reach the Proposed Recommendation stage as is.

To explain the main concept, you can define animations using CSS keyframes with Element.animate() and control the animation using the Animation object obtained from it. All modern browsers support this.

Let's take a closer look.

Implementing Keyframe Animations

While we use the JavaScript interface, the actual implementation of the movement uses CSS keyframes. It utilizes CSS syntax.

element.animate(keyframe, options);

Elements have a method called animate. I'm not sure since when, but it's there :). Here's a simple implementation of an animation that spins an image round and round:

const anim = image.animate(
	[{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }], // (1)
	{ 
		duration: 1000,
		iterations: Infinity,
	} // (2)
); 

anim.pause(); // (3), pause

The keyframe parameter (1) defines CSS properties as an array. If there are two elements in the array, it can implement the animation from the first element, i.e., from CSS property, to the second to element. Here, we used rotate to make a full turn.

The second parameter options (2) can pass information on how to play the animation defined by keyframes. duration is the time it takes for the keyframe to play fully, which we set to 1000 milliseconds, i.e., 1 second. This means it will make one turn per second. iterations is the animation repeat type, which we set to infinity for infinite repetition.

You can check more details about options here at KeyframeEffect(). In fact, the animate() function is a kind of facade that abbreviates the creation process of KeyframeEffect() and Animation() for convenience.

It rotates once per second infinitely.

Although it rotates infinitely, you can stop or play it using the Animation object returned by animate(). Like animations applied with CSS, animations defined in JavaScript automatically play when animate() is executed. animate() returns an Animation, and you can control the animation using methods like pause() on this object (3). We'll cover the Animation object again below.

Keyframe Format

The keyframes used to define animations in CSS can also be used in JavaScript in the form of objects. The array we used as the first argument in animate() is the Keyframe Format array.

If the array has two elements, the first element is used as the from keyframe and the second as the to keyframe, which is the same as when defining keyframes in CSS. In addition to this, you can use multiple keyframes to define animations more richly. It's similar to defining multiple keyframes using percentages in CSS keyframes.

Let's say we have keyframes defined in CSS like this:

@keyframes identifier {
  25% {
    transform: translate3d(100px, 0, 0);
  }
  50% {
    transform: translate3d(100px, 100px, 0);
  }
  75% {
    transform: translate3d(0, 100px, 0);
  }
  100% {
    transform: translate3d(0, 0, 0);
  }
}

If the duration of this animation is 4 seconds, it moves 100px to the right at 1 second, 100px down at 2 seconds, 100px to the left at 3 seconds, and returns to the original position at 4 seconds. It creates movement by dividing the given duration time into 4 equal parts of 25% each.

Defining this in keyframe format would look like:

const keyframes = [
	{ transform: 'translate3d(100px, 0, 0)', offset: 0.25 },
	{
	  transform: 'translate3d(100px, 100px, 0)',
	  offset: 0.5,
	},
	{
	  transform: 'translate3d(0, 100px, 0)',
	  offset: 0.75,
	},
	{
	  transform: 'translate3d(0, 0, 0)',
	  offset: 1,
	},
];

It can be defined like this. Instead of percentages, it uses an offset property. 0% is offset: 0 and 100% is offset: 1. Everything else is the same.

However, when using time divisions like this, if you omit offset, it will automatically divide duration by 4. offset is useful in cases where the flow of time in the animation is not uniform. I won't spend time creating a specific animation example for this :)

The keyframe format can also use easing.

// ...
{ transform: 'translate3d(100px, 0, 0)', offset: 0.25, easing: "ease-in" },
// ...

You can use various easing-functions to change on a per-keyframe basis for dynamic expressions.

Controlling Animations

The animate() method of an element returns an instance of Animation. Even if you create an animation with CSS, it internally creates an instance. When you apply an animation defined in CSS to an element, it creates an instance called CSSAnimation. This is a subtype of Animation with just the animation name field added that was used when defining CSS.

The Animation object contains information about the animation applied to the element and can control the animation. Just like a Video object, you can stop or play the animation. You can also receive events at specific points. If you've ever received an event when a CSS-applied animation ended and executed something, you've indirectly used Animation.

const anim = image.animate(...);
						   
anim.play();
anim.pause();
anim.cancel(); // End animation execution, reset progress and switch to standby state
anim.currentTime // Current progress time of the animation. If it has never been played, it's null.
anim.playState // Current animation state (idle, running, paused, finished)

These are likely to be the members you'll use most often. You can find more detailed information at MDN: Animation.

Since Animation inherits EventTarget just like elements, you can receive events in the same way as elements.

anim.addEventListener('finish', (event) => {})
anim.addEventListener('cancel', (event) => {})
anim.addEventListener('remove', (event) => {})

The remove event occurs when the browser removes the animation. The reason why the browser removes animations is that animations that can occur in large numbers, like animations following the mouse pointer, can create meaningless animations due to overlapping movements, which can lead to memory leaks. So the browser removes unnecessary animations at its level. This event occurs then. Smart, right?

Instead of animate(), you can create more detailed animations by directly creating KeyframeEffect and Animation, but CSS and animate() play automatically by default, while this way the animation doesn't play by default and needs to be played directly with play() at the necessary point. Of course, you can change the default with CSS animation-play-state.

const keyframes = new KeyframeEffect(
	image,
	[{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }],
	{
	  duration: 1000,
	  iterations: Infinity,
	}
);

const anim = new Animation(keyframes, document.timeline);

anim.play() // Stays in standby until directly executed

This way, you can prepare animations in advance and precisely manipulate them when needed. If you need to change and play animations frequently depending on the state, you can control them with the Animation object instead of executing animate() every time.

We used document.timeline as the second argument when creating Animation. The timeline is the basis for the progress of CSS animations. The default is document.timeline, where the animation progresses according to the flow of time, and if you put a scroll timeline here, the animation is controlled according to the scroll value. It's still an experimental feature, but there's an example on MDN.

Animation

The fact that an animation is applied to an element means that an Animation object has been created, whether it's used or not. So it's created as many times as the number of animations applied. The Animation objects created using CSS or JavaScript API on an element can be obtained through the getAnimation() method.

element.getAnimations(); // Animation[]
document.getAnimations() // You can also get all animations in the document.

And you can know when an animation has ended with the finish event. Many of you might have used this before. Instead of events, you can also use promises. You can use animation.finished, which is a promise created when the animation is played and is resolved when the animation execution ends.

So for elements that exist only for animations that appear briefly and disappear, like icons or images, there's no need to remain on the DOM once all animations have ended, so it's good to remove them. This can be useful in such cases.

Promise.all(elem.getAnimations().map((animation) => animation.finished)).then(
  () => elem.remove(),
);

You could handle it by receiving the finish event, but you'd need additional code to confirm that all animations applied to the element have ended. Using promises, it can be handled this simply.

Since the finished property is a promise, it's recreated every time play() is executed. In other words, a thenable callback that has been executed once won't be executed again, so it needs to be redefined every time play() is called.

Using with React and tailwindCSS

These days, Tailwind is widely used. Animations applied with Tailwind can also be easily controlled with Animation. Tailwind doesn't have separate utility classes for defining animations with keyframes, and uses pure CSS instead. It only supports transitions. Tailwind's predefined animate-spin, animate-ping, animate-pulse, animate-bounce are useful for simple uses.

There's nothing special about animations used with Tailwind. It's the same as controlling animations applied with CSS. The animation is applied with CSS, not animate(), and then controlled using the Animation object.

Here's an example of applying Tailwind's animate-spin and controlling it with Animation:

const ref = useRef<HTMLImageElement>(null);

useEffect(() => {
    if (ref.current) {
        const anims = ref.current.getAnimations() as CSSAnimation[]; // (1)
        const spinAnim = anims.find((ani) => ani.animationName === 'spin'); // (2)

        spinAnim.pause();
    }
}, []);

return (
    <img
        className="animate-spin"
        ref={ref}
        src="/logo.jpg"
        />
);

The key here is how to extract the Animation object of the animation used as a CSS class.

As explained above, elements have had a method called getAnimations() for some time now. Using this method, you can get an array of Animation objects of all animations applied to the element. Since multiple animations can be applied simultaneously, you need to find the animation you want to control from this array.

The animation you're looking for can be found through the animationName property (2). animationName is not a property of the Animation type, but of its subtype CSSAnimation. As mentioned, if you define and use an animation with the JavaScript Web Animation API, it's an Animation object, and if you define an animation with CSS, it's a CSSAnimation. TypeScript can't infer this, so you need to do type casting (1). This is one of the few cases where casting is allowed in TypeScript.

animationName is the keyframe name used when defining keyframes in CSS.

@keyframes spin {  // Here, `spin` is the value of `animationName`
	from { transform: rotate(0deg); } 
	to { transform: rotate(360deg); } 
}

This code is actually Tailwind's animate-spin code. If you know the name, you can find and control any CSS animation, not just Tailwind.

Conclusion

We've taken a look at web animations. While we've been focusing on changes in open-source frameworks and libraries, it seems we've paid less attention to these useful new standard APIs. These days, when I dig through MDN, I often find myself thinking, "Huh? This existed?" When I discover such things, I organize them like this to study, and I hope it's helpful for others as well.

출처 [1] 20250304.jpg https://res.cloudinary.com/dow8qjmrt/image/upload/v1740723688/postcovor/20250304.jpg

♥ Support writer ♥
with kakaopay

Creative Commons LicenseThis work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

shiren • © 2025Sungho Kim