K

Interactive Pixel Spritesheet Animation with CSS

Learn how I used some new CSS features (like the @property at-rule and the round function) to create an interactive pixel animation entirely with CSS.

Posted: May 10, 2025
Find more posts about:

I've dabbled in pixel art here and there (see Fungus February), and recently I had an idea to draw a pixelized lever to use for a light/dark mode toggle. However, I wanted to try to handle the animation and interactivity entirely with CSS.

Spritesheet setup

First, I started out by creating a spritesheet of all six frames of my animation.

A pixel art spritesheet of a lever

By using a spritesheet, we can handle our entire animation using a single asset. Each frame is 32x32 pixels. We can display our image with a regular <img> tag, then wrap it in a <div>, set its height to the size of a single frame, and cut off any overflow.

html
<div class="frame">
<img
class="lever"
src="lever.png"
role="presentation"
/>
</div>
css
.frame {
height: 32px;
width: 32px;
overflow: hidden;
.lever {
width: 32px;
}
}

This is rather small, so let's scale it up We'll use a custom CSS property --pixel-size as our scaling factor. Also, since we're scaling up such a small image, we'll need to add a rule to tell the browser to keep things pixelated: image-rendering: pixelated;. Without this, the browser would try to smooth out our image while enlarging it.

css
: root {
--scale: 4;
--frame-size: calc(var(--scale) * 32px);
}
.frame {
height: var(--frame-size);
width: var(--frame-size);
overflow: hidden;
image-rendering: pixelated;
.lever {
width: var(--frame-size);
}
}

User interaction

Now that we have our image and frame set up, we need a trigger to kick off the animation. Since we need to allow for user interaction and need a boolean state, a checkbox will work perfectly. Let's set up a checkbox adjacent to our lever frame. We can put a wrapper around them both, then use some absolute positioning to place the checkbox above the image.

html
<div class="wrapper">
<input
type="checkbox"
class="checkbox"
/>
<div class="frame">
<img
class="lever"
src="lever.png"
role="presentation"
/>
</div>
</div>
css
:root {
--scale: 4;
--frame-size: calc(var(--scale) * 32px);
}
.wrapper {
position: relative;
width: var(--frame-size);
}
.checkbox {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.frame {
height: var(--frame-size);
width: var(--frame-size);
overflow: hidden;
image-rendering: pixelated;
.lever {
width: var(--frame-size);
}
}

Create the animation

Finally, we have everything we need to set up our animation! Whenever the checkbox has the checked state, we'll shift our spritesheet down so that it sits on the last frame. We'll need a few pieces to make this work well.

  1. Set up a new property for the spritesheet's Y offset.
  2. Change the value of that property when the checkbox is checked.
  3. Add a transition property so that the shift doesn't happen immediately.
  4. Add a translate property to the lever spritesheet so it follows the offset we defined.

1. Offset property

First, let's create a CSS custom property for our spritesheet offset. Instead of just defining it on the :root, though, we're going to use the @property at-rule. Defining it this way lets the browser know that our property is a length, which means it can be animated. We can also set our initial offset to 0px.

@property --lever-offset {
syntax: '<length>';
initial-value: 0px;
inherits: false;
}

2. Update property when checked

With our offset property defined, let's update it based on the checkbox's state. By default, our offset is already 0px, so we don't need to worry about the unchecked state. When our checkbox is checked, then, let's set the offset to go all the way to the end of the spritesheet. We have six frames total, but five that need to be animated to, so we can multiply five by the size of our frame. We'll also make it negative so that the spritesheet moves up the page and not down.

.checkbox {
... &:checked ~ .frame .lever {
/* This comes out to -640px */
--lever-offset: calc(-5 * var(--frame-size));
}
}

3. Transition our property

Now, let's take advantage of the fact that our property can be animated/transitioned. On our <img>, we're going to set a transition property. Normally, we'd set this to watch a certain property, like transition: transform ..., transition: opacity ..., or even transition: all .... But we can tell the browser to explicitly use our property:

.lever {
width: var(--frame-size);
transition: --lever-offset 500ms linear;
}

This means that whenever the checkbox is checked, instead of flipping our value from 0px immediately to -640px, the browser will actually interpolate linearly from 0 to -640 over 500ms.

4. Move the spritesheet

Now that our offset property is defined and is being updated, let's consume it. We're going to translate our image by the offset amount. The translate property takes the X axis translation first, so we'll pass 0 then our variable.

.lever {
width: var(--frame-size);
translate: 0 var(--lever-offset);
transition: --lever-offset 500ms linear;
}

From here on out, the levers you see are interactive! Give them a click to see them in action.

There's obviously something wrong with this animation; there's another constraint we need to add. As you can see from the demo above, animating this now causes our spritesheet to smoothly glide from one end to another. What we need instead is to jump from one frame to the next. We can do this with the new CSS round function. This function lets us pass in our current value and a "rounding interval", which we can treat like a step. So we'll pass in our image offset as the value and the size of our frame as the rounding interval.

.lever {
width: var(--frame-size);
translate: 0 round(var(--lever-offset), var(--frame-size));
transition: --lever-offset 500ms linear;
}

This is actually the first way I approached this animation!

transition: --lever-offset 500ms steps(5);

Open for details on what went wrong:

The steps function seems to split our offset from 0 to -640 into five equal pieces and jump from frame to frame the same way.

256 -> 384 -> 512 -> 640

There's a slight bug that occurs, though, and it reveals a bit about how the steps function actually works under the hood. If the user clicks the checkbox and quickly clicks it again before the animation is complete, the animation will reverse, _but_ it won't stick to these steps!

I've slowed down the animation below; click to start it, then click again to interrupt and reverse it.

So, it seems like the step function is actually just running a linear interpolation behind the scenes, and each time 1/5 of the total amount passes (128 in this case), it emits an update. If a user were to reverse the animation when the internal linear function were at 300, for example, the step function would start counting from there! It would emit an update at 300, 172, 44, and 0. This means that instead of seeing our nice, clean frames, our image now starts jumping in the middle of frames.

By taking over this calculation ourselves with round, we have more control over what we actually want to happen. Also, an additional benefit is that we don't have to keep our interpolation linear! We could swap to an easing function and make certain frames stay longer than others.

Conclusion

Our animation is now complete! We have an interactive pixel art spritesheet animation coded entirely in CSS.

html
<div class="wrapper">
<input
type="checkbox"
class="checkbox"
/>
<div class="frame">
<img
class="lever"
src="lever.png"
role="presentation"
/>
</div>
</div>
css
:root {
--scale: 4;
--frame-size: calc(var(--scale) * 32px);
}
@property --lever-offset {
syntax: '<length>';
initial-value: 0px;
inherits: false;
}
.wrapper {
position: relative;
}
.checkbox {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
&:checked ~ .frame .lever {
--lever-offset: calc(-5 * var(--frame-size));
}
}
.frame {
height: var(--frame-size);
width: var(--frame-size);
overflow: hidden;
image-rendering: pixelated;
.lever {
width: var(--frame-size);
translate: 0 round(var(--lever-offset), var(--frame-size));
transition: --lever-offset 500ms linear;
}
}