How to add an interactive body map to any website
One iframe, one event listener, and a decision about where each click should go. The embed itself is about fifteen lines. The rest of this page is the parts people get wrong.
Published September 16, 2026 4 min read
If you are not on Shopify, there is no app to install and that is fine. The widget is a standalone page you load in an iframe. It tells you what the visitor clicked through postMessage, and what happens next is entirely your code.
That split is deliberate. We do not know your URL structure, your category taxonomy, or whether a knee click should open a collection, filter a listing in place, or prefill a form. You do.
The minimum embed
Drop this anywhere you can paste HTML. A WooCommerce block, a Wix embed element, a Squarespace code block, a Magento CMS block, a React component, a plain PHP template.
<iframe
id="persoscan-widget"
src="https://widget.persoscan.com/?theme=light&lang=en"
title="Body map"
style="width: 100%; height: 620px; border: 0"
loading="lazy"
></iframe>
That already works. Rotate the model, click a zone, see the selection highlight. No license, no account. There is a small watermark until you add a license key, which is intentional: build and test the whole thing before money changes hands.
Listening for clicks
The widget posts a message every time someone selects a zone. Here is the listener, with the checks that matter:
const WIDGET_ORIGIN = new URL('https://widget.persoscan.com').origin;
window.addEventListener('message', (event) => {
if (event.origin !== WIDGET_ORIGIN) return;
const msg = event.data;
if (!msg || msg.source !== 'persoscan-widget' || msg.version !== 'v1') return;
if (msg.type === 'zone-selected') {
const { zoneId, zoneName } = msg.data;
// Your routing decision goes here.
window.location.href = destinationFor(zoneId);
}
});
All three guards earn their place.
event.origin !== WIDGET_ORIGIN is the security one. Without it, any page that can open a frame pointing at your site can fake a zone selection. Cheap to add, unpleasant to debug later.
The source check stops you from reacting to unrelated postMessage traffic. Analytics scripts, chat widgets and embedded video players all use the same channel, and they are noisier than you expect.
The version check is your upgrade escape hatch. If the envelope ever changes, your handler ignores the new shape instead of throwing inside a message listener, where errors are easy to miss.
event.origin !== '*' is not a check, and postMessage(data, '*') when sending commands leaks your
payload to whatever happens to be in the frame. Always name the origin explicitly.
Mapping zones to destinations
destinationFor is the only function you have to write. It is usually a plain object:
const ZONE_ROUTES = {
'lower-back': '/product-category/lower-back-supports/',
'upper-back': '/product-category/posture/',
neck: '/product-category/neck-supports/',
'left-shoulder': '/product-category/shoulder-supports/',
'right-shoulder': '/product-category/shoulder-supports/',
'left-leg': '/product-category/knee-supports/',
'right-leg': '/product-category/knee-supports/',
'left-foot': '/product-category/insoles/',
'right-foot': '/product-category/insoles/',
};
const destinationFor = (zoneId) => ZONE_ROUTES[zoneId] ?? '/shop/';
Two habits worth keeping. Point left and right at the same place unless you genuinely stock side-specific products. And always have a fallback, because a zone you forgot to map should send people to your catalogue rather than nowhere.
If your platform supports tag or attribute filtering, a query string often beats a dedicated category page. WooCommerce takes ?filter_body_area=knee, Wix does it in Velo, Magento takes layered navigation params. The zone key becomes the filter value and you skip building nine category pages.
Platform-specific versions of this, with the exact URL shapes, are in the integration guides for Shopify, WooCommerce, BigCommerce, Magento, PrestaShop, OpenCart, Wix, Squarespace, Cafe24 and EC-CUBE.
Filtering in place instead of navigating
Sending the visitor to another page is the simplest option, not always the best one. On a single long listing page, filtering without a reload feels faster and keeps the body map on screen, so the next click is one tap away.
if (msg.type === 'zone-selected') {
applyFilter(msg.data.zoneId); // your existing client-side filter
history.replaceState(null, '', `?body=${msg.data.zoneId}`);
}
The replaceState line is not decoration. Without it, a filtered view has no URL, so nobody can share it, bookmark it, or come back to it from search. That is a real cost for a small line of code.
Talking back to the widget
The channel runs both ways. You can push configuration and selection into the widget after load:
const iframe = document.getElementById('persoscan-widget');
const sendCommand = (type, data) =>
iframe.contentWindow.postMessage({ type, data }, WIDGET_ORIGIN);
sendCommand('update-config', { theme: 'dark', lang: 'de' });
sendCommand('select-zone', { zoneId: 'lower-back' });
sendCommand('clear-selection', {});
The obvious use is theme sync. If your site has a dark mode toggle, send update-config when it flips, otherwise you get a bright white body map sitting inside a dark page.
The less obvious use is deep linking. Read a zone out of the URL on page load and send select-zone, and a shared link arrives with the right area already highlighted.
Sizing, which is where launches break
The widget fills its container. That means the container is your problem, and a fixed pixel height is the usual mistake.
.persoscan-frame {
width: 100%;
height: min(78vh, 680px);
min-height: 420px;
}
min(78vh, 680px) keeps the model inside the viewport on phones while capping it on desktop, where an 800 pixel tall body map pushes everything else off screen. The min-height stops the model collapsing inside short flex containers.
Check it on a real phone. Not a resized desktop window, which does not reproduce the address bar or the viewport height changes when it hides.
A short launch checklist
event.originchecked against an explicit origin.- Every clickable zone has a destination, and a fallback exists for the ones you missed.
- Destinations have products in them.
loading="lazy"on the iframe if it is below the fold.- A
titleattribute on the iframe, for screen readers. - Theme sync wired up, if your site has a dark mode.
- Tested on a phone, with the address bar visible.
Fifteen lines of code, an afternoon of mapping decisions, and the thing that actually takes time is none of the above. It is agreeing internally on what a knee click should mean.