JavaScript tag reference
Install the reusable OmniLab tag, configure its options, and handle the messages an embedded experience sends your page.
Install the reusable tag so one page can load different experiences and the frame behaves correctly without you writing the sizing and panel logic yourself. This page is for your developer.
The one value you need first
The snippet below needs the address your experiences are served from. Everything else is fixed.
That address must be a subdomain of the site you are embedding on — see the requirement below — so for a site at www.yourbrand.com it will be something like experience.yourbrand.com. Set it up before you build. See Custom domain.
The experience must share your site's domain
If you embed an experience served from a different domain than the page — including the default your-brand.topage.co inside www.yourbrand.com — the browser treats the frame as third-party and blocks or isolates its cookies. Sign-in does not persist, and the login round-trip loses its state partway through.
Serving the experience from a subdomain of your own site makes the frame same-site, and all of that works normally. This is a requirement for embedding, not a preference.
Install the tag
The script address depends on which OmniLab environment your experiences run on. Use production unless you are deliberately testing against another one, and keep the script host and the experience domain on the same environment — mixing them produces a frame that loads and then behaves as though the campaign does not exist.
| Environment | Script address |
|---|---|
| Production | https://cdn.21-digital.com/omplayer.js |
| UAT | https://cdn-uat.21-digital.com/omplayer.js |
| Development | https://cdn-dev.21-digital.com/omplayer.js |
<div id="omnilab-container"></div>
<script>
(function (b, o, n, u, s) {
var a, t;
a = b.createElement(u);
a.async = 1;
a.src = s;
t = b.getElementsByTagName(u)[0];
t.parentNode.insertBefore(a, t);
o[n] = o[n] || [];
})(document, window, "_om_async", "script", "https://cdn.21-digital.com/omplayer.js");
_om_async.push([
"init",
{
domain: "experience.example.com",
targetId: "omnilab-container",
queryParam: "id",
},
]);
</script>With this in place, the host page's own address decides which experience loads:
https://www.example.com/promo?id=summer-campaignhttps://www.example.com/promo?id=summer-campaign?c=<touchpoint-id>
The value of id is the path after the OmniLab domain. A nested ? survives as-is, because the tag reads everything up to the next &. If you need to pass more than one nested parameter, URL-encode the whole value so the & does not truncate it.
You do not need to add embedded=1 here — the tag appends it, along with the host page's address, when it builds the frame. That parameter is only yours to add when you write the iframe yourself.
What the tag needs from your page
Three things, none of them obvious from the snippet:
Leave the container empty. The tag owns everything inside #omnilab-container — the loading state, the frame, its styling, and its width and height. If your framework renders a placeholder or a spinner inside that element, the two will fight over the same nodes. Render the empty div and let the tag fill it.
Allow both hosts in your Content Security Policy. If your site sends a CSP — most do — the embed is blocked silently until you add:
script-src … https://cdn.21-digital.com
frame-src … https://experience.yourbrand.comAllowlist the script host for the environment you are actually using. If you test against UAT from the same site, that host needs allowlisting too, or the test fails in a way that looks like a broken campaign.
frame-src names the domain your experiences are served from — your own subdomain, per the requirement above. This is the most common reason an embed that worked in a test page fails on the real site: the test page had no policy and the real one does.
Re-initialise after a client-side navigation. The script guards against running twice, so in a single-page application that navigates away and back, the tag will not restart itself. Call init again once the script is loaded.
Options
Pass these in the same object as domain and targetId.
| Option | Default | What it does |
|---|---|---|
domain | — | The address your experiences are served from — your OmniLab subdomain or your own. Required. |
targetId | — | The id of the container the frame is inserted into. Required. |
queryParam | "id" | Which host-page query parameter carries the OmniLab path |
width | "100%" | Width applied to the frame and its container |
height | "100px" | Starting height, before the experience reports its own |
iframeId | "omnilab-iframe" | id given to the generated frame |
iframeClass | "" | Extra CSS classes on the frame |
iframeStyle | "border: none;" | Inline style on the frame |
allowFullscreen | off | Adds the fullscreen attributes |
sandboxAttributes | unset | Sets a sandbox attribute with the value you give |
stickyHeaderSelector | auto-detected | A selector for your sticky header, so panels open below it |
topOffset | 0 | Fixed pixels to keep clear at the top, instead of header detection |
scrollIntoViewOnNavigate | true | Scrolls the embed back into view when the visitor navigates inside it |
instantGameHeight | unset | Forces a fixed frame height for viewport-sized pages |
minFrameHeight | 320 | Floor applied to the measured viewport height |
maxFrameHeight | unset | Ceiling applied to the measured viewport height |
Check these against your own CSS
These options style the frame the tag generates. If you also style #omnilab-iframe from your own stylesheet, make sure the two agree — whichever loses the specificity contest silently wins the layout.
Sticky headers
The tag finds a standard page header on its own and keeps panels clear of it, including when the header scrolls away. Name yours explicitly when it is not picked up:
_om_async.push([
"init",
{
domain: "experience.example.com",
targetId: "omnilab-container",
stickyHeaderSelector: "#site-nav",
},
]);Use a stable selector — an id or a data attribute, not a hashed CSS-module class that changes between builds. Pass false to opt out of header detection and use topOffset instead.
Writing the parent page by hand
Only do this if something prevents you loading the tag. The tag exists precisely so you do not have to implement what follows, and it is the path OmniLab tests against.
Writing the parent yourself means you own the frame's height, the placement of any panel the experience opens, and keeping both correct as the experience changes. Everything below is what the tag would otherwise do for you.
You still need the CSP entries
The two policy directives above apply either way — the frame host has to be allowed in frame-src whether the tag creates the frame or you do.
Sizing
The experience announces how it wants to be sized on every page load, and again whenever it changes:
| Message from OmniLab | What it means |
|---|---|
{ type: "sizing", mode: "content" } | The page reports its own height. Size the frame from the resize messages that follow. |
{ type: "sizing", mode: "viewport" } | The page fills whatever frame it is given. Size the frame yourself, from your own viewport. |
Instant games are viewport-sized, because they measure window.innerHeight — which, inside a frame, is the frame's height. Sizing the frame from a height derived that way closes a loop with no outside input and the frame freezes. OmniLab suppresses resize entirely in viewport mode for that reason.
The mode belongs to the page, not the embed. Navigating from a game back to a landing page returns to content sizing and the page re-announces it, so hold the mode in a variable rather than deciding once at load.
<script>
window.addEventListener("message", (event) => {
if (event.origin !== "https://experience.example.com") return;
const data = event.data;
if (data?.type !== "resize" || data?.target !== "body") return;
if (typeof data?.data?.height !== "number" || data.data.height < 100) return;
const iframe = document.querySelector("#omnilab-embed");
if (iframe) iframe.style.height = data.data.height + "px";
});
</script>OmniLab coalesces these to one message per animation frame and drops repeats of the same height, so apply each one directly without debouncing. Ignore anything below 100 pixels (a page mid-layout) and anything arriving while a panel is open (the frame's viewport is then the panel box; applying it shrinks the embed once the panel closes).
Panels
| From OmniLab | When it fires |
|---|---|
open-modal | The first panel opens. Nested panels do not repeat it. |
close-modal (with source) | The last panel closes, so it always means fully closed. |
| To OmniLab | What it does |
|---|---|
ready-to-open-modal (with parentHeight, parentWidth, scrollTop, stickyOffset, position) | Acknowledges open-modal and hands the panel the slice of the frame the visitor can actually see. Send it on every open-modal, nested ones included. |
ready-to-close-modal (with source) | Closes the panel on top of the stack. Send it when your own UI dismisses it. |
If you never send ready-to-open-modal, panels still open — they fall back to a default full-height presentation instead of being placed in the visible slice.
Check event.origin against your OmniLab domain in anything you ship. The tag does this already; a hand-written listener that skips it will act on messages from any framed page.