React Animated Sprites
I recently wrote a React Animated Sprite component to add juice to a project. It’s primarily used for FX reveals, but there’s no reason it couldn’t be applied to characters!
If you want to jump to the code, find it at: https://stackblitz.com/edit/vitejs-vite-3azgatbp?file=src%2FApp.tsx
Here’s what you’ll need for your own totes-sweet implementation:
1. A Sprite Atlas
Let’s start with the data source. Your atlas will need to contain:
- The spritesheet image url
- A list of animations and the frame definitions inside each
- An anchor coordinate
// hero.json
{
// ye old src
"src": "walk.png",
// anchor position
"anchor": [0.5, 0.5],
// all animations with a string array of the frames keys
"animations": {
"up": ["up0", "up1", "up2", "up3", "up4", "up5", "up6", "up7", "up8"],
"left": [
...
],
...
},
// all frames keyed by animation type
"frames": {
// named frames
"up0": {
"source": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
},
"up1": {
"source": {
"x": 64,
"y": 0,
"w": 64,
"h": 64
}
},
...
},
}
A React component w/ a ref Callback
React batches state updates, and at a certain point that’s going to get both choppy and expensive. If you want performant animation, you’ve got to step outside of React and call the magic of requestAnimationFrame.
function AnimatedSprite({ atlas, animation, fps = 12 }: SpriteProps) {
const abortController = useRef<AbortController | null>(null);
const ref = useCallback(
(node: HTMLDivElement | null) => {
// 1. Update the sprite anchor position if the animation has changed it
// 2. Use AbortController to cancel any existing animations.
// 3. Start an animation ticker thread that'll continually update the node.
},
[atlas, animation, fps]
);
return <div ref={ref}>
}
( Ref callbacks are great if you need to modify the DOM immediately after mount! Tanstack guy has a great post on them )
Recursive Animation Ticker
Once started, this method will be continually called whenever the browser has a spare cycle. It’s effectively a more animation-friendly `useInterval`.
You’ll want to make sure your refresh rate is tied to elapsed time rather than clock speed, otherwise it’ll run faster on newer machines!
interface AnimationTickOptions {
node: HTMLElement;
frames: SpriteFrame[];
frameDuration: number;
initialTimestamp: number;
signal: AbortSignal;
}
async function animationTick({ node, frames, frameDuration, initialTimestamp, signal }: AnimationTickOptions) {
// early exit if parent has killed this thread.
if (signal.aborted) {
return;
}
requestAnimationFrame((currentTimestamp) => {
// determine how much time has passed since this module was loaded.
const elapsedFrames = (currentTimestamp - initialTimestamp) / frameDuration;
// Modulus that down to determine which frame is currently active.
const currentFrameIndex = Math.round(elapsedFrames) % frames.length;
// apply the props from the current frame
const currentFrame = frames[currentFrameIndex];
node.dataset.frame = String(currentFrameIndex);
node.style.backgroundPosition = `${currentFrame.source.x * -1}px ${currentFrame.source.y * -1}px`;
node.style.width = `${currentFrame.source.w}px`;
node.style.height = `${currentFrame.source.h}px`;
// queue up another recursive tick!
animationTick({
node,
frames,
frameDuration,
initialTimestamp,
signal,
});
});
}
If more than one component needs to update according to this ticker, you’ll likely want a parent controller to call it on each component rather than having a dozen competing tickers.
And that’s it! You should now be able to load up a spritesheet and iterate through it.
A bonus trick: Use an anchor pixel for floating elements with flexible dimensions
If you’re not sure what size your sprite will be, but want to position it absolutely on your stage, wrap it in a 1x1 anchor pixel and absolutely position the contents.
// AnimatedSprite.tsx
<div className={styles.anchorPixel}>
<div className={styles.sprite} ref={ref} />
</div>
// AnimatedSprite.module.css
.anchorPixel {
position: relative;
width: 1px;
height: 1px;
}
.sprite {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
background-repeat: no-repeat;
image-rendering: pixelated;
}