DEV Community

Cover image for How I Built a Website Code Exporter That Handles Framer Animations, Webflow IX2, and Wix's 3MB Runtime
José Paulino
José Paulino

Posted on

How I Built a Website Code Exporter That Handles Framer Animations, Webflow IX2, and Wix's 3MB Runtime

I built NoCodeExport — a tool that takes any site built on Framer, Webflow, Wix, Squarespace, or WordPress and exports it to clean, self-hostable HTML/CSS/JS files.

This post covers the hard technical problems I ran into and how I solved them. If you've ever tried to scrape a modern SPA or reverse-engineer a no-code platform's output, you'll relate.


The Problem

No-code platforms are great for building sites fast. But they lock you into their hosting.

A simple portfolio on Squarespace costs $192/year. A Framer site with a custom domain runs $150/year. Multiply that by 5–10 client sites and you're paying serious money for what is essentially static content.

The "just right-click → Save As" approach doesn't work on modern sites:

  • Framer renders everything through React — the raw HTML is a hydration shell, not a usable page.
  • Wix ships a 3MB JavaScript runtime just to display text and images.
  • Webflow's IX2 animations die the moment you move the files off their CDN.

I wanted a tool that produces files you can drag into Netlify and have a working site in 20 seconds.


The Architecture

The pipeline is straightforward in concept, painful in practice:

URL → Headless Browser → Rendered DOM → Cheerio Processing → ZIP
Enter fullscreen mode Exit fullscreen mode

Tech stack:

Layer Tool Why
Web app Next.js 16 (App Router) SSR, API routes, ISR
Headless rendering Playwright Better cross-browser handling than Puppeteer
DOM manipulation Cheerio Fast server-side jQuery — no browser overhead
Packaging JSZip Streaming ZIP generation
Storage Vercel Blob Temporary ZIP hosting with TTL

The browser renders the page exactly as a visitor would see it. I capture the full DOM, then run it through a 12-step processing pipeline. Simple idea — approximately 8,000 lines of edge cases.


Problem 1: Platform Detection

Before processing a page, I need to know what built it. Each platform leaves fingerprints — meta generators, CDN URLs, DOM attributes, layout classes.

The naive approach would be a bunch of if statements. Instead, I built a scoring system with ~20 signals per platform:

// Simplified — real version checks ~20 signals per platform
function detectPlatform(html: string): PlatformInfo {
  const scores = { framer: 0, webflow: 0, wix: 0, squarespace: 0, wordpress: 0 };

  // Meta generators (high confidence)
  if (html.includes('Squarespace'))              scores.squarespace += 30;
  if (html.includes('WordPress'))                scores.wordpress   += 30;

  // CDN URLs (medium-high confidence)
  if (html.includes('framerusercontent.com'))     scores.framer   += 25;
  if (html.includes('uploads-ssl.webflow.com'))   scores.webflow  += 25;
  if (html.includes('static.wixstatic.com'))      scores.wix      += 25;

  // DOM structure (medium confidence)
  if (html.includes('data-framer-component-type')) scores.framer  += 20;
  if (html.includes('class="w-layout-grid"'))      scores.webflow += 20;

  // ... 15+ more signals per platform

  // Return highest score above confidence threshold of 30
}
Enter fullscreen mode Exit fullscreen mode

A confidence threshold of 30 prevents false positives — a random site with one Webflow CDN image won't trigger Webflow-specific processing.

WordPress sites running Elementor get bonus scoring from [data-element_type] attributes (+10) and [data-settings*="animation"] (+5), which influences which animation polyfills get injected later.


Problem 2: Animations That Die on Export

This was the hardest problem by far.

Framer uses framer-motion (their own React animation library). Webflow has IX2. Wix has Thunderbolt's animation system. When you strip these runtimes, every animation freezes.

My solution: structural detection + lightweight CSS/JS polyfills.

Instead of trying to preserve the original framework code (impossible for React-based platforms), I detect what kind of animation is happening and inject polyfills that replicate the behavior.

Scroll Reveal Animations

Framer, Webflow, Wix, Squarespace, and Elementor all have scroll-triggered reveals. They all work differently internally, but the visible result is identical: an element starts at opacity: 0 with a transform, then animates in when scrolled into view.

