DEV Community

Touseef Ibn Khaleel
Touseef Ibn Khaleel

Posted on

Why We Ditched iFrames for Our Testimonial Widget (And Wrote 1,000 Lines of Vanilla JS Instead)

Every embeddable widget tutorial recommends an iFrame. Drop it in, it's isolated, it can't affect the host page, done. We followed that advice for the first version of Proofly's Wall of Love embed. It lasted about three weeks before we tore it out.

The problems weren't subtle. They showed up immediately, in the first round of user testing, on every host site that wasn't a plain white page.

What broke

Height synchronization. An iFrame doesn't know how tall its content is unless you tell it. Our Wall of Love renders a grid of testimonial cards — the number varies by plan, the cards vary in height based on how long the testimonial text is, and the layout reflows when the viewport changes. Keeping the iFrame height synchronized with its content required a postMessage listener inside the frame, a ResizeObserver watching the content, and a handler outside the frame that updated the height style in response. This worked until it didn't — race conditions on initial load, missed resize events on certain mobile browsers, and a 300ms flash of the wrong height on every page load.

Scroll isolation on mobile. Touch-scroll on iOS inside an iFrame is famously broken. Depending on how the iFrame is sized and positioned, the user either scrolls the frame content, scrolls the parent page, or both fight each other in a way that makes the section feel stuck. We tried -webkit-overflow-scrolling: touch and various overflow combinations. Nothing was clean.

Background color bleed. iFrames have a default white background. If the host page section has a dark or colored background, the iFrame box shows as a white rectangle until the content loads, then snaps to transparent (if you've set background: transparent on the frame body). The flash is ugly. More importantly, some host configurations — Webflow, certain WordPress themes — override the iFrame background regardless of what we set inside it.

Any one of these would have been acceptable to ship around. All three together meant the embed looked bad in ways we couldn't fully control.

What we built instead

The replacement is a script tag that, when loaded, injects the widget directly into the host page's DOM. No iFrame. The host page just needs a container div with a data-proofly-wall attribute:

<div data-proofly-wall="your-embed-slug"></div>
<script src="https://proofly.shipquick.app/embed.js" async></script>
Enter fullscreen mode Exit fullscreen mode

The embed script does the following on load:

(function() {
  const containers = document.querySelectorAll('[data-proofly-wall]');
  containers.forEach(container => {
    const slug = container.getAttribute('data-proofly-wall');
    if (!slug) return;
    initWall(container, slug);
  });
})();
Enter fullscreen mode Exit fullscreen mode

initWall fetches the wall data from our API using the embedSlug as the only credential, builds the DOM, injects the styles, and appends everything to the container. The slug is a random 16-character string — unguessable, not rotatable, and intentionally not tied to an auth session. If someone steals your embed slug, the worst outcome is they display your testimonials on their site, which is not a meaningful threat model.

Scoping CSS without Shadow DOM

The first thing you reach for when you need style isolation is Shadow DOM. We looked at it. The browser support is fine in 2026. The problem is that Shadow DOM makes it hard to let host page fonts inherit into the widget, which matters for design-conscious customers who want the testimonial cards to match their site's typography.

Instead, every CSS rule in the embed is prefixed with .proofly-root:

.proofly-root {
  font-family: inherit;
  box-sizing: border-box;
}

.proofly-root .wall-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1rem;
}

.proofly-root .testimonial-card {
  background: var(--proofly-card-bg, #ffffff);
  border-radius: var(--proofly-radius, 8px);
  padding: 1.25rem;
}
Enter fullscreen mode Exit fullscreen mode

CSS custom properties (--proofly-card-bg, --proofly-radius, etc.) let customers theme the widget from their host page without us needing to expose a configuration API. They set variables on the container and the widget inherits them.

The inject-once check is simple:

function injectStyles() {
  if (document.getElementById('proofly-embed-styles')) return;
  const style = document.createElement('style');
  style.id = 'proofly-embed-styles';
  style.textContent = EMBED_CSS; // minified at build time
  document.head.appendChild(style);
}
Enter fullscreen mode Exit fullscreen mode

The slug as the security model

The Wall of Love is intentionally public — the whole point is to display approved testimonials to anyone who visits the host page. There's no sensitive data in the embed response. The embedSlug isn't a secret key; it's more like a stable, opaque identifier.

async function fetchWallData(slug) {
  const res = await fetch(`https://proofly.shipquick/app/embed/${slug}`);
  if (!res.ok) return null;
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

The API endpoint for embed data is public and rate-limited but not authenticated. It returns only the approved testimonials for that wall — no user data, no account information. If a customer wants to take their wall private (hide it without deleting it), they can disable the embed from their dashboard, which stops the endpoint from returning data for that slug.

The ~1,000 lines

The file is currently 1,140 lines, including comments. The bulk of it is:

  • The card renderer (~200 lines): handles text cards, video thumbnail cards, audio cards, and star ratings
  • The grid layout manager (~150 lines): responsive column logic, masonry-style height balancing, lazy loading
  • The CSS injection (~180 lines): the full stylesheet, minified at build time
  • Video modal (~200 lines): click-to-expand video playback, keyboard close, scroll lock while open
  • Utilities (~100 lines): debounce, intersection observer for lazy loading, DOM helpers
  • Initialization and public API (~150 lines): initWall, the data- attribute scanner, the window.Proofly object It's vanilla JS — no bundler, no framework dependencies. This was a deliberate choice. The embed runs on every customer's host site, which might be a React app, a Webflow site, a plain HTML page, or a WordPress theme from 2019. Shipping a dependency graph into that environment is asking for conflicts. Vanilla JS with no imports is the safest thing to put in someone else's page.

What the iFrame would have gotten right

I want to be fair to the alternative. An iFrame would have handled:

  • Complete CSS isolation. The host page's resets and overrides can't touch iframe content.
  • JavaScript isolation. A bug in our widget code can't throw an error that breaks the host page. With our inline script, a runtime error in initWall could theoretically interrupt a host page script that runs after ours. The JavaScript isolation point is real and we take it seriously. The embed script has a try/catch around the entire initialization:
try {
  initWall(container, slug);
} catch (e) {
  console.warn('[Proofly] Widget failed to initialize:', e);
  // silent failure — the container just stays empty
}
Enter fullscreen mode Exit fullscreen mode

A failed widget initialization shows an empty div, not a broken page. That's the best we can do without the sandbox boundary.

Whether it was worth it

For our use case: yes. The height sync and mobile scroll problems were visible on every non-trivial host page. The background flash was the kind of thing that makes a product look unpolished in a demo. None of those go away with an iFrame without significant complexity.

The tradeoff is 1,140 lines of code we own and have to maintain, plus occasional CSS conflict reports from customers on unusual host configurations. We'd rather have the better default experience and handle edge cases than have the worse default experience and blame the iFrame.

If you're building an embeddable widget and your content is truly static — fixed height, no user interaction, no dark mode — an iFrame is still the answer. For anything dynamic, the direct DOM approach is worth the extra work.

Top comments (1)

Collapse
 
txlabs profile image
Touseef Ibn Khaleel

Try it out here: proofly.shipquick.app