Back to field notes
Engineering 6 min read

Why we ditched iframes for our testimonial widget and wrote 1,000 lines of vanilla JS instead

iFrames seem like the obvious answer for cross-site embedding. We shipped one, watched it break on half our customers' sites, and replaced it with a self-contained vanilla JS script that renders directly into the host page DOM.

TL;DR

iFrames lose their mind the moment a host page has unusual CSS, a dark background, or a custom font. We replaced ours with a vanilla JS script that fetches the wall feed from /api/wall/:slug and renders the testimonials directly into the host page's DOM — scoped under a .proofly-root class with all:initial — so there's no height syncing, no scrollbar, and no background bleed.

When we first built the Wall of Love embed for Proofly, we did what every team does: we shipped an iframe. It took a day to build, it worked on our own test pages, and we shipped it.

Within two weeks we had four support tickets from customers whose walls looked broken.

  • One customer had a dark-background site. The iframe came in with a white background, a bright rectangle floating inside their dark layout.
  • One customer had a Webflow site with a max-width wrapper. The iframe was 100% wide but showed a horizontal scrollbar because the iframe's internal width was slightly larger than its container.
  • One had a font set at the root that didn't bleed through, so their embedded wall looked visually disconnected from the rest of the page — different weights, different line heights.
  • One had a Squarespace site that was injecting CSS globally and clashing with our iframe's internal styles in ways we couldn't reproduce locally.

None of these are edge cases. They're the normal surface area of the web. And iframes are uniquely terrible at handling them.

What iframes actually can't do#

An iframe creates a completely separate browsing context. That's its whole point — isolation. But for a testimonial wall you want the opposite: you want it to feel like part of the page, not a guest on it.

The specific things that break:

Height. iframes have a fixed height. You either set it to something arbitrary (say, 600px) and clip your content, or you implement postMessage height syncing, where the iframe posts its scroll height to the parent, the parent adjusts the iframe height, the iframe's content reflows, and the whole loop runs again. It works, but it's brittle across browsers, breaks on resize, and adds round-trip message passing latency you can see as a layout jump.

Background. An iframe that doesn't explicitly set background: transparent will paint white. Most don't. You can set it from the parent with CSS, but background: transparent on an iframe is non-standard and has cross-browser inconsistencies.

Typography. The parent page's fonts don't exist inside the iframe's browsing context. You'd need to import them again inside the iframe's document, which means another network request, a flash of unstyled text, and the ongoing maintenance problem of keeping the font stack in sync with whatever the customer is using.

Scrollbars. Any overflow inside the iframe that you didn't anticipate shows a scrollbar the parent page can't control.

What we built instead#

We replaced the iframe with a single <script> tag that fetches the wall feed from /api/wall/:slug and renders the testimonials directly into the host page's DOM — no iframe, no separate browsing context.

<div data-proofly-wall="YOUR_SLUG"></div>
<script src="https://useproofly.app/embed.js" async></script>

When the script loads, it:

  1. Resolves the API origin from its own src attribute (so the embed works from any deployment without hardcoding a URL)
  2. Finds all [data-proofly-wall] elements on the page
  3. Fetches GET /api/wall/:slug — a CORS-enabled JSON endpoint that returns wall config + the first 15 approved testimonials
  4. Injects a single <style> block scoped to .proofly-root with all styles self-contained
  5. Renders the testimonial cards as HTML strings into the host page DOM
  6. Handles "Load more" pagination with subsequent fetches to /api/wall/:slug/testimonials?offset=15

The rendered output looks like this in the DOM:

<div class="proofly-root proofly-dark">
  <div class="proofly-header">...</div>
  <div class="proofly-grid">
    <div class="proofly-card proofly-video-card">...</div>
    <div class="proofly-card proofly-text-card">...</div>
    ...
  </div>
  <div class="proofly-actions">
    <button class="proofly-load-more">Load more</button>
  </div>
</div>

Because it's in the host page's DOM, it inherits the parent page's font-family and color by default — no font mismatch, no import needed.

The style isolation problem#

Rendering into the host page's DOM introduces the opposite problem from iframes: the host page's CSS can leak in. A global * { box-sizing: content-box } rule, a CSS reset, a WordPress theme that sets img { max-width: 100% } — any of these can break our layout.