During the Cheerio processing phase, I tag elements for the polyfill based on structural patterns:

// Framer: elements with will-change + transform + opacity
// Wix: [id^="comp-"] elements with opacity: 0 or visibility: hidden
// Elementor: elements with data-settings JSON containing animation keys
// Webflow: .w-anim-in elements
// Generic: any element with opacity: 0 + transform (catches everything else)

element.attr('data-nce-scroll', 'true');
Enter fullscreen mode Exit fullscreen mode

Then a single unified scroll-reveal.js polyfill handles all platforms:

import { animate, inView } from 'https://cdn.jsdelivr.net/npm/motion@latest/+esm';

document.querySelectorAll('[data-nce-scroll]').forEach(el => {
  el.style.opacity = '0';
  el.style.transform = 'translateY(20px)';

  inView(el, () => {
    animate(el,
      { opacity: 1, transform: 'translateY(0)' },
      { duration: 0.6, easing: [0.25, 0.1, 0.25, 1] }
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

If the CDN is unreachable, an inline CSS-transition fallback kicks in automatically. The exported site never depends on a single external resource to function.

Key design decision: All polyfills use structural/algorithmic detection, not platform gates. I don't check if (platform === 'framer') before injecting the scroll reveal. The polyfill finds elements that look like they need revealing regardless of what built them. This means the system works on platforms I haven't explicitly coded for.

Ticker / Marquee Animations

Framer's ticker component and generic marquee patterns get detected with a pure structural heuristic:

overflow:hidden container
  → single flex child
    → 6+ repeating children
      = ticker
Enter fullscreen mode Exit fullscreen mode

The polyfill normalizes content to 3 repeating sets (using text-fingerprint deduplication to avoid infinite repetition) and injects CSS @keyframes for smooth continuous scrolling.

Respecting prefers-reduced-motion

Both scroll-reveal.js and ticker.js check:

window.matchMedia('(prefers-reduced-motion: reduce)').matches
Enter fullscreen mode Exit fullscreen mode

If the user prefers reduced motion:

  • Scroll reveals snap to visible instantly (no animation)
  • Tickers pause completely

This is a WCAG 2.1 SC 2.3.3 requirement that the original platforms handle in their runtimes. When you remove the runtime, you have to re-implement the accessibility behavior in your polyfills — otherwise the exported site is less accessible than the original.


Problem 3: Wix's Massive Runtime

Wix sites ship a multi-megabyte JavaScript runtime called Thunderbolt. The rendered HTML is littered with this:

<script type="application/json" id="wix-viewer-model">
  { /* ~500KB of viewer configuration */ }
</script>
<script>/* Sentry error tracking */</script>
<script>/* Fedops performance monitoring */</script>
<script>/* Platform analytics & telemetry */</script>
<!-- Dozens of preload/prefetch hints for wix/parastorage domains -->
Enter fullscreen mode Exit fullscreen mode

The ContentCleaner strips all of this. But the tricky part is Wix elements that start hidden (opacity: 0, visibility: hidden) and are revealed by JavaScript at runtime. Without the Wix runtime, they stay invisible forever.

The fix is the same data-nce-scroll tagging system from Problem 2:

// Find Wix components that start hidden
$('[id^="comp-"]').each((_, el) => {
  const style = $(el).attr('style') || '';
  if (style.includes('opacity: 0') || style.includes('visibility: hidden')) {
    $(el).attr('data-nce-scroll', 'true');
  }
});
Enter fullscreen mode Exit fullscreen mode

Result: exported Wix sites load 3–4x faster than the original because you're serving clean HTML instead of bootstrapping an entire application framework.


Problem 4: Webflow IX2 Re-initialization

Webflow's interaction engine (IX2) is actually well-architected compared to other platforms. Unlike Framer, Webflow ships webflow.js which contains all the animation logic as a self-contained module.

The problem: it only initializes once on page load. After export, the page loads in a different context and IX2 doesn't fire.

The fix is a small re-initialization script — simple in hindsight, but it took a while to figure out the correct invocation order:

// webflow-init.js — re-triggers the Webflow runtime
const Webflow = window.Webflow || [];

Webflow.push(() => {
  // Re-init IX2 animation engine
  const ix2 = window.Webflow.require('ix2');
  if (ix2) ix2.init();

  // Re-init UI components (order matters)
  ['slider', 'tabs', 'dropdown', 'navbar', 'lightbox'].forEach(mod => {
    try {
      const m = window.Webflow.require(mod);
      if (m?.redraw) m.redraw();
      else if (m?.init) m.init();
      else if (m?.ready) m.ready();
    } catch { /* module may not exist on this page */ }
  });

  // Trigger resize to force layout recalculation
  window.dispatchEvent(new Event('resize'));
});
Enter fullscreen mode Exit fullscreen mode

The redraw()init()ready() fallback chain is needed because different Webflow component versions expose different lifecycle methods.

This is the only platform-specific polyfill in the entire system. Everything else is cross-platform.


Problem 5: Framer's SSR Breakpoint Variants

This one was subtle and took weeks to diagnose.

Framer does server-side rendering with all responsive variants present in the HTML simultaneously. Desktop layout, tablet layout, and mobile layout are all in the DOM at once. They use JavaScript to show/hide the correct variant based on screen size.

Without their runtime, you see all variants at once. Wrong images, wrong spacing, overlapping text — visual chaos.

Before fix:

┌─────────────────────────────┐
│ Desktop Hero Image          │
│ Tablet Hero Image           │  ← All three visible simultaneously
│ Mobile Hero Image           │
│ Desktop Nav  Tablet Nav     │
│ Mobile Nav                  │
└─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

After fix: Only the correct variant shows for the current viewport.

The solution: extract breakpoint data from one of two locations (Framer changed their format), generate @media queries, and inject them:

/* Generated breakpoint CSS — injected as <style data-nce-breakpoints> */
@media (max-width: 809px) {
  .hidden-abc123 { display: none !important; }
}
@media (min-width: 810px) and (max-width: 1199px) {
  .hidden-def456 { display: none !important; }
}
@media (min-width: 1200px) {
  .hidden-ghi789 { display: none !important; }
}
Enter fullscreen mode Exit fullscreen mode

Framer stores breakpoints in two places depending on the version:

  1. <script id="__framer__breakpoints"> (older Framer)
  2. data-framer-hydrate-v2 attribute JSON with a breakpoints array (newer Framer)

The processor tries source 1 first, then falls back to source 2. Without this fix, every Framer export looks broken.


Problem 6: Videos That Don't Exist Yet

Framer renders videos via React. When Playwright captures the DOM, <video> elements often don't have their src attribute set yet — React hasn't finished hydrating that part of the tree.

If I capture immediately, I get <video></video> with no source. The video is gone.

Solution: a MutationObserver that waits for video sources to appear:

// Wait up to 3 seconds for video sources to materialize
await page.evaluate(() => new Promise(resolve => {
  const videos = document.querySelectorAll('video:not([src]):not(:has(source))');
  if (!videos.length) return resolve();

  let resolved = false;
  const observer = new MutationObserver(() => {
    const remaining = document.querySelectorAll('video:not([src]):not(:has(source))');
    if (!remaining.length && !resolved) {
      resolved = true;
      observer.disconnect();
      resolve();
    }
  });

  observer.observe(document.body, { subtree: true, attributes: true });
  setTimeout(() => {
    if (!resolved) { resolved = true; observer.disconnect(); resolve(); }
  }, 3000);
}));
Enter fullscreen mode Exit fullscreen mode

Another detail: <video> elements with src are always hotlinked (resolved to absolute URLs) regardless of the asset download policy. Background videos are typically 10–50MB — way too large for a ZIP file. The optimistic approach of downloading them would produce broken empty videos when the download times out.


Problem 7: Canvas Elements With No Fallback

Some Framer templates use WebGL canvas elements for liquid/shader backgrounds and decorative grain/noise overlays. These require JavaScript runtimes that don't survive export.

Shader canvases (data-paper-shaders="true"): I extract the mp4 URL from the page's scripts and replace the canvas with a <video> element.

Grain/noise canvases: Detected by either data-framer-name containing "grain" or "noise", or structurally — a <canvas> inside a pointer-events: none + position: absolute/fixed overlay container. These get replaced with an SVG feTurbulence filter:

/* CSS replacement for canvas grain overlay */
background: url("data:image/svg+xml,...feTurbulence...");
opacity: 0.15;
Enter fullscreen mode Exit fullscreen mode

Important: Never use hardcoded class hashes like .framer-1xjpnxp-container for detection. Framer regenerates these per template. Always use structural or attribute-based detection.


The Full Processing Pipeline

Every page goes through these steps in order:

Step What It Does Cross-Platform?
1. Platform cleanup Remove badges, tracking scripts, telemetry Per-platform removal, shared interface
2. Asset handling Hotlink (free) or download (Pro) all images, fonts, CSS, JS
3. Lazy loading Add loading="lazy", decoding="async", fetchpriority
4. Link rewriting Convert absolute URLs to relative paths
5. Form rewriting Strip builder-specific attributes, wire to chosen backend Per-platform cleanup, shared output
6. Lottie embedding Inline JSON data to avoid CORS on file://
7. Animation recovery Tag hidden elements, extract hover CSS, detect tickers ✅ (structural detection)
8. Nav scroll detection Find transparent fixed navs → dark bg on scroll
9. Polyfill injection Only inject what the page actually needs
10. <picture> wrapping WebP <source> hints for images (Pro)
11. SEO audit Score title, meta, H1, alt text, canonical, OG tags (Pro)
12. Minify & package Into a deployment-ready ZIP

Each step returns metrics (scripts removed, forms rewritten, images optimized) that feed into a speed gain estimate displayed to the user.


The Centering Transform Gotcha

One edge case that caused hours of debugging: elements positioned with transform: translate(-50%, -50%) (the classic absolute centering trick).

These elements often have opacity: 0 set for initial state — which makes them look like scroll-reveal candidates. But if you animate them with translateY(20px), they slide from the bottom-right corner to the center instead of fading in place.

The fix: skip elements with centering transforms from all animation paths. The check exists in four places:

// Skip centering transforms — animating these would break positioning
const style = $(el).attr('style') || '';
if (style.includes('translate(-50%')) {
  $(el).css('opacity', '1'); // Snap to visible, don't animate
  return;
}
Enter fullscreen mode Exit fullscreen mode

What I Learned

1. Don't fight the framework — replace the behavior

Trying to preserve Framer's React runtime is a losing battle. Detecting the intent (scroll reveal, ticker, accordion) and reimplementing with vanilla JS produces better, lighter results.

2. Structural detection beats platform detection

My best polyfills work on sites I've never tested against because they detect patterns, not brands. An overflow: hidden container with a flex child and 6+ repeating items is a ticker, regardless of whether Framer or Webflow built it.

3. Accessibility doesn't survive runtime removal

When the original platform's JavaScript runtime handles prefers-reduced-motion, aria-live, or keyboard navigation — and you strip that runtime — you've made the site less accessible. Every polyfill needs to re-implement the a11y behavior.

4. The long tail of edge cases is infinite

Lottie animations, WebGL canvas backgrounds, grain/noise overlays, counter animations, Framer hover states extracted via Playwright page.evaluate()... every week I discover a new pattern that needs handling.

5. Pre-clean detection matters

Some detection has to happen before the cleanup phase. Framer's <script data-framer-appear-animation> gets deleted by ContentCleaner (it removes scripts containing __framer). If you check for it after cleanup, it's gone. Store detection flags early, use them later.


Closing Thoughts

Building this forced me to deal with a long tail of messy edge cases — animation runtimes, hidden breakpoint variants, delayed video hydration, canvas fallbacks, and platform-specific cleanup that breaks the moment you move a site outside its original environment.

What looked simple at first — render a page, capture the DOM, package it into a ZIP — turned into a much deeper engineering problem around behavior preservation, accessibility, and cross-platform resilience.

I’d be interested to hear how others have approached similar problems.

What’s the weirdest web scraping, DOM reconstruction, or front-end runtime issue you’ve had to solve?

Top comments (0)