Why Sprites Returned to the Web
Back in 2015, when Twitter redesigned its "favorite" action into a "like," the team faced a performance problem. The heart animation they wanted involved 16 elements animating at once—14 particles, a popping circle, and the heart itself. Creating that with DOM nodes on the low-end mobile devices of the era was not realistic. Their solution borrowed directly from game development: a sprite sheet containing every frame of the animation, displayed in quick succession like frames of film.
Today's hardware and browsers are far more capable, so that specific performance constraint has loosened. Procedural animations with fresh trigonometry and randomness can offer richer, less repetitive results. But the sprite technique remains valuable for any imagery that is inherently frame-based, such as pixel art or pre-rendered sequences.
Building a Frame Animation with CSS
The core idea is to place all animation frames into a single image—the spritesheet—and then control which part of that image is visible. Modern CSS provides two properties for exactly this job, replacing the old background-position hacks.
Consider a 5-frame animation of a flickering flame on a trophy. The full image is 2000px × 800px, meaning each frame is 400px × 800px. To stay sharp on high-density displays, we should display it at half resolution: 200px × 400px.
An <img> tag alone won't do the work. By default, the browser applies object-fit: fill, which squeezes the entire spritesheet—all five frames—into the element's box, making the animation impossible. Setting object-fit: cover changes the behavior: now the underlying image scales so that it completely covers the element's area. Since the element is sized to match a single frame, the cover value reveals only one frame of the spritesheet.
With a single frame on display, the next step is to slide to the correct one. The object-position property controls which part of the underlying image is shown within the <img> window. Doing this manually shifts the view from trophy to trophy.
Conceptually, this acts like an SVG viewBox: the element is a fixed 200×400 viewport, and object-position slides the larger sprite beneath it.
To animate through the frames, we could use JavaScript timers, but CSS keyframes with a special timing function offer a cleaner path. The default easing curves produce a smooth slide between frames, which is visually wrong for this use case. The steps() timing function fixes this by dividing the animation timeline into a number of discrete jumps, creating the crisp flip between frames.
A complete implementation keys an animation that targets object-position, stepping through the five positions across the animation cycle:
.img {
width: 200px;
height: 400px;
object-fit: cover;
object-position: 0% 0%;
animation: play 1s steps(5, jump-none) infinite;
}
What About the Step Position?
The second argument to steps() is less intuitive. The default value is jump-end, which excludes the final value of the animation range from its jumped levels. In a keyframe that goes from 0% to 100% with steps(5), the discrete levels would be 0%, 20%, 40%, 60%, and 80%; the animation would never hold at 100% during the loop.
This default makes sense for non-looping animations, where reaching the end state exactly at the end of the duration feels correct. For a looping sprite animation, however, we need the final frame to be an active state. We do this by switching the step position to jump-none. This argument tells the function to include the upper bound as one of the discrete steps. With steps(5, jump-none), the levels are 0%, 20%, 40%, 60%, and 80%, allowing the animation to hold on the final frame for its allotted slice of time before looping back to the start.
When a Sprite Makes Sense
Performance is no longer the primary driver for this technique. Use it for images that fundamentally look like sprites: creature animations for pixel-art characters or pre-rendered illustrations with a fixed frame count.
The technique also shines in interactive contexts. A sprite-based character can react to user behavior. For instance, a wandering cat character could include separate animation loops for walking, interacting, and sleeping. If the user stops paying attention, the site can adjust the properties—maybe switching to a different spritesheet or lengthening the animation-duration to slow the breathing cycle. This level of polish is not as practical with an animated GIF.
Thanks to the game-dev ecosystem, there is no shortage of spritesheets to experiment with. You can now drop a sprite-based character onto almost any page with just a few CSS rules and some carefully chosen keyframes.