The solution is all: initial on the root element, combined with a specific CSS class namespace:

.proofly-root {
  all: initial;
  display: block;
  width: 100%;
  background: transparent;
  color: inherit;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  box-sizing: border-box;
}

.proofly-root *,
.proofly-root *::before,
.proofly-root *::after {
  box-sizing: border-box;
}

all: initial resets every inherited CSS property on the .proofly-root element to its initial value. Combined with color: inherit, it means our widget starts from a clean slate but still picks up the parent's text color (which is what you want — dark text on light pages, light text on dark pages).

Every other rule in the stylesheet is prefixed with .proofly-root so it can't escape:

.proofly-root .proofly-grid { ... }
.proofly-root .proofly-card { ... }

Theming without CSS variables leaking#

We use CSS custom properties for the color palette — --proofly-paper, --proofly-ink, --proofly-ember — but they're defined on .proofly-root, not on :root. That means they're scoped to our widget and can't be overridden by a host page's --paper variable (a name collision we actually hit in testing on a Notion-styled site).

The dark theme flips those variables in place:

.proofly-root.proofly-dark {
  --proofly-paper: #15110e;
  --proofly-ink: #f3ede1;
  --proofly-ember: #ff6b50;
}

The dark class is applied by the script when the wall config's theme field is set to "dark". The API returns this in the wall object alongside the testimonials, so the script has everything it needs from a single fetch.

The CORS and security model#

The /api/wall/:slug endpoint returns wide-open CORS headers:

access-control-allow-origin: *
access-control-allow-methods: GET, OPTIONS
access-control-max-age: 86400

The security model is the embed slug itself — a 10-character random string from Node's crypto.randomBytes, using 36 possible characters (lowercase letters + digits). That's 36^10 ≈ 3.7 trillion possible values. The slug is unguessable without prior knowledge, and it's not linked from any indexed page. This lets us skip auth on the public feed entirely, which is what makes the cross-origin embed possible without credentials.

The script resolves its own API origin from document.currentScript.src:

var ORIGIN = (function () {
  try {
    var current = document.currentScript;
    if (current && current.src) return new URL(current.src).origin;
    var scripts = document.getElementsByTagName("script");
    for (var i = scripts.length - 1; i >= 0; i--) {
      var src = scripts[i].src || "";
      if (src.indexOf("/embed.js") !== -1) return new URL(src).origin;
    }
  } catch (_) {}
  return "";
})();

This means the same embed.js file works from any deployment — staging, production, a customer's self-hosted instance — without modification.

What we learned#

The iframe support tickets stopped after we shipped the script embed. The four classes of breakage we saw — background color, height syncing, font mismatch, CSS leak-in — all disappeared because the embed is now part of the host page's document tree and inherits its context rather than fighting it.

The cost was about 1,000 lines of vanilla JavaScript. No framework, no dependencies, no build step. The script is a single file that gets served from our CDN with aggressive cache headers. It loads once per page and replays from cache on every subsequent visit.

If you're building a widget that other people will embed on their sites, reach for the script-tag pattern before reaching for the iframe. The extra work is front-loaded, and the maintenance headache of iframe support tickets is ongoing.

Frequently asked

Quick answers

Why not just use a Web Component instead of raw vanilla JS?+

We considered it. Shadow DOM would give us style isolation without the all:initial hack. But browser support for declarative Shadow DOM is still inconsistent enough that it would add a runtime polyfill, and the whole point of our embed is that it loads fast and has zero dependencies. We decided one global CSS class namespace (`.proofly-root`) was a better tradeoff than shipping a polyfill.

Does the script-tag embed work with Content Security Policy headers?+

Yes, as long as the host page whitelists the Proofly domain in their script-src and connect-src directives. The embed makes a single fetch to /api/wall/:slug and then renders entirely client-side — no inline scripts, no eval, no dynamic script injection beyond the initial tag. CSP-strict sites can run it without unsafe-inline.

What if the customer's site has a dark background and your light theme clashes?+

The embed ships both a light and dark theme. The theme is set on the wall config and sent in the API response — the script applies a .proofly-dark class to the root element, which flips all CSS custom properties (--proofly-paper, --proofly-ink, etc.) to dark-mode values. Customers can also pass data-proofly-theme='dark' on the div to override at the embed point.