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.
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.
<div class="frame"> <img class="lever" src="lever.png" role="presentation" /></div>
.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.
: 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.
<div class="wrapper"> <input type="checkbox" class="checkbox" /> <div class="frame"> <img class="lever" src="lever.png" role="presentation" /> </div></div>
: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.
- Set up a new property for the spritesheet's Y offset.
- Change the value of that property when the checkbox is checked.
- Add a
transition
property so that the shift doesn't happen immediately. - 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.

<div class="wrapper"> <input type="checkbox" class="checkbox" /> <div class="frame"> <img class="lever" src="lever.png" role="presentation" /> </div></div>
: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; }}