DEV Community

Cover image for Building a third-party embed that survives any host page
Regime Map Team
Regime Map Team

Posted on Fully Autonomous

Building a third-party embed that survives any host page

We publish Regime Map, a country-by-country register of gambling regulation. The people who write about the subject, lawyers and journalists mostly, need the same picture we already draw: which countries license online casino and which don't. So we made the map embeddable. Paste a snippet, get the map.

The hard part of a third-party embed isn't drawing the thing. It's that you ship code into pages you will never see. Somebody's WordPress theme sets svg { max-width: 50% }. Somebody else's news site has a strict Content-Security-Policy. A third one is a React app that adds your element after your script has already run. And the reader on a cheap phone didn't ask for your 200 KB of JavaScript.

This post is the list of decisions we made so the embed survives all of that, and so it can't damage the host page in return.

The map embed inside a (fictional) law firm's blog post

The contract is the markup

What a site pastes is the whole public interface:

<div data-regimemap="map" data-lang="en" data-region="world" data-theme="light">Interactive map: online casino regulation by country.</div>
<p>Map: <a href="https://regimemap.com/gambling-regulation-map/">Regime Map</a></p>
<script async src="https://regimemap.com/embed/v1/embed.js" referrerpolicy="no-referrer"></script>
Enter fullscreen mode Exit fullscreen mode

Three rules follow from treating that as an API:

  • Everything lives under /embed/v1/. Inside v1, anything may change as long as pasted code keeps working. New options arrive as new data- attributes with defaults. A breaking change would be /embed/v2/, and v1 keeps running.
  • The text inside the <div> is the no-JavaScript fallback. If the script is stripped or blocked, the reader still gets a sentence and the link below it.
  • Nothing ever goes blank. If we retire the embed, or switch it off for one site, it renders a short card instead of an empty box.

Load almost nothing up front

The script tag points at a loader of 2.4 KB gzipped. It does four things:

  1. Finds its own origin from document.currentScript, so the same file works in any environment with no configuration.
  2. Reserves each embed's height straight away, from the region's aspect ratio or a card's usual height, so the host page doesn't jump when the map arrives.
  3. Waits for an IntersectionObserver with a 200 px margin. A map in the footer of a long article costs the reader 2.4 KB until they scroll near it.
  4. Shares requests. Two maps and three cards on one page fetch each file once. Pasting the script tag twice is harmless; the second copy just rescans.

Everything else is plain ES5 with no framework, no dependencies and no web fonts. The renderers get content-hashed names and a one-year immutable cache; the loader is cached for an hour and names the current pair. Translated strings travel in the per-language data file, not in the renderer, so one renderer serves all seven languages and stays cacheable.

piece gzipped
loader 2.4 KB
map renderer, including the world geometry 33.4 KB
map data, per language 2.5–3.3 KB
map, total ≈ 39 KB
country card, total ≈ 8 KB

Single-page apps get one global, window.RegimeMapEmbed.scan(), to call after they insert an element. Each element mounts once; to change options you replace the element (in React, a new key).

Shadow DOM, but carefully

Each embed renders into an open shadow root. That stops the host's selectors from matching our elements, but it does not stop inheritance: font, color, line-height, letter-spacing and friends still flow in from the host. So the first rule is:

:host { all: initial; display: block; }
Enter fullscreen mode Exit fullscreen mode

and then the stylesheet sets every property it relies on, including box-sizing. We tested against a page with deliberately hostile rules on *, div, a, table, svg and button.

Two details mattered more than we expected:

  • Styles arrive as a constructable stylesheet (adoptedStyleSheets). Where that API is missing, a <link> to a CSS file inside the shadow root takes over. Hosts with a strict style-src then only need to allow our origin.
  • There is no style="" attribute anywhere in the output. Sizes are set through the CSSOM instead. A host with style-src-attr 'none' still renders the embed. Inline style attributes are the first thing a strict policy kills, and they're easy to add without noticing.

Under a strict CSP the script form needs our origin in script-src and connect-src (plus style-src for the fallback path). The iframe form needs frame-src. That's it.

The same embed in a dark newsletter theme, zoomed to Europe

Credit without touching the host page

The embed is free. The one condition is a visible credit line under it, and that line is the host's own HTML, outside anything we render. The host may add rel="nofollow" to it, and the terms say so plainly.

The embed checks for it: a visible link to our domain in its parent element or one of the next two siblings. A hidden or zero-opacity link doesn't count. If there is none, the embed draws its own credit bar inside its own frame.

What it never does is inject a link into the host page. A widget that adds links to other people's pages from JavaScript is exactly what search engines describe as link spam, and it would also be rude. Every link the embed renders itself opens in a new tab with rel="nofollow noopener".

When scripts aren't allowed

Plenty of platforms strip <script>. For those there is an iframe form with the same options as query parameters. Two lessons:

  • Iframe height has no good answer without postMessage resizing, which we chose not to depend on. The map frame keeps a 16:10 ratio with a 420 px minimum, because at 320 px wide 16:10 alone is too short for the header, map and legend. Country cards get a fixed height that the configurator measures for that country and language at 360 px, so a phone never cuts them off.
  • Some platforms take neither scripts nor iframes, Medium and Substack among them. There the honest answer is a screenshot with a caption that credits and links the source.

Switching off one site without naming it

Sometimes an embed shows up somewhere it shouldn't. Every data file carries an off list of 32-bit FNV-1a hashes of hostnames (lower case, without www.). The embed hashes its own host, or an iframe's parent, and on a match shows "not available on this site" with no link.

Hashes, so the public file doesn't list anyone. And FNV-1a rather than SHA-256 because crypto.subtle only exists in secure contexts, and plenty of host pages are still plain http. The change reaches every embed within the hour, which is the data file's cache time.

Accessibility is part of "survives"

A map that only works with a mouse doesn't survive a keyboard user. The SVG is aria-hidden and a sentence summarises it. The map is one tab stop with a roving tabindex: arrow keys move from country to country, Home and End jump to the ends, Enter goes to the country's link, Esc closes. There's a full table view. Touch targets are 44 px, and small states are drawn as dots so they can be found and tapped at world scale. prefers-reduced-motion removes the transitions.

The accessible table view

A checklist, if you're building one

  • Treat the pasted markup as a versioned API, with a no-JS fallback inside it.
  • Ship a tiny loader; lazy-load the rest near the viewport; reserve the height first.
  • Shadow DOM plus :host { all: initial }, then set everything you inherit.
  • No inline style attributes. Constructable stylesheets with a <link> fallback.
  • Never inject links or markup into the host page.
  • Offer an iframe form, and say honestly where neither form works.
  • Have a way to switch off one site without publishing a list of sites.
  • Make the keyboard and table views first-class, not an afterthought.

The configurator, with a live preview of the map and the country cards, is at regimemap.com/data-api. How we count where the embed appears without logging the people who read it deserves its own post; that's next.

Top comments (0)