The guide
A web page cannot film itself, so a CSS animation has to be drawn twice to become a GIF
Keyframes describe motion as a handful of states and a curve between them. Turning that description into a looping image file means redoing the browser's arithmetic and then living inside a format from 1990.
- 256 colors the most one GIF color table can hold
- hundredths of a second the unit of every GIF frame delay
- 4,096 codes the size of the LZW table before it must be cleared
- 15 frames in the one-second bounce of the worked example
- 3.7 MB to 341 KB one GIF re-encoded as WebM in a published comparison
Read the full guide Fold the guide
A box bounces on a web page. It looks like a short film, and the obvious wish is to press a button and save it as one. The page cannot do that. The animation is not a film at all. It is a few lines of text that the browser turns into pictures many times a second, and the page that wrote those lines is never handed the pictures.
Why the page cannot simply record itself
A script can paint onto a canvas and read the pixels back, but the list of things a canvas will accept is short: an image element, a video, another canvas, a bitmap. An arbitrary element with its styles, fonts and transforms is not on the list. There is no call that says “draw this div as it looks right now”.
The browser does have a way to film the screen. The Screen Capture API hands a page a live video stream of a display, a window or a tab, and it is treated as a powerful feature: the person is asked to choose what to share and is prompted for permission. That is the right design for a video call. It is a clumsy one for exporting a 200 pixel box, because the result is a recording of a region of the screen, not the animation’s own frames at a size and rate of your choosing.
So an honest exporter does something else. It reads the keyframes as text, works out for itself where the element should be at each instant, draws that on a canvas, and encodes the canvas. The drawing is a reconstruction. Its value depends entirely on whether the arithmetic matches the browser’s, which is why the reconstruction belongs beside the real thing, driven by the same clock, with the two sets of numbers compared.
Reading pasted CSS has a second, quieter problem. Style sheets can name files to fetch, and text dropped into a page as a style sheet is carried out, not just displayed. A careful tool never lets the pasted text reach the browser’s style engine. It parses the text into plain numbers, discards what it does not understand, says so, and builds everything the page sees from those numbers alone.
What keyframes actually say
A keyframes rule is a list of stops. Each stop has a position, written as a percentage or as the words from and to, and a set of property values that should hold at that moment. Nothing is said about the moments in between. If the rule leaves out the start or the end, the browser builds that keyframe from the values the element already has, which is how a rule with only a 50% stop can lift a box and set it down again.
The in-between is the job of interpolation, and each kind of value has its own rule. Opacity and lengths blend as numbers. Colors written in the old notations, hex codes and rgb() and hsl(), blend in gamma-encoded sRGB with their alpha premultiplied, a choice the specification keeps for compatibility with the pages already on the web.
Transforms are the delicate case. Two lists of functions are blended one pair at a time as long as the functions match, so rotate(0deg) to rotate(360deg) is a full turn. Where they stop matching, the remainders are multiplied out into matrices, the matrices are pulled apart into a translation, a scale, an angle and a small remainder, and those parts are blended. A full turn expressed as a matrix is no turn at all.
Then there is time itself. Duration, delay, iteration count, direction and fill decide which part of the keyframe list applies at a given instant, and the rules are precise enough to be an algorithm: find the active time, divide by the duration, take the fraction, flip it on reversed iterations. The Web Animations model writes these steps down, and the same model is exposed to scripts, where an element’s animate() method takes keyframes and timing and returns an object whose current time can be set by hand. That is what makes a scrubber possible.
The curve between two stops
Even a perfect blend would look mechanical if progress ran evenly, so every interval between keyframes is bent by an easing function. The familiar keywords are shorthand for cubic Bezier curves: ease is cubic-bezier(0.25, 0.1, 0.25, 1), ease-in is (0.42, 0, 1, 1), ease-out is (0, 0, 0.58, 1). The curve starts at (0, 0) and ends at (1, 1). The four numbers are the coordinates of two control points that pull it out of a straight line.
The horizontal axis is time and the vertical axis is progress. The catch is that a Bezier curve is not given as progress for a time. Both coordinates are functions of a hidden third parameter, so finding the progress at a given time means first solving a cubic for that parameter. The control points must keep their horizontal coordinates between 0 and 1, which guarantees one answer for every instant. Their vertical coordinates are free, and a point above 1 makes the motion overshoot its target and settle back.
A timing function written in a keyframe applies from that keyframe to the next one, not to the whole animation. That is why a bounce with ease does not ease once across the second. It eases up, then eases down.
Steps are the other family. A steps() function divides the interval into equal parts and holds still inside each, and its jump term says whether the first jump happens at the very start, the last at the very end, both, or neither. With jump-none, five steps show five evenly spaced values including both ends. With jump-both, three steps pass through four jumps. It is the right tool for sprite sheets and ticking hands.
What a GIF can hold
The file on the other side of this work was specified by CompuServe on 31 July 1990. A GIF frame is a grid of small numbers, each an index into a table, and a table holds at most 256 colors.
Reducing means choosing which 256 colors, and the classic answer is the median cut, which Paul Heckbert published in 1982. Enclose all the colors in a box and split it at the median along its longest side, so each half holds the same number. Repeat until there are as many boxes as palette entries, then average the colors in each.
A flat drawing rarely needs this. A blue box on white, with the soft pixels along its edges, came to 109 distinct colors over a whole bounce in one test, and those fit exactly. Photographs and gradients do not, and that is where ordered dithering earns its place, nudging each pixel by a small repeating pattern of thresholds so that neighboring palette colors mix by eye.
The index grid is then compressed with LZW. The encoder builds a table of pixel runs it has already seen and writes the table position instead of the run. Codes begin one bit longer than the color depth and widen as the table grows, up to twelve bits, which caps the table at 4,096 codes. When it is full the encoder writes a clear code and starts learning again. The compression is lossless, and it is blind to everything outside one frame.
Time is coarse. Each frame carries a delay counted in hundredths of a second, so 30 frames a second, which needs 3.33 hundredths, cannot be written. An encoder has to alternate delays of 3 and 4 to keep the total honest. Looping is not in the 1990 document at all. It lives in an application extension labeled NETSCAPE2.0 that carries a repeat count, where zero means forever. Transparency is a single palette index declared invisible, so an edge is either there or not.
Take the plain bounce: a 60 pixel box on a 200 by 200 stage, rising 30 pixels and returning in one second with ease. At a quarter of a second the box is halfway through its first interval in time, but the curve puts it 0.80 of the way in distance, about 24 pixels up, not 15. Sampled at 15 frames a second it becomes 15 frames with delays of 7, 6, 7, 7, 6 hundredths and so on, summing to exactly 100. Those frames hold 600,000 pixels. The finished file was 8,858 bytes in the test browser.
That ratio flatters the format, because a flat box on a flat background is the best case for run-based compression. Double the stage to 400 by 400 and triple the frames, and a pulsing box came to 79,745 bytes. Add a photograph, a gradient or dithering and the runs vanish. Nothing in the format describes a frame as the previous one shifted over.
Choosing a size, a rate, and sometimes another format
Frame count multiplies everything. Halving the frame rate roughly halves the file, and since a delay cannot be finer than a hundredth of a second, very high rates buy little. Size multiplies faster: twice the width and height is four times the pixels. A palette of 32 or 64 colors shortens the codes for flat artwork at no visible cost.
When the animation is long, large or photographic, a video format is the better container. One published comparison took a 3.7 MB GIF to a 551 KB MP4 and a 341 KB WebM, from 3.7 MB to 341 KB with nothing but re-encoding. A video element set to autoplay, loop, stay muted and play inline behaves like a GIF. Browsers can record a canvas into WebM, an open and royalty-free format, through MediaRecorder, though the capture runs in real time and a tool has to check that the browser can do it.
What a reconstruction cannot do should be said plainly. It draws only the properties it has been taught, so a rule that leans on filters, shadows, clip paths or three-dimensional transforms cannot be exported this way. It draws one element, not a page. Text is laid out again on the canvas and may sit a pixel away from the browser’s. And where browsers disagree with the written rules, as they do in small corners of matrix blending, a careful tool reports the difference instead of hiding it.
The GIF survives because it asks nothing of whoever receives it. For a short loop of flat color it is small enough. Knowing how it stores motion is mostly knowing when to stop asking it for more.
Sources
Every source consulted for this guide. Links checked October 4, 2026.
- GRAPHICS INTERCHANGE FORMAT(sm) Version 89a CompuServe Incorporated, hosted by the World Wide Web Consortium
- CSS Animations Level 1 World Wide Web Consortium
- CSS Easing Functions Level 1 World Wide Web Consortium
- CSS Transforms Module Level 1 World Wide Web Consortium
- CSS Color Module Level 4 World Wide Web Consortium
- Web Animations World Wide Web Consortium
- @keyframes CSS at-rule MDN Web Docs
- cubic-bezier() CSS function MDN Web Docs
- Element: animate() method MDN Web Docs
- CanvasRenderingContext2D: drawImage() method MDN Web Docs
- Screen Capture API MDN Web Docs
- MediaRecorder MDN Web Docs
- HTMLCanvasElement: captureStream() method MDN Web Docs
- Image file type and format guide MDN Web Docs
- 3MF Project: What's In A GIF - Animation and Transparency Matthew Flickinger
- 3MF Project: What's In A GIF - LZW Image Data Matthew Flickinger
- Median cut Wikipedia
- Ordered dithering Wikipedia
- Replace animated GIFs with video for faster page loads web.dev
- The WebM Project | About WebM The WebM Project