CHECKING STATUS
I AM LISTENING TO
|

Why Your Penpot Animated GIF Freezes in Chrome — and the SVG Trick That Wakes It Up

LAST MODIFIED: 25. July 2026
23. July 2026
.SHARE

Table of Contents

Last update: 25.07.

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:

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:

  1. Find every <image> living inside an SVG <pattern>.
  2. Find the shape using that pattern as its fill.
  3. Create a direct SVG <image> above the shape.
  4. Copy the original position, size, transform, mask, and aspect ratio.
  5. Hide the frozen pattern with fill-opacity="0".
  6. Set pointer-events="none" on the new image so prototype navigation still works.
  7. 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:

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.

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 ;)

portalZINE.DE | portalzine penpot video embed placeholder

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.

portalZINE.DE | portalzine penpot video embed placeholder

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:

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.

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:

Add one read-only mount to the NPM service in docker-compose.yml:

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.

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.

This is what I use for the cleanup of the viewer in main.css. Result is a clean Penpot template.

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

Browser APIs and Security

Proxy and Injection Setup

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.

Let’s Talk!

Looking for a reliable partner to bring your project to the next level? Whether it’s development, design, security, or ongoing support—I’d love to chat and see how I can help.

Get in touch,
and let’s create something amazing together!

RELATED POSTS

WHAT? cubicFUSION’s Elementor Menu turns WordPress nav menus into design surfaces. Six widgets cover the range — a mega menu that drops full Elementor templates into any item, a hamburger menu with five panel layouts from a simple slide-out to a modal-style fullscreen overlay and an off-canvas mode that scales the page back into a […]

Design tools have quietly become one of the most locked-in parts of a modern team’s workflow. The files live in someone else’s cloud, access is billed per editor seat, and the moment you stop paying, the door to your own work can close behind you. For a small studio or an internal team, that model […]

Spend a lot of time with Vikunja (cubicFUSION Vikunja Importer) lately, one thing that it is currently missing is an easy way to style the interface. Especially the task view is just one long page without any clear section separation. LET’S TWEAK IT Here a quick starting point to tweak the style quickly. You can […]

Alexander

I am a full-stack developer. My expertise include:

  • Server, Network and Hosting Environments
  • Data Modeling / Import / Export
  • Business Logic
  • API Layer / Action layer / MVC
  • User Interfaces
  • User Experience
  • Understand what the customer and the business needs


I have a deep passion for programming, design, and server architecture—each of these fuels my creativity, and I wouldn’t feel complete without them.

With a broad range of interests, I’m always exploring new technologies and expanding my knowledge wherever needed. The tech world evolves rapidly, and I love staying ahead by embracing the latest innovations.

Beyond technology, I value peace and surround myself with like-minded individuals.

I firmly believe in the principle: Help others, and help will find its way back to you when you need it.