ENGINEERING
Your og:image can move now
Every OG image on the internet is a still. Not because the platforms demand it — Discord and Slack will happily play an animated card in a link unfurl — but because the tools that generate OG images can't draw motion. satori converts a subset of CSS into a static SVG. Puppeteer wrappers call page.screenshot(), which is one frame by definition. The whole category is built on "render once, freeze it."
ogshot renders with real Chromium, and Chromium can obviously animate — every page you visit animates. The frames were always being drawn; we were just throwing away all but one of them. So we stopped: add animate=true to a render and instead of screenshotting the page, ogshot records it and hands back a looping GIF, MP4, or WebP. This post is about the three problems between that idea and shipping it: when to let the page move, how to get watchable pixels out of a video capture, and how to keep one URL honest for both still and moving consumers.
The line of code that was in the way
The renderer already had an opinion about motion:
That line exists because a screenshot API that captures animations mid-flight is broken in the worst way: nondeterministically. A fade-in caught at 80ms renders a ghost. A slide-up caught halfway is just a layout bug you can't reproduce. Forcing prefers-reduced-motion: reduce makes well-built pages settle to their finished state before the shutter clicks.
The insight that made animation shippable is that this line isn't an obstacle — it's a contract. If motion is gated behind the reduced-motion media query (which is how you should be writing it anyway, for accessibility), then the same HTML has two well-defined renderings: emulate reduce and you get the settled still; emulate no-preference and the motion plays. The renderer flips exactly one context option per mode, and neither mode can contaminate the other:
This is the pattern to copy into your own cards, whether you use our templates or POST your own HTML:
JS-driven motion follows the same contract by checking the media query. Our stats template counts numbers up from zero with a tiny requestAnimationFrame loop — under reduce it writes the final value immediately, so a plain PNG request is pixel-identical to what the template looked like before animation existed.
Recording instead of screenshotting
Playwright ships video recording at the context level, which turns the capture into surprisingly little code. The shape of it:
The one non-obvious line is the page.close(): Playwright doesn't flush the video file until the page that produced it is gone. Close, then ask for the path.
That yields a WebM, which is useless in an <img> tag. The transcode is where quality is won or lost.
GIFs are a palette problem
A naive ffmpeg -i in.webm out.gif produces the muddy, banded GIFs you remember from 2011, because GIF is limited to 256 colors per frame and ffmpeg's default picks them badly. The fix is a two-pass encode: first scan the whole clip and compute an optimal palette, then encode against it with ordered dithering:
For flat UI colors and text — which is what social cards are — this is the difference between "obviously a GIF" and "looks like the source." stats_mode=diff biases the palette toward pixels that change between frames, which is exactly where a count-up or a drawing chart lives.
The honest numbers, and why there are three formats
GIF's problem is weight. Here's the same 2.6-second render of our trend template — a line chart drawing itself while the headline metric counts up — in each output format:
| animate_format | size | where it wins |
|---|---|---|
gif | 1.27 MB | the only one that plays inside Discord and Slack link unfurls |
webp | 218 KB | loops like a GIF in any modern <img> — embeds on your own pages |
mp4 | 79 KB | 16x smaller than the GIF; attach it to an X/LinkedIn post as media — a product demo clip straight from HTML |
The platform reality, so you can pick deliberately: Discord and Slack animate GIF og:images in unfurls. Facebook, X, and iMessage fetch the image once and show a static frame — which, because of the reduced-motion contract, is still a clean settled card, not a mid-animation smear. And when you want motion on platforms that won't play it in an unfurl, you flip to mp4 and post the clip itself.
What we actually animated
Capability without designed content is a demo, not a product, so seven of the twelve templates ship motion that means something: numbers that count up (stats, launch, progress), a price that settles as the savings badge pops (product), an SVG chart that draws itself left to right (trend — the pathLength="1" + stroke-dashoffset trick), a waveform that pulses (podcast — its 1.3s alternate cycle fits the 2.6s capture exactly twice, so the loop is seamless), and a terminal where the command types itself out before the result lands (terminal).
Every animated preview on the templates page is the real output of animate=true, not a mockup — and the playground has an animate toggle with the format picker, so you can point it at your own HTML and get a loop back in a few seconds. It caches like any other render: the first request records and encodes, every identical request after that is a disk read.
One flag, because the renderer was already a browser
The reason this feature is a query param and not a rewrite is the same reason ogshot exists at all: rendering with the real engine means the platform's capabilities are your capabilities. A CSS-subset renderer would need to implement animation. We needed to stop suppressing it — carefully, behind a contract that keeps every static consumer of the same URL getting the same finished frame they always did.
curl "…/v1/template/stats?stat1=48,112&label1=new+users&animate=true" -o card.gif — that's the whole integration. The docs cover the knobs; the free tier needs no card.