Toolkitly

How to turn a sprite sheet into an animated GIF

A sprite sheet is for engines. A GIF is for everyone else — Discord, an issue tracker, a store page, a tweet. Converting between them is mostly a set of decisions about the format's limits rather than about your art. If the sheet was exported with its JSON, two of those decisions are already made for you.

1. Get the grid right first

Everything downstream depends on the cut. If the grid is a pixel off, every frame carries a sliver of its neighbour and the GIF flickers along one edge — which is far more obvious in motion than it ever was in the static sheet.

For a sheet exported by an animation tool, the columns and rows are usually obvious and the cell size falls out of the sheet dimensions. Watch for spacing: exporters often leave a pixel or two between cells to stop the GPU sampling across frame boundaries, and that padding has to be declared or the grid drifts a little further off with every column.

For a packed atlas with frames of different sizes, there is no grid to find — use automatic detection, which finds islands of non-transparent pixels instead. The Sprite Sheet Cutter covers the same ground with more control if the sheet is awkward.

If the sheet came with a JSON, none of this applies. An Aseprite or TexturePacker export states every frame's rectangle outright. Drop the .json in alongside the PNG and there is nothing to find, nothing to detect and nothing to get off by a pixel. That is the whole of this step gone, and it removes the most common cause of a flickering edge.

2. Narrow the range to one animation

Sheets rarely hold a single animation. The usual layout is one animation per row — idle, walk, attack, hurt — and exporting the whole sheet gives you a GIF that cycles through all of them, which is nobody's intent.

Set the first and last frame to the row you want. If the sheet is 8 columns wide, row 2 is frames 8 to 15. Play it before exporting: a row that looked complete in the sheet sometimes turns out to have two padding cells at the end, and those export as a pause.

Aseprite frame tags do this for you. If the animations were tagged in the document, the export's JSON carries those ranges by name, so picking walk from a list beats counting cells across a sheet. Tags also record a direction, so a ping-pong tag exports as a ping-pong rather than as a loop that snaps back at the end.

3. Per-frame timing, which is what GIF actually stores

This is worth understanding before the next step, because it reverses the usual assumption. A frame rate is an averaging of timing. GIF does not store one — it stores a delay on every frame independently. So the format is natively capable of something a single frame-rate slider cannot express, and most sprite sheet to GIF conversions throw that capability away because a bare PNG grid has no timing in it to keep.

An Aseprite export does. Every frame in its JSON carries a duration in milliseconds, taken from the document's own timeline, and those durations are usually not uniform — which is the point. A walk cycle typically holds the two contact frames, where a foot lands and takes the weight, longer than the passing frames between them. An impact holds one frame for four times the length of its neighbours. Played back at a flat 12 fps, exactly the part that was authored gets flattened out.

Carrying the durations through to the GIF is therefore not a nicety; it is the difference between exporting the animation and exporting the frames of it. The Sprite Animation Previewer plays the imported timings and writes the same delays into the GIF, so what downloads is what you watched.

The rounding in the next section still applies, per frame rather than globally: a 100ms Aseprite frame lands exactly as 10cs, while 33ms is written as 30. And a sheet without a JSON has no timing to recover, so a single frame rate is genuinely the best available answer — pick it by watching, not by arithmetic.

4. Pick a frame rate the format can store

This is where GIF surprises people. It does not store a frame rate — it stores a delay per frame, in hundredths of a second. So the rates it can express exactly are the ones that divide 100 cleanly:

25 fps → 4cs · 20 fps → 5cs · 12.5 fps → 8cs · 10 fps → 10cs · 5 fps → 20cs

Anything else rounds. Ask for 12 fps and you get 8cs, which plays at 12.5. Ask for 24 and you get 4cs, which plays at 25. The difference is usually invisible, but if you are matching the GIF to a video or a game running at a known rate, choose a rate from the list above and avoid the question.

Two harder limits: GIF cannot go faster than 50 fps at all, and many viewers treat a delay of 0 or 1 centisecond as 10, which means a "100 fps" GIF plays at 10. Anything above about 25 fps is outside what the format was built for.

5. Understand what GIF does to your colours

A GIF frame can use at most 256 palette entries, and one of them has to be spent on transparency if the animation has any. That leaves 255 colours for the artwork.

For pixel art this is a non-issue — a sprite drawn from a 32-colour palette comes through byte-for-byte identical. For a rendered or photographic source it is not: the encoder has to quantise, choosing 255 representative colours and mapping everything else to the nearest one, which shows up as banding in gradients.

Transparency is one bit. Each pixel is either fully transparent or fully opaque; GIF has no partial alpha. A hard-edged sprite is unaffected. A sprite with a soft glow, a drop shadow or anti-aliased edges will show a hard boundary where the PNG had a fade — and if the semi-transparent pixels get treated as opaque, you get a visible halo in whatever colour was underneath.

If either of those is a problem, GIF is the wrong container. Animated WebP and APNG both carry full alpha and full colour, and every current browser plays them; GIF survives because Discord, Slack and old forum software still assume it.

6. Scale before you export, not after

A 32×32 sprite is a postage stamp on a modern display. Export it at 4× or 8× so it reads at a glance — but scale with nearest-neighbour sampling and a whole factor, or the pixels come out uneven or blurred and you have undone the art.

Scaling up also costs surprisingly little in file size. GIF compresses runs of identical pixels, and a 4× upscale turns every pixel into a 4×4 block of identical pixels — which is exactly what the compressor is good at. An 8× GIF is typically a fraction of 64 times the size of the 1× one.

Do the scaling as part of the export rather than afterwards in an image editor. A second pass over a finished GIF means re-quantising an already-quantised palette, and the losses stack.

Common problems

A thin flickering line along one edge

The grid is off by a pixel, or the sheet has spacing that was not declared. Zoom in and check the cut before exporting — or import the sheet's JSON, which states the rectangles instead of leaving them to be worked out.

The animation pauses once per loop

Empty padding cells at the end of the row are being exported as frames. Narrow the range, or turn on skip-empty.

The GIF plays slower or faster than the preview

Frame rate rounding. Pick a rate that divides 100 — 10, 20 or 25 fps.

The motion feels mechanical compared to Aseprite

Every frame is being held for the same length of time. The document's own durations are not uniform; import the JSON so they survive into the GIF.

A very short frame plays slower than it was drawn

GIF cannot express a delay under 20ms, so anything shorter is raised to it. A 10ms Aseprite frame becomes 20 and the timing around it shifts. There is no fix inside the format — above roughly 25 fps you are outside what GIF was built for.

A coloured halo around the sprite

Semi-transparent edge pixels being forced opaque by GIF's one-bit alpha. Remove the soft edge, or use WebP or APNG instead.

The file is enormous

Too many frames, too many colours, or both. Trim the range first; reducing the palette helps far less than removing frames.

Make a GIF now