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 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> 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 newdata-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:
- Finds its own origin from
document.currentScript, so the same file works in any environment with no configuration. - 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.
- Waits for an
IntersectionObserverwith a 200 px margin. A map in the footer of a long article costs the reader 2.4 KB until they scroll near it. - 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; } 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 strictstyle-srcthen only need to allow our origin. - There is no
style=""attribute anywhere in the output. Sizes are set through the CSSOM instead. A host withstyle-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.
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
postMessageresizing, 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.
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
styleattributes. 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.


