Last update: 25.07.
- Penpot Installation via Docker
- Planned Features
- Chrome Extension (done)
- Video / Lottie inject (done)
- Gated View, so that the injects only executes on the view (done)
- CLI to connect to Penpot API (mostly done) + Wizard to build the Video / Lottie Injector Config (in progress)
I know a lot of text, but that is how I process ideas and iterate fast!
You add an animated GIF to a Penpot prototype, open it in Google Chrome, and… nothing. The GIF sits there like it’s waiting for written permission to move.
Then you wiggle the cursor or interact with the prototype and—surprise—it springs to life.
No, the GIF isn’t lazy. You’ve stumbled into a weird little corner where animated images, SVG patterns, and Chrome’s rendering engine don’t quite agree on who should repaint what.
TL;DR
Quick Answer: Penpot’s SVG pattern can freeze a GIF in Chrome; rendering it as a direct SVG image gets the animation moving again.
Penpot can place an animated GIF inside an SVG <pattern>. Chrome decodes the animation but may fail to repaint that pattern automatically. The workaround is to copy the GIF into a direct SVG <image> element, hide the frozen pattern, and keep the original shape available for clicks and prototype navigation.
What’s Actually Going Wrong?
Quick Answer: Chrome can decode the GIF correctly while continuing to display an old cached frame from the SVG pattern.
Penpot is built around open web standards, including SVG. That’s usually great: designs stay structured, scalable, and inspectable.
In this case, Penpot renders the GIF roughly like this:
|
1 2 3 4 5 6 7 |
<pattern id="image-pattern"> <image href="/path/to/animation.gif"></image> </pattern> <rect fill="url(#image-pattern)"></rect> |
The SVG <pattern> acts like wallpaper, while the <rect> decides where that wallpaper appears. According to MDN’s SVG pattern documentation, patterns are reusable graphics objects referenced through attributes such as fill.
Here’s the awkward bit: MDN notes that animated GIF behavior inside SVG <image> elements is undefined. In other words, browsers are allowed to get a little weird here—and Chrome absolutely accepts the invitation.
During testing, the GIF asset animated normally when opened directly. Inside Penpot’s SVG pattern, however, the visible frame only changed when something forced Chrome to repaint the area, such as cursor activity or a screenshot capture. The decoder was running; the pattern was simply showing an old frame.
The Fix: Skip the Pattern
This is running in Chrome, with a simple AGIF integrated.
Masks are working fine!
Quick Answer: Put the same GIF in a direct SVG image above the original shape, then hide the pattern without removing its click area.
Instead of trying to fake mouse movement, rotate random UI elements, or repeatedly poke Chrome with a stick, the cleaner approach is:
- Find every
<image>living inside an SVG<pattern>. - Find the shape using that pattern as its fill.
- Create a direct SVG
<image>above the shape. - Copy the original position, size, transform, mask, and aspect ratio.
- Hide the frozen pattern with
fill-opacity="0". - Set
pointer-events="none"on the new image so prototype navigation still works. - Also added some basic classes to help me style the viewer and possibly by viewer file-id in the future.
The core JavaScript looks like this:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
(() => { const SVG_NS = 'http://www.w3.org/2000/svg'; const FIX_ATTRIBUTE = 'data-chrome-gif-fix'; const gifChecks = new Map(); let activeFileId = null; let observer = null; let patchScheduled = false; function hasGifSignature(bytes) { if (bytes.length < 6) return false; const signature = String.fromCharCode(...bytes.subarray(0, 6)); return signature === 'GIF87a' || signature === 'GIF89a'; } async function responseIsGif(response) { if (!response.ok) return false; const reader = response.body?.getReader(); if (!reader) { const bytes = new Uint8Array(await response.arrayBuffer()); return hasGifSignature(bytes); } const header = new Uint8Array(6); let offset = 0; try { while (offset < header.length) { const { done, value } = await reader.read(); if (done) break; const remaining = header.length - offset; const chunk = value.subarray(0, remaining); header.set(chunk, offset); offset += chunk.length; } } finally { await reader.cancel().catch(() => {}); } return offset === header.length && hasGifSignature(header); } function isGifAsset(href) { let assetUrl; try { assetUrl = new URL(href, document.baseURI).href; } catch { return Promise.resolve(false); } if (!gifChecks.has(assetUrl)) { const check = fetch(assetUrl, { credentials: 'same-origin', cache: 'force-cache' }) .then(responseIsGif) .catch(() => false); gifChecks.set(assetUrl, check); } return gifChecks.get(assetUrl); } function applyViewerClasses() { const currentUrl = new URL(window.location.href); const hashQuery = currentUrl.hash.split('?')[1] || ''; const hashParams = new URLSearchParams(hashQuery); const fileId = currentUrl.searchParams.get('file-id') || hashParams.get('file-id'); document.body.classList.add('portalzine', 'page-loaded'); if (activeFileId && activeFileId !== fileId) { document.body.classList.remove( activeFileId, `file-id-${activeFileId}` ); } if (fileId) { document.body.classList.add(fileId, `file-id-${fileId}`); activeFileId = fileId; } } function patchPatternImages() { const viewer = document.getElementById('viewer-layout'); if (!viewer) return; viewer.querySelectorAll('pattern image').forEach(async sourceImage => { const href = sourceImage.getAttribute('href') || sourceImage.getAttribute('xlink:href'); if (!href || !(await isGifAsset(href))) return; if (!sourceImage.isConnected) return; const pattern = sourceImage.closest('pattern'); const svg = pattern?.closest('svg'); if (!pattern?.id || !svg) return; const fillReference = `url(#${pattern.id})`; [...svg.querySelectorAll('[fill]')] .filter(shape => shape.getAttribute('fill') === fillReference) .forEach(shape => { const nextElement = shape.nextElementSibling; if ( nextElement?.getAttribute(FIX_ATTRIBUTE) === pattern.id ) { return; } const animatedImage = document.createElementNS( SVG_NS, 'image' ); animatedImage.setAttribute( 'href', href ); animatedImage.setAttribute( 'preserveAspectRatio', sourceImage.getAttribute('preserveAspectRatio') || 'xMidYMid slice' ); [ 'x', 'y', 'width', 'height', 'transform', 'opacity', 'clip-path', 'mask' ].forEach(attribute => { const value = shape.getAttribute(attribute); if (value !== null) { animatedImage.setAttribute(attribute, value); } }); animatedImage.setAttribute(FIX_ATTRIBUTE, pattern.id); animatedImage.setAttribute('pointer-events', 'none'); shape.setAttribute('fill-opacity', '0'); shape.after(animatedImage); }); }); } function patchViewer() { applyViewerClasses(); patchPatternImages(); } function schedulePatch() { if (patchScheduled) return; patchScheduled = true; requestAnimationFrame(() => { patchScheduled = false; patchViewer(); }); } function start() { patchViewer(); observer = new MutationObserver(schedulePatch); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener('hashchange', schedulePatch); } function stop() { observer?.disconnect(); window.removeEventListener('hashchange', schedulePatch); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start, { once: true }); } else { start(); } window.addEventListener('pagehide', stop, { once: true }); })(); |
The MutationObserver is important because Penpot swaps prototype screens dynamically. Your GIF might not exist when the page first loads; it may appear only after someone presses a blue box, opens an overlay, or triggers another interaction.
Planned additions / updates
Now that I dived deep into fixing Animated GIFs, I decided to also add video and Lottie support by replacing an image placeholder on demand. More on that next week …. Once that is working I have a solid foundation to go completely wild in Penpot ;) Bye Bye Adobe XD (I will be completely free of Adobe soon), Bye Bye Figma …
Video / Lottie Support
New injection solution for Video and Lottie Playblack.
|
1 2 3 4 |
<script src="/assets/motion-config.js"></script> <script src="/assets/motion-shapes.js"></script> |
|
1 2 3 4 5 6 7 8 9 10 11 |
# Shape definitions <script> window.CUBICFUSION_MOTION_SHAPES = [ ['1af99ffc-0724-80f8-8008-60433984e673', { type: 'video', src: 'https://example.com/videos/demo.mp4', }], ]; </script> |
Video
Video injection is working and in testing. It has a css preloader, that shows before the video is available. A central config file to define the placeholder / video replacements. Pretty simple and elegant solution for now, until we get these natively ;)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ type: 'video', src: 'https://…/demo.mp4', // required — MP4/H.264 recommended webm: 'https://…/demo.webm', // optional extra <source>, tried first fit: 'cover', // 'cover' (default, crop) | 'contain' (letterbox) autoplay: true, // default true loop: true, // default true muted: true, // default true — required for autoplay startAt: 0, // seconds — start offset (a saved playback position wins) background: '#000000', // optional backdrop for the player area } |
Lottie
Lottie is also working. Tweaking the config a bit more, but this really rocks now! Lottie is loaded via the Cloudflare CDN, but you can define your own location in the config.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{ type: 'lottie', src: 'https://…/anim.json', // required* — plain Lottie JSON (no .lottie zip) animationData: {…}, // *or inline JSON object instead of src — no fetch, no CORS fit: 'cover', // 'cover' → xMidYMid slice | 'contain' → meet autoplay: true, // default true loop: true, // default true speed: 1, // playback rate, e.g. 0.5 or 2 renderer: 'svg', // 'svg' (default) | 'canvas' — faster for heavy animations reverse: false, // true → play backward (loops backward) yoyo: false, // true → ping-pong: forward, backward, forward… background: 'transparent', // default transparent — per-shape override } |
Chrome Extension
Bundled a small Chrome extension, if you like to try it out that way.
- Activate SVG fix for a specific website
- Allow to quickly tweak the viewer styles, if needed.
Extract and pull into extensions.
First, Let the Iframe Through the Door
Quick Answer: CORS controls scripted requests; iframe access is controlled by X-Frame-Options and CSP frame-ancestors.
Here’s a browser-security trap that catches plenty of people: adding Access-Control-Allow-Origin: * does not make a page embeddable. CORS deals with JavaScript reading cross-origin responses. Iframe permission is a different bouncer entirely.
If Penpot returns X-Frame-Options: SAMEORIGIN, an external portal can’t frame it. The modern fix is a narrowly scoped CSP frame-ancestors allowlist. Don’t use the obsolete ALLOW-FROM value.
For a Penpot Proxy Host in Nginx Proxy Manager, the important part looks like this:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# Replace this with the portal that embeds Penpot. set $portal_origin "https://portal.example.com"; # Remove the upstream iframe restriction. proxy_hide_header X-Frame-Options; # Allow only Penpot itself and the trusted portal to embed the viewer. add_header Content-Security-Policy "frame-ancestors 'self' https://portal.example.com" always; # Optional CORS for fetch/API requests from that same portal. add_header Access-Control-Allow-Origin $portal_origin always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; add_header Vary "Origin" always; |
Heads up: Nginx Proxy Manager documents a global X_FRAME_OPTIONS setting, and its default is deny. Changing that environment variable affects the whole NPM instance, so a per-host rule is safer. If X-Frame-Options still appears after proxy_hide_header, it’s being added by NPM rather than the Penpot upstream. Clear it for this host with the available headers-more module or remove the global header at its source—then confirm the final response instead of guessing.
Also check for an existing Content-Security-Policy. Multiple CSP headers are enforced together, so an upstream frame-ancestors 'none' will still win. Merge or replace that specific upstream policy carefully; don’t casually delete a full security policy just to make an iframe behave.
Don’t Forget Penpot’s enable-cors Flag
If you control the Penpot containers, you can also append enable-cors to the existing PENPOT_FLAGS value. The flag is spelled enable-cors, not enable-core.
|
1 2 3 4 5 6 7 8 9 10 |
services: penpot-frontend: environment: PENPOT_FLAGS: "enable-registration enable-login-with-password enable-cors" penpot-backend: environment: PENPOT_FLAGS: "enable-registration enable-login-with-password enable-cors" |
Those are example flag lists, so append enable-cors to your existing values instead of deleting flags your installation already needs. If your Compose file shares PENPOT_FLAGS through a YAML anchor, add it once to that shared value. Then recreate or restart the affected Penpot containers.
Important: Penpot documents enable-cors as a development-oriented setting that allows every domain. It can be handy while testing, but the explicit origin allowlist in Nginx Proxy Manager is the better production boundary. The flag still does not override X-Frame-Options or CSP frame-ancestors, so keep the iframe headers from the previous example.
Inject the Fix Through Nginx Proxy Manager
Quick Answer: Mount polyfill.js and main.css into NPM, expose exact asset locations, then inject both tags into Penpot’s HTML response.
You don’t need to rebuild Penpot every time the workaround changes. Save the complete GIF patch as polyfill.js, keep your viewer overrides in main.css, and mount both files into the Nginx Proxy Manager container.
A tidy host directory looks like this:
|
1 2 3 4 5 6 7 |
npm/ ├── docker-compose.yml └── portalzine-assets/ ├── polyfill.js └── main.css |
Add one read-only mount to the NPM service in docker-compose.yml:
|
1 2 3 4 5 6 7 8 9 |
services: app: image: jc21/nginx-proxy-manager:latest volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt - ./portalzine-assets:/data/portalzine:ro |
Restart NPM, open the Penpot Proxy Host, and paste the following into its Advanced configuration. The unique /portalzine/ prefix avoids replacing a real Penpot asset by accident.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# Ask the upstream for plain HTML so sub_filter can edit it. proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter_types text/html; sub_filter '<head>' '<link rel="stylesheet" href="/portalzine/main.css?v=1"><script defer src="/portalzine/polyfill.js?v=1"></script></head>'; location = /portalzine/polyfill.js { alias /data/portalzine/polyfill.js; default_type application/javascript; add_header Cache-Control "no-store"; } location = /portalzine/main.css { alias /data/portalzine/main.css; default_type text/css; add_header Cache-Control "no-store"; } |
The official NGINX substitution module replaces text in an upstream response. Here it swaps </head> for the stylesheet, the deferred polyfill, and the original closing tag. Disabling upstream compression matters because NGINX can’t find plain-text </head> inside a compressed response.
Whenever you update either file, bump ?v=1 to ?v=2. That tiny version switch is a delightfully boring cache buster—and boring is exactly what you want from deployment plumbing.
Here another article talking about taming the proxy : Nginx Proxy Manager Tweaks .
Use the Same Switch for Viewer Styles
Quick Answer: Serve a custom main.css beside the polyfill and scope every override under a dedicated body class.
The polyfill adds porlazine, page-loaded, the raw Penpot file-id, and a safer prefixed file-id-… class to <body>. That gives your stylesheet clean hooks without editing Penpot’s bundled CSS.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
body.porlazine #viewer-layout { background: #09090b; } body.porlazine .main_ui_viewer_header__viewer-header { background: rgb(24 24 27 / 88%); backdrop-filter: blur(12px); } body.file-id-c8993e96-88de-81dd-8008-4ea026a148d6 #viewer-layout { background: #000; } |
This is what I use for the cleanup of the viewer in main.css. Result is a clean Penpot template.
An error has occurred. Please try again later. |
This is much safer than replacing Penpot’s own compiled main.css. Your custom file loads afterward, stays easy to roll back, and can target one prototype without splashing changes across every viewer.
Why Navigation Still Works
Quick Answer: The original transparent shape keeps handling clicks while the animated replacement ignores pointer input.
The original Penpot shape stays exactly where it was. It becomes visually transparent, but it still owns the interaction area. The new animated image sits above it with pointer-events="none", so it can’t steal clicks.
Think of it as putting a working television in front of a broken one while leaving the original remote sensor exposed. Slightly ridiculous? Sure. Effective? Also yes.
Perfect For
- Self-hosted Penpot installations where you can inject JavaScript into the viewer
- Prototypes with GIFs that freeze specifically in Chromium-based browsers
- GIFs placed inside ordinary rectangular image fills
- Fixes that must preserve existing prototype clicks and navigation
Heads Up: The Catch
This isn’t a universal “make every GIF behave forever” button.
- The script must run inside the Penpot document. A cross-origin parent page can’t reach into the iframe because of the browser’s same-origin policy.
- Complex clipping paths, rounded shapes, filters, or unusual transforms may need extra attribute copying.
- The generic selector also patches static pattern images. They should look the same, but testing your whole prototype is still a smart move.
- Chrome can throttle animations in hidden tabs or hidden iframes.
- If you control the media pipeline, a looping, muted video is usually more predictable than an animated GIF.
The Bottom Line
The GIF itself isn’t broken, and Chrome isn’t refusing to decode it. The real problem is the SVG pattern failing to request fresh paints as the GIF advances.
By moving the image out of the pattern and rendering it directly, you stop fighting Chrome’s cache and let the browser do what it already knows how to do: play the animation. No fake cursor gymnastics required.
Sources
Penpot and SVG
- Penpot repository — GitHub
- Penpot configuration and
enable-corsflag - SVG <pattern> element — MDN Web Docs
- SVG <image> element — MDN Web Docs
Browser APIs and Security
- MutationObserver.observe() — MDN Web Docs
- Same-origin policy — MDN Web Docs
- CSP
frame-ancestors— MDN Web Docs X-Frame-Options— MDN Web Docs- Chromium issue 1123663: animated GIF in an SVG pattern
Proxy and Injection Setup
- Advanced configuration — Nginx Proxy Manager
- Nginx Proxy Manager repository — GitHub
- NGINX HTTP substitution module
- NGINX HTTP proxy module
FAQ
Does this work with every animated GIF?
It works best with GIFs used as Penpot SVG pattern fills. Complex masks, filters, or custom shapes may require a few extra copied attributes.
Does the script target only GIF files?
Yes. It verifies the GIF87a or GIF89a binary signature instead of trusting the URL extension. Assets that cannot be verified are skipped.
Will it break prototype navigation?
It shouldn’t. The replacement image uses pointer-events="none", while the original transparent shape keeps handling interactions.
Why not simulate mouse movement?
JavaScript-generated mouse events aren’t trusted browser input and don’t reliably trigger the same rendering path. They also treat the symptom rather than fixing the frozen pattern.
Why did moving the real cursor help?
Real cursor activity caused Chrome to repaint the viewer. That repaint exposed the GIF’s current decoded frame, making it look as though the cursor restarted the animation.
Why use a MutationObserver?
Penpot mounts and replaces prototype screens dynamically. The observer notices new SVG content and patches GIFs that appear after navigation.
Can a parent page patch a Penpot iframe?
Only when both documents share the same origin and the iframe configuration permits access. Cross-origin pages are blocked from reading or changing the iframe DOM.
Will it work in Firefox or Safari?
The workaround uses standard SVG and DOM APIs, but you should still test each browser. Those browsers may not need the workaround in the first place.
Does this keep working in a hidden iframe?
Not reliably. Browsers commonly reduce or pause rendering work for hidden documents to save power.
Should I use video instead of GIF?
If your workflow allows it, usually yes. A muted, looping WebM or MP4 offers better compression and more predictable playback controls.
Is CORS enough to allow Penpot inside an iframe?
No. Penpot’s enable-cors flag permits cross-origin requests and is intended for development, but iframe permission still comes from X-Frame-Options and CSP frame-ancestors. Configure the iframe headers separately.
How do I update polyfill.js or main.css without rebuilding Penpot?
Replace the mounted file and bump the version query from ?v=1 to ?v=2. Nginx Proxy Manager will serve the new asset without touching Penpot’s build.

