Diagrams
Animated SVG in GitHub comments: what survives, what gets stripped, and why SMIL
An animated SVG plays inside a GitHub comment if it arrives as an image and moves declaratively. What the sanitizer removes, what the camo proxy does, and why the URL must change with the picture.
It does work, and the reason it works is narrower than most people assume. An SVG referenced as an image in a GitHub comment plays SMIL animation, because the browser puts it in a mode where declarative animation is the one thing still switched on. SMIL has been available in Chrome, Edge, Firefox and Safari since January 2020, so this is not a trick with a browser caveat attached. What does not work is anything that needs to run, fetch or react. Below is the full list of what survives the trip into a comment, and what the constraints did to the way PR Lens draws.
What GitHub removes from a comment body
GitHub converts your Markdown to HTML and then sanitizes that HTML against an allowlist. The library the pipeline is built on, html-pipeline, publishes its allowlist in the open, and three details of it decide everything else.
svg is not on the list. Neither is script, style, iframe, object or foreignObject. So pasting an inline <svg> block into a comment gets you nothing; the element is removed along with everything in it.
img is on the list, with exactly four attributes allowed: src, longdesc, loading and alt. No style, no class, no event handlers. Whatever you were planning to do with a CSS hook on the image, you cannot.
picture and source are both on the list, and source keeps its srcset. That is the pair GitHub's own theme-switching images rely on, and it is the only way to get two versions of one picture into a comment.
So there is exactly one door: host the SVG somewhere, reference it as an image, and let the browser fetch it.
What happens to the file on its way in
The reference does not go straight to your server. GitHub proxies externally hosted images through camo, an open-source proxy, and rewrites the src on every img tag to point at it. The GitHub docs describe camo's job as generating an anonymous URL for each file, hiding the reader's browser details from the host serving the image.
Camo checks the content type against an allowlist before it will serve anything. image/svg+xml is on that list, which is the fact this entire post depends on. It also has a size ceiling: the open-source default is 5 MiB, set by CAMO_LENGTH_LIMIT, and anything larger comes back as a 404 saying the content length was exceeded. GitHub configures its own deployment and does not publish those numbers, so treat 5 MiB as the shape of the limit rather than the exact one.
The file itself is not touched. Camo fetches the bytes and hands them on. Nothing sanitizes the inside of your SVG, which surprises people who assume GitHub strips the JavaScript out of it. GitHub does not need to.
Why the JavaScript in your SVG never runs
An SVG referenced by an HTML img element is processed in what the W3C SVG Integration specification calls secure animated mode. The spec's own table for that mode reads: script execution, no. External references, no. Interactivity, no. Declarative animation, yes.
An SVG in a GitHub comment is an image, and an image is not allowed to run anything.
That is a browser rule rather than a GitHub rule, which is the useful part. It holds in a README, in a blog post, in a documentation site, anywhere the same file is used as an image. It also means the security question answers itself: a diagram cannot exfiltrate anything, because it cannot fetch anything, and it cannot react to a click, because it cannot receive one.
The mode has a sibling worth knowing about. Secure static mode is identical except that declarative animation is off too, and that is the mode an SVG lands in when the surrounding document does not support animation. Nothing in a comment breaks if you hit it. The picture simply stops moving.
Why SMIL, and what SMIL costs
Declarative animation in that spec covers both SMIL animation elements and CSS animations, and both survive. So there were two options open to us, and I want to be straight about that, because SMIL gets described as the only one more often than it should be.
The reason PR Lens uses SMIL is geometry. The motion in a diagram is a dot travelling along an edge, and the edge is already a path string the renderer computed and wrote into the file. animateMotion takes that same string as its path attribute, so the animation reuses the geometry instead of restating it as a set of keyframe percentages that would need recomputing every time the layout moved. One number describes each dot: how far behind the drawing's clock it runs.
<circle r="2.6" fill="#3fb950">
<animateMotion dur="1.6s" begin="-0.4s" repeatCount="indefinite" path="…" />
</circle>The negative begin is the only subtle part. A line running behind the shared clock has to start its motion partway through its own cycle, and a positive delay would describe the same steady state while lying for the first seconds after load: an animation has no effect before it begins, so a dot waiting its turn would sit parked at the canvas origin, in the corner, in full view.
The cost of SMIL is that it is old and unfashionable, and every time it comes up someone asks whether it is deprecated. MDN currently lists the animation elements as widely available across every major browser, with no deprecation flag on the entry, so the answer today is no. It is still a dependency on a standard nobody is excited about. I would rather carry that than carry a diagram that stops moving inside an img.
The other cost is that no external references means no external font file. The renderer uses a system font stack and measures every string against an embedded table of advance widths rather than asking a font engine, so a CI runner with no fonts installed lays a diagram out identically to a laptop with all of them. That started as a determinism requirement and turned out to be the same requirement as the sandbox.
Why the URL has to change when the picture does
This is the part that cost us the most time, and the folklore around it is wrong.
The story people tell is that GitHub caches proxied images forever. GitHub's documentation does not say that. What camo actually does is pass your origin's Cache-Control header through untouched, and substitute one year only when your origin sends none:
'cache-control': srcResp.headers['cache-control'] || 'public, max-age=31536000'GitHub's documented remedies for an image that will not update follow from that. Return Cache-Control: no-cache from your own server, and if that does not work, send a PURGE request to the camo URL, which the docs tell you to use very sparingly because it forces every GitHub user to re-request the image.
Neither of those is a good fit for a bot that redraws a diagram on every push. no-cache throws away the caching you want, and purging on every push is antisocial at exactly the scale where it would matter. So PR Lens goes the other way and serves every render with public, max-age=31536000, immutable on purpose, then guarantees that promise by never reusing a URL.
Each rendered SVG is stored under a key built from a SHA-256 hash of its own bytes, truncated to 32 hex characters, at renders/<owner>/<repo>/<hash>.svg. Change one node label and the bytes change, the hash changes, and the comment points at a URL nobody has ever fetched. Nothing is invalidated, because nothing is overwritten. That key format has one owner function, and every surface that builds such an address goes through it, because two places formatting the same key is how a bot ends up serving a picture it has already replaced.
This is also why the renderer has to be deterministic down to the byte. Content addressing is worthless if the same document renders differently on two machines: every push would mint a new URL for an identical picture, and the cache would never hit. Rounding every coordinate before writing it, comparing strings by code unit rather than by locale, measuring text from a table: those exist so that hashing the output means something. The wider argument for keeping the drawing step deterministic is in why the model should describe the graph and never draw it.
Light and dark are two files
An image cannot see the theme of the page it lands in. There is no media query available to it, because it is not part of that document.
So the renderer emits pairs and the comment pairs them up:
<picture>
<source media="(prefers-color-scheme: dark)" srcset="…-dark.svg">
<img src="…-light.svg" alt="…">
</picture>GitHub has supported this in Markdown since August 2022, and it works precisely because picture and source are on the sanitizer's allowlist. Two files per view per theme doubles the render count, which is why the schema caps how many views one document may declare: the cap is derived from the asset budget divided by the number of themes, rather than being a second number that has to be kept in step by hand.
What this rules out
Worth being clear about the ceiling. No hover states, no tooltips, no click-to-expand, no zoom, no filtering inside the comment. Anything interactive has to happen somewhere else, which for us is the canvas at prlens.dev that every comment links to, where the same document is drawn in a real page that is allowed to have event handlers.
Anything text-based has the opposite trade. A Mermaid block in a comment is rendered by GitHub itself rather than proxied as an image, so it gets a live DOM and whatever the Mermaid build GitHub happens to run can do. It also gets whatever layout the engine chooses, and no way to find out which build that is. That trade is the subject of why Mermaid is the wrong tool for code review, and the wider set of options is ranked in how to visualise a pull request as a diagram.
And a comment that redraws itself on every push has a second problem, which is staying one comment. That one is a Redis lease and a hidden marker, and it has its own post: how PR Lens keeps exactly one comment per pull request.
Questions people ask
Can you put an animated SVG in a GitHub comment?
- Yes. Reference it with a Markdown image or an img tag pointing at a hosted SVG, and animate it declaratively with SMIL or CSS. GitHub fetches the file through its camo proxy and the browser renders it as an image, which plays declarative animation. Inline svg markup in the comment body is removed by the sanitizer, so the file has to be referenced rather than pasted.
Does JavaScript work inside an SVG on GitHub?
- No. An SVG referenced through an img element is processed in what the SVG Integration spec calls secure animated mode, where scripting, external references and interactivity are all disabled and only declarative animation runs. That is a browser rule, not a GitHub one, so it holds anywhere the SVG is used as an image.
Why does my image on GitHub not update after I change it?
- GitHub serves images through the camo proxy, which passes your server's Cache-Control header through and falls back to one year when your server sends none. GitHub's documented fixes are to return Cache-Control: no-cache from your origin, or as a last resort to send a PURGE request to the camo URL. Serving each version at its own URL avoids the problem entirely.
How do I make an image on GitHub match the reader's light or dark theme?
- Ship two files and pair them with a picture element and a prefers-color-scheme media query on the source. GitHub has supported this in Markdown since 2022. An image cannot see the theme of the page it lands in, so one adaptive file will not work.
Sources
Keep reading
Turn it on
Install the App and every pull request in the repositories you pick gets drawn. Or hand the prompt to the agent you already have open.