网页精灵图
Sprites on the Web

原始链接: https://www.joshwcomeau.com/animation/sprites/

## 用于高效动画的 CSS 精灵图 2015 年,Twitter 面临一个挑战,需要将他们的“收藏”图标更新为“喜欢”动画(一颗心)。由于低端移动设备的性能限制,使用单个 DOM 元素重现复杂的动画是不切实际的。他们求助于从电子游戏中借鉴的技术:**精灵图**。 精灵图将多个动画帧组合成单个图像(精灵图表)。CSS 然后一次只显示该图像的一部分,从而产生动画的错觉。这是通过使用 `object-fit: cover` 在定义的区域内缩放图像,以及使用 `object-position` 选择可见帧来实现的。关键帧动画随后循环遍历精灵图表内的不同位置。 至关重要的是,`steps()` CSS 定时函数用于确保离散的帧变化,避免模糊的过渡。虽然动画 GIF 是一种替代方案,但精灵图提供对速度、暂停的更大控制,并且通常具有更好的性能——尤其是在使用 `.avif` 等现代图像格式时。 精灵图擅长重复动画,例如闪烁效果,并允许进行动态调整(例如减慢呼吸动画)。但是,程序化动画每次都有独特的元素(例如粒子效果)更适合其他方法。

Hacker News 新闻 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Web 上的精灵图 (joshwcomeau.com) 11 分,vinhnx 发表于 1 小时前 | 隐藏 | 过去 | 收藏 | 2 条评论 求助 asib 21 分钟前 [–] 我喜欢 Josh 的博客和写作。我想知道在代理编写所有 CSS 的时代,这种内容有什么位置。它有点像手工木工现在 - 仍然会有人喜欢自己设计东西,但这将变得少得多。也许它本来就是这样。回复 zarzavat 5 分钟前 | 父评论 [–] LLM 实际上在编写 CSS 方面很糟糕。CSS 是 20% 的逻辑和 80% 的艺术。机器不理解什么是吸引人的,什么是丑陋的。我手动编写所有的 CSS,而且我预计在短期内不会改变。回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文
Introduction

In 2015, back when Twitter was still Twitter, their dev team had a problem.

In those early days, tweets could be “favourited” by clicking a little “⭐” icon. The product team wanted to migrate to “liking” tweets, Facebook-style, with a “❤️”. As part of this update, their designers created this lovely animation:

This looks super nice, but there’s kind of a lot going on in there; by my count, there are 16 separate elements all animating at the same time (14 particles, the popping circle, the heart). Twitter’s web app needed to run on very low-end mobile devices, so it wasn’t feasible to create this procedurally using DOM nodes. Instead, they decided to borrow a technique from video games: sprites.

The basic idea with a sprite is that we create a single image that contains each individual frame of an animation in a long strip. Then, we display each frame for a fraction of a second, like a roll of film sliding through an oldschool film projector:

In this blog post, I’ll show you the best way I’ve found to work with sprites in CSS, and share some of the use cases I’ve discovered. We’ll also talk about some of the trade-offs, to see when we shouldn’t use sprites.

First thing’s first, we need an asset! Let’s use a gold trophy sprite I created a few years ago:

To produce the illusion that the fire is flickering, I drew five different versions of the blue flames. These frames are stacked side-by-side in a single image known as a “spritesheet”:

Here’s the fundamental strategy: we’ll create an <img> tag and calculate its size based on one of these frames. We can then use object-fit and object-position to control which part of the sprite is currently visible, flipping through each frame using a CSS keyframe animation.

This full image has a native resolution of 2000px × 800px, and contains 5 frames. This means that each frame is 400px × 800px. In order for this image to look sharp on high-resolution displays, we’ll want to cut this size in half, so our final image will be 200px × 400px.

By default, <img> tags will try to squeeze the entire image content into the DOM node’s area, meaning we’ll wind up seeing all 5 trophies, crammed together:

Code Playground

Result

This happens because of the “object-fit” CSS property(opens in new tab). This property controls what happens when there’s a mismatch between the size of the underlying image and the size of the <img> element.

The default value is fill, which tries to ensure that the entire image is visible, even if it has to be squashed. Let’s switch to cover:

Code Playground

Result

Now we’re getting somewhere! cover will scale the underlying image so that it covers the entire area of the <img> node. As a result, we wind up seeing 1/5th of the total image.

Next, we can use the object-position property to control which part of the underlying image is shown:

If you’re familiar with the SVG format, what we’re doing here is conceptually similar to modifying the viewBox to control which part of the image is displayed. In this case, the <img> tag is a 200×400 window into our trophy sprite, and we can slide the underlying image data around using the object-position property.

We’re almost there, but there’s one final wrinkle we need to iron out: the animation. How do we set this up so that we flip between each trophy variant?

Let’s try adding a looping keyframe animation:

Code Playground

Result

The problem is that we’re sliding the image smoothly, rather than moving in discrete steps. For this technique to work, we need to display each of the 5 frames for an equal amount of time.

We could do this in JavaScript with setInterval(), but there’s an obscure CSS timing function we can use for this instead: steps.

The core idea with steps is that instead of transitioning smoothly using a Bézier curve, the value jumps between a specified number of midpoints. A staircase, instead of a ramp. This’ll be clearer with a visualization:

Timing function:

—— Progression ——

The steps timing function allows us to split the total progression into discrete values. In this case, we’re specifying 5 steps, and the animation will spend 1/5th of the total duration on each step.

We call the steps function with the number of total steps and the “step position”. We’ll unpack that in a bit, but first, here’s a complete implementation of our trophy sprite, sliding the image data within the <img> node using object-position:

Code Playground

Result

I think this is pretty cool. 😄

Link to this headingStep positions

In the playground above, you might’ve noticed something a bit odd:

.trophy {
  object-fit: cover;
  animation: sprite 1s steps(5, jump-none) infinite;
}

The steps() function takes two arguments. The first argument is the number of steps, which is pretty self-explanatory. But what on earth is jump-none?

The second argument is the “step position”, and it has a default value of jump-end. In this mode, steps() will exclude the final value from its discrete values. For example, if our keyframe definition goes from 0% to 100% and we set steps(5), the levels will be 0%, 20%, 40%, 60%, and 80%. It will never actually reach 100%.

Here’s a playground that showcases this clearly:

Code Playground

Result

Our fill keyframe goes from width: 0% to width: 100%, but the .bar element never gets beyond 80% width!

I found this quite perplexing at first, but I realized that this behaviour makes much more sense for non-looping animations:

Code Playground

Result

Over the course of this 2-second animation, the bar’s width grows from 0% to 80%. When the animation expires, right at the 2-second mark, the final value from our keyframe definition (width: 100%) is applied.

So, by default, steps() has a “step position” of jump-end, causing it to jump to the final value at the very end of the animation. Without the jump, our bar would become full-width at the 1.6 second mark, which would feel premature in a lot of situations.

When it comes to looping animations like our trophy sprite, however, we don’t want to do any jumping. We don’t want to land on the final frame right as the animation expires, we want to include that final frame as one of the 5 discrete values that we flip between. And we can do that by specifying steps(5, jump-none).

Now that we’ve covered the basics of this technique, let’s talk about when we should actually use it. And, just as importantly, when we shouldn’t.

I mentioned at the start that the Twitter development team chose to use a sprite-based approach in part due to performance considerationsSource: I met one of Twitter’s devs back at a conference in 2016, and he told me. I think this was valid back in 2015, but I would push back against this in 2026. Devices have gotten much faster and browsers have gotten much more optimized in the years since; even the lowest-end devices ought to be able to handle 14 particles animating at the same time without breaking a sweat. And when we use a sprite for something like this, we lose some of the magic.

In my upcoming course, Whimsical Animations(opens in new tab), we build the following “Like” button. Try clicking it a few times:

The lovely thing about this approach is that it’s a bit different every time you click on it. The particles are being procedurally generated using trigonometry and randomness. By contrast, Twitter’s “Like” button is exactly the same every time you click it. It’s like we’re replaying the same video, over and over and over. 😬

So, when should we use sprites? I think the main use case is for things that, well, look like sprites! In addition to the gold trophy example, here’s another example from a generative art project(opens in new tab) I released years ago:

This little cat wanders onto the screen after a while. If you hover over her, she’ll encourage you to follow me on Bluesky(opens in new tab).

It’s a very silly example, but I think it really showcases how much more powerful sprites can be, compared to animated GIFs. We can make it so much more dynamic. For example, if you don’t interact with her for a while, she falls asleep:

While sleeping, I pick a longer animation-duration so that her breathing slows!

While this technique is seldomly used on the web, it’s used all the time in video games. There’s an enormous number of spritesheets available online. You can use this technique to have a little Sonic or Mega Man run across your site!JOSH W COMEAU ASSUMES NO LIABILITY FOR ANY COPYRIGHT INFRINGEMENT CAUSED BY YOUR USE OF ANY INTELLECTUAL PROPERTY OWNED BY COMPANIES OR INDIVIDUALS INCLUDING BUT NOT LIMITED TO NINTENDO®, SEGA®, OR CAPCOM®.

And if you’d like to learn how to create top-tier animations and interactions, you should check out my upcoming course.

The course will teach you the fundamental techniques I use to create next-level animations and interactions in my work. The “Like” button is just one of many examples. If you’ve ever wondered how something on this blog works, there’s a very good chance we cover it in the course! ✨

Whimsical Animations should be released before the summer, and there may be a special discount for folks who sign up for updates. 😉

Last updated on

February 24th, 2026

联系我们 contact @ memedata.com