How to Make a High-Quality GIF From Video
By Mark Fulton · 2026-09-02 · 12 min read

A GIF looks bad for one reason: the format holds at most 256 colours per frame, so converting a video means repainting it with a box of 256 crayons. Quality is entirely a question of which 256 you pick. Build the palette from your own clip rather than accepting a fixed one, hold the width at 480 pixels or under, drop to 12 frames per second, and match the dither to the kind of footage you have. In the exports I measured for this post, swapping a fixed generic palette for one generated from the clip moved structural similarity against the source from 0.215 to 0.975 on a screen recording, and from 0.198 to 0.926 on camera-style footage, at roughly the same file size. No other setting comes close to that.
Everything below is measured rather than estimated. I rendered three six-second, 1280x720, 30 fps test clips with FFmpeg specifically for this post, converted each one at several settings, and read the byte counts off the resulting files. Where I quote a quality number it is SSIM, a structural similarity score where 1.000 means identical, computed with FFmpeg's ssim filter against the same clip scaled to the same output size. Comparing against the scaled reference rather than the original 720p means the score isolates the colour reduction instead of punishing the downscale, which every GIF has to do anyway.
The three clips:
- Screen recording. A dark code editor: header bar, lighter sidebar, flat-colour text rows scrolling upward, a cursor tracking across. Flat UI colour throughout, hard edges, large uniform areas.
- Camera-style footage. A slowly shifting four-colour gradient with the hue rotating and per-frame grain added. Continuous tone, no flat regions, noise in every frame. This is the expensive case.
- Flat-colour animation. Solid red and navy shapes sliding across a cream background over a blue bar. Half a dozen colours, no texture at all.
Why do GIFs look banded and grainy?
Because of a three-bit field written into the format in 1990.
In the GIF89a specification, the size of a colour table is stored in three bits, and the reader is told to raise 2 to the value of that field plus one. Three bits means a maximum value of 7, which means 2 to the power of 8, which means 256 entries, each an RGB triplet. That is the whole ceiling. MDN's guide to image file types puts it the same way from the decoder's side: every pixel is a single 8-bit value indexing into a palette of 24-bit colours.
Your source video does not have 256 colours. A 1080p screen recording with antialiased text has tens of thousands. Camera footage has millions. So every conversion quantises, and quantisation is where the damage happens. A smooth sky becomes visible steps. Antialiased text edges snap to whichever of the 256 is closest, which is what makes converted screen recordings look chewed. Dithering scatters the error across neighbouring pixels so your eye averages it back out, and that scattering is the grain people complain about.
On top of that, GIF stores each frame as a complete image compressed with variable-length-code LZW, an algorithm that hunts for repeated runs inside one picture and knows nothing at all about the frame before it. There is no motion compression to hide behind. The full arithmetic of why that balloons a file is worth reading before you commit to the format at all.
What does a generated palette change?
Almost everything, and it is the step most converters skip.
The lazy route maps every frame against one fixed palette that was decided before your file existed. FFmpeg has such a palette built in as the rgb8 pixel format: three bits of red, three of green, two of blue, evenly spaced across the colour cube. It is a legitimate 256-colour palette. It just has nothing to do with your footage. The better route runs FFmpeg's palettegen filter across the clip first, builds the 256 entries out of the colours that are actually in it, and only then maps the frames.
Same file format. Same colour count. Here is the difference, at 480 pixels wide and 12 fps, 72 frames each:
| Clip | Fixed generic palette | Palette generated from the clip |
|---|---|---|
| Screen recording | 8,663 bytes · SSIM 0.215 | 10,915 bytes · SSIM 0.975 |
| Camera-style | 2,229,459 bytes (2.13 MB) · SSIM 0.198 | 2,888,776 bytes (2.76 MB) · SSIM 0.926 |
| Flat animation | 8,620 bytes · SSIM 0.296 | 8,631 bytes · SSIM 0.982 |
Look at the flat animation row. Eleven bytes of difference in file size, and a fidelity gap of 0.686. That clip uses six colours. A generated palette spends six of its 256 entries reproducing them exactly and wastes the rest. A fixed palette has to approximate all six, because a cream that is not on the 3:3:2 grid simply is not available, and the result is an entire image of near-misses.
The screen recording row is the same story with a twist worth knowing: the generated palette produced a larger file. Reproducing a colour exactly means no dither pattern is needed to fake it, but it also means the encoder is holding more distinct values in play. Paying 2,252 bytes for four times the fidelity is not a trade anyone would decline, but it is a trade, and pages that promise smaller and better at once are selling something.
One extra setting matters here. palettegen accepts stats_mode=diff, which, in FFmpeg's words, computes histograms only for the part that differs from the previous frame. On a screen recording where the sidebar never moves, that stops a static background from eating palette entries that the moving content needs. It is what our converter uses, and on UI capture it is the right default.
Which dither suits which content?
Dither exists to hide banding, and it is not free. FFmpeg's paletteuse offers ordered Bayer dithering plus six error-diffusion algorithms and none, defaulting to sierra2_4a. The two families behave very differently once you put them in a loop.
Ordered dithering lays down a fixed crosshatch pattern. It is deterministic, so the same colour lands on the same pattern in every frame. Error diffusion recalculates from scratch each frame, pushing quantisation error into neighbouring pixels, which means the noise it creates moves between frames even when the picture does not. FFmpeg's own documentation acknowledges this when describing diff_mode, noting that limiting error diffusion to the changing rectangle gives less moving noise and better GIF compression.
Measured, at 480 pixels and 12 fps with a generated palette in every case:
| Clip | Bayer (scale 4) | sierra2_4a | No dither |
|---|---|---|---|
| Screen recording | 10,915 B · 0.975 | 7,802 B · 0.995 | 7,778 B · 0.995 |
| Camera-style | 2,888,776 B · 0.926 | 3,828,562 B · 0.719 | 2,443,048 B · 0.930 |
| Flat animation | 8,631 B · 0.982 | 6,850 B · 0.998 | 6,850 B · 0.998 |
Two clear readings.
On flat content, dither is a cost with no benefit. There is no banding to hide, because a generated palette already holds those colours exactly. Bayer still stamps its pattern over the whole frame, which costs bytes and pulls the score down. On the animation clip, sierra2_4a and none produced byte-identical files: with nothing to diffuse, error diffusion degenerates into no dithering at all.
On grainy continuous tone, error diffusion is the expensive option. It grew the camera file by 33% against Bayer and scored 0.719, the worst result in the table. Grain is error, and diffusing error across a noisy frame amplifies it and destroys the long identical runs LZW feeds on.
Note the asymmetry. Bayer's worst deficit across the three clips was 0.020. sierra2_4a's worst was 0.211. That is why a single fixed choice can be defensible: ordered dithering never falls off a cliff, and it stays still between frames. And bear in mind what SSIM cannot see. It compares frames numerically, so it does not object to a banded gradient the way your eye does, and it does not register shimmer across a loop at all. Treat none as a candidate only when your source really is flat colour.
The pattern's visibility is adjustable. FFmpeg's bayer_scale runs from 0 to 5, and the docs are blunt about the trade: a low value means a more visible pattern for less banding, a higher value means a less visible pattern at the cost of more banding.
What is the recipe for each kind of clip?
Four levers, in the order they matter. Width is quadratic, since halving it halves the height too. Frame rate is linear. Palette source decides fidelity. Dither decides texture.
| Lever | Screen recording | Camera footage | Flat-colour animation |
|---|---|---|---|
| Width | 480 px | 320 px | 480 to 640 px |
| Frame rate | 12 fps | 12 fps | 8 fps |
| Palette source | Generated from the clip, stats_mode=diff |
Generated from the clip | Generated from the clip |
| Dither | Ordered, or none if the UI is genuinely flat | Ordered | Barely matters, none is smallest |
| Measured at that recipe | 10,915 bytes | 1,326,776 bytes (1.27 MB) | 8,031 bytes |
The camera row is where the width call earns its keep. That same clip at 480 pixels came out at 2.76 MB, so dropping to 320 cut it to 46% of the size. Continuous-tone footage is the one case where you should give up pixels before you give up anything else, and it is also the case where a GIF is hardest to justify at all.
Our own video to GIF converter exposes two of those four levers directly: frame rate at 8, 12 or 18, and width at 320, 480 or 640. The other two are decided for you. It always generates the palette from your clip with stats_mode=diff, always applies ordered Bayer dithering at scale 4, scales with lanczos, and loops forever. Everything runs through FFmpeg compiled to WebAssembly inside your own browser, so the file is read from your disk into the page and written back to it. If you need to shrink the source first, the resize tool works in height presets and only downscales, so run it before the conversion rather than after.
Free exports carry a 200 MB input cap and a small semi-transparent mark in the corner, and the mark is composited before the palette is generated so its colours are accounted for rather than crushed. Pro removes both, at $4/mo billed $24 per six months, or $79 once for life.
How low can the frame rate go before it stutters?
Lower than most people expect, and it depends entirely on what kind of motion you captured.
Frame rate multiplies file size linearly, which makes it the cheapest large cut after trimming. The camera clip at 480 pixels and 18 fps came to 4.18 MB against 2.76 MB at 12 fps, a factor of 1.52 for exactly 1.5 times the frames. That is the whole relationship, and it holds.
- 8 fps suits anything that changes in discrete steps: clicking through a UI, a form validating, shapes sliding into place. The flat animation clip at 8 fps was 8,031 bytes and read perfectly well.
- 12 fps is the default worth keeping. A cursor gliding across a screen still looks like a cursor gliding.
- 18 fps earns its 50% size penalty only when something moves continuously and you are specifically demonstrating that motion.
The trap is not the number, it is the content. A screen that sits still between clicks is nearly free at any frame rate because consecutive frames compress hard. A screen that scrolls changes every pixel every frame, and GIF pays the full price for each one. If you can show the thing without scrolling, cut instead.
When should this be an MP4 instead?
Whenever the destination will accept video, which is most places.
Google's web.dev guidance on replacing animated GIFs with video measures a 3.7 MB GIF re-encoded to 551 KB of MP4 and 341 KB of WebM. My own numbers say the same thing from the other direction: the camera-style source MP4 was 2,186,428 bytes at 720p and 30 fps, while the GIF made from it was 2,888,776 bytes at 480 pixels and 12 fps. A third of the resolution, 40% of the frames, and still 32% larger.
The exception is real, though. GIF behaves like an image, so it plays anywhere an <img> tag works: READMEs, documentation, issue trackers, forums, wikis, old CMSes, email. No play button, no codec negotiation, no embed permissions. That is worth a lot, and it is why the format refuses to die. If the target is a chat app, check first: what actually posts and plays in Slack is a different question from what the format can do.
The flat animation clip is the honest counter-example. Its source MP4 was 11,365 bytes and the 480-pixel GIF was 8,631. For simple flat-colour motion with few colours, GIF is genuinely competitive.
Frequently asked questions
How many colours can a GIF hold?
256 per frame, and that is a hard limit written into the specification. The colour table size field is three bits, so the largest table is 2 to the power of 8. One entry is usually spent on transparency, which is why FFmpeg's palettegen reserves a slot by default and builds 255 colours instead. Animated GIFs can carry a separate local colour table per frame, so a whole file can display more than 256 colours over time, but any single frame is still capped.
Why is my GIF bigger than the video?
Because there is no motion compression. H.264 spends its bits describing what changed between frames, so a still shot costs almost nothing. GIF stores every frame as a complete image and compresses each one on its own with LZW. The camera-style clip measured here came out 32% larger as a GIF than as its source MP4, despite losing two thirds of the resolution and 60% of the frames. Trim first, then cut width, then cut frame rate.
What fps should a GIF be?
12 fps for most things. Drop to 8 for step-by-step UI capture, where nothing moves continuously. Go to 18 only when you are demonstrating smooth motion and can afford roughly 50% more bytes. Above 18 you are paying for frames that a small looping image cannot show off.
Can a GIF have transparency and motion?
Yes, but the transparency is binary. The Graphic Control Extension carries a transparency flag and a transparent colour index, and when a pixel matches that index the display simply leaves it alone. There is no alpha channel and no partial transparency, so a soft or antialiased edge over an unknown background will show a hard fringe. If you need real alpha with motion, you need a video format or APNG.
Ready to convert one? Make the GIF here and the palette is taken from your own clip, in your own browser, with nothing uploaded.
Sources
- CompuServe, Graphics Interchange Format Version 89a specification
- MDN Web Docs, Image file type and format guide
- FFmpeg project, palettegen and paletteuse filter documentation
- Google web.dev, Replace animated GIFs with video