JavaScript tag reference
Install the JavaScript tag, set its options and handle the messages between your page and an embedded experience.
For your developer. With the tag, one template page can show any experience, picked by the page's own address.
The one value you need first
The snippet needs the host your experiences are served from. For a site at www.lindenhall.example, that's a subdomain such as play.lindenhall.example. It must be a subdomain of your site, or participants can't stay signed in. See Custom domain.
Install the tag
| Environment | Script address |
|---|---|
| Production | https://cdn.21-digital.com/omplayer.js |
| Staging (UAT) | https://cdn-uat.21-digital.com/omplayer.js |
Use Production. Load the Staging address only to test against a Staging experience.
Put the container where the experience should appear, with the snippet after it:
<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: "play.lindenhall.example",
targetId: "omnilab-container",
queryParam: "id",
},
]);
</script>Your page's own address then decides which experience loads. The value of id is the path after the experience host:
https://www.lindenhall.example/promo?id=lindenhall-autumn-workshopsopens the campaign's landing page.https://www.lindenhall.example/promo?id=lindenhall-autumn-workshops?c=a315opens one touchpoint.
The tag reads id up to the next & or #, so one nested ? works as written. To pass several nested parameters, URL-encode the whole value: id=lindenhall-autumn-workshops%3Fc%3Da315%26l%3Den.
Don't add embedded=1 yourself. The tag adds it when it builds the frame.
What the tag needs from your page
Leave the container empty. The tag adds its loading spinner and the frame after anything already in #omnilab-container. Render the element empty, and don't let your framework re-render its contents.
Don't give the frame a height in CSS. The tag sizes the frame with its height attribute, and any CSS height overrides that. Style the frame with the iframeStyle and iframeClass options instead.
Allow both hosts in your content security policy. If your site sends a policy, the embed is blocked until it allows the tag's host and the experience host:
Content-Security-Policy: script-src 'self' https://cdn.21-digital.com; frame-src https://play.lindenhall.exampleKeep your own sources in each directive. The snippet is an inline script, so give it your page's nonce, or move it into a file on your own domain. If you also test against Staging, allow the Staging script host and your Staging experience host too.
The tag also adds a small inline <style> for its loading spinner, with no nonce. A style-src without 'unsafe-inline' blocks it and logs an error. The experience still loads, but its space stays blank until it appears. Add 'unsafe-inline' to style-src if you want the spinner.
The policy is the most common reason an embed that worked on a test page fails on the real site. The test page had no policy; the real one does.
Re-initialise in a single-page app
In a single-page app, the script loads once and stays loaded. When your router shows the page again, with a new empty container, call init yourself. This function works whether or not the script has finished loading:
function showLindenhallExperience() {
var options = {
domain: "play.lindenhall.example",
targetId: "omnilab-container",
queryParam: "id",
};
if (window._om_async && typeof window._om_async.init === "function") {
window._om_async.init(options);
} else {
window._om_async = window._om_async || [];
window._om_async.push(["init", options]);
}
}Don't run the snippet again. Once the script has loaded, _om_async is the tag itself, with no push, so the snippet throws.
Call init only on an empty container. If the previous frame is still in the page, the tag adds a spinner that never clears. The tag also reads id from the address again, so the new route must carry it.
What to expect after a re-init: the tag still tells the new frame how much room a panel has each time a page loads inside it. It no longer does so when the window resizes or the phone rotates, and each resize logs an error in the console. A panel opened after a rotation can then be sized for the old screen, until the visitor moves to another page. A full page load puts it right.
Sign-in state
The tag tells the experience whether your own site has signed the visitor in. It reads a global variable, loginStatus, when it initialises, and adds login to the frame's address:
loginStatus | The tag adds | Each time your page loads the frame |
|---|---|---|
Not set, or anything but the string "True" | login=0 | The experience starts signed out, even for a participant who signed in on an earlier visit |
"True" | login=1 | The experience sends the visitor through sign-in before it shows anything |
The exception is the return from sign-in: the participant stays signed in then.
Set the variable before the snippet, and only when your own site has signed the visitor in:
<script>
window.loginStatus = "True";
</script>It must be the string "True". The boolean true counts as signed out.
Options
Pass these in the same object as domain and targetId.
| Option | Default | What it does |
|---|---|---|
domain | none | The host your experiences are served from, here play.lindenhall.example. Required. Without a scheme, the tag uses https. |
targetId | none | The id of the container the frame goes into. Required. |
queryParam | "id" | The parameter in your page's address that names the experience. Keep id if participants sign in: the return from sign-in always uses id. |
width | "100%" | Width of the container and the frame |
height | "100px" | Minimum height of the container. The frame's own height comes from the screen and the experience. |
iframeId | "omnilab-iframe" | id given to the frame |
iframeClass | "" | Extra CSS classes on the frame |
iframeStyle | "border: none;" | Inline style on the frame |
allowFullscreen | off | Set to true to add the fullscreen attributes |
sandboxAttributes | unset | Adds a sandbox attribute with the value you give |
stickyHeaderSelector | #main-header, then header | The element panels must stay below. false turns detection off. |
topOffset | 0 | Pixels to keep clear at the top when no header is found, or detection is off |
scrollIntoViewOnNavigate | true | Scrolls your page up to the embed when the visitor changes page inside it and the embed's top is above the screen. false leaves the scroll alone. |
Sticky headers
The tag keeps panels clear of a header fixed to the top of your page. It looks for the element with the id main-header, then for the first <header> element. It measures that element's bottom edge, so a header that scrolls away stops counting. It never keeps more than a third of the screen clear.
If your header is a different element, name it. Replace the push call in the snippet with this one:
_om_async.push([
"init",
{
domain: "play.lindenhall.example",
targetId: "omnilab-container",
stickyHeaderSelector: "#site-nav",
},
]);Use a stable selector, such as an id or a data attribute, not a hashed class that changes between builds. If the selector matches nothing, or you pass false, the tag keeps topOffset pixels clear instead.
Messages between the page and the experience
Your page and the experience talk with postMessage. The tag handles every message below. Read on if you write the parent page yourself, or need to debug one.
The experience runs this exchange only when its address has embedded=1. Without it, the experience sends resize alone, and panels open at the edge of the whole frame, often off screen.
Check the origin. The experience posts its messages to any origin. Act only on messages whose event.origin is exactly your experience host, here https://play.lindenhall.example. Post your replies to that same origin.
From the experience to your page
| Message | Shape | Sent when | Your page should |
|---|---|---|---|
resize | { type: "resize", target: "body", data: { height } } | The page's height changes: at most once per animation frame, never the same height twice in a row. Games and error pages never send it. | Set the frame's height to height pixels. Ignore values below 100. |
request-viewport | { type: "request-viewport" } | The experience is ready to receive viewport | Reply with viewport |
open-modal | { type: "open-modal" } | The first panel opens. Nested panels don't repeat it. | Nothing required |
scroll-to | { type: "scroll-to", top, height } | A panel has been placed, and again if its space changes while it's open. top and height are pixels in the frame's own page. | Scroll so that box sits in the middle of the visible area, below your header |
close-modal | { type: "close-modal", source } | The last panel closes, so it always means fully closed. source is "closeButton", "backdrop" or "escape". | Nothing required |
From your page to the experience
| Message | Shape | Send it | What it does |
|---|---|---|---|
viewport | { type: "viewport", availableHeight } | When the frame loads, in reply to request-viewport, and when your window resizes. Not on scroll. | Sets the tallest a panel may be: your visible screen height, minus your sticky header. It must be a number above 0. |
ready-to-close-modal | { type: "ready-to-close-modal", source } | Optional, when your own interface dismisses the panel | Closes the panel on top |
Without viewport, a panel can grow as tall as the whole frame.
Panels
When a visitor opens a panel, the experience places it near their last tap, no taller than availableHeight. It sends open-modal, then scroll-to with the panel's position, and your page scrolls to centre it. When the last panel closes, it sends close-modal. Nothing scrolls back on close.
Leave your page scrollable while a panel is open. The panel is fixed inside the frame, so it moves with your page.
What the tag does for you
- Builds the frame's address from your page's
id, then addsembedded=1,login,frameoriginandframeparam. The last two bring the visitor back to your page after sign-in. - Accepts messages only from
domain, and posts only to it. - On every page load inside the frame, sets the frame to your visible screen height, minus the header. It then sends
viewport. - Applies each
resize, skipping heights below 100 and changes of 2 pixels or less. - Answers
request-viewport, and resendsviewportwhen the window, orientation or visible area changes. After a single-page-app re-init it stops resending: see Re-initialise in a single-page app. - Handles
scroll-towith a jump, not an animation. - Replies
ready-to-close-modalto eachclose-modal. - Scrolls back to the embed when the visitor changes page inside it: see
scrollIntoViewOnNavigate.
A parent page written by hand
This page does what the tag does for sizing, panels and the return from sign-in. Its frame may also use the camera and location, which the tag's can't. Replace the address with your experience's.
<iframe
id="omnilab-embed"
allow="camera; geolocation"
style="width: 100%; border: none"
></iframe>
<script>
const EXPERIENCE_ORIGIN = "https://play.lindenhall.example";
const EXPERIENCE_PATH = "lindenhall-autumn-workshops?c=a315";
const frame = document.getElementById("omnilab-embed");
// Space a sticky header takes at the top of the screen.
function headerOffset() {
const header = document.querySelector("header");
const bottom = header ? header.getBoundingClientRect().bottom : 0;
return Math.min(Math.max(0, bottom), Math.floor(window.innerHeight / 3));
}
// Height the visitor can see below the header.
function availableHeight() {
const visible = window.visualViewport ? window.visualViewport.height : window.innerHeight;
return Math.max(0, visible - headerOffset());
}
function sendViewport() {
frame.contentWindow.postMessage(
{ type: "viewport", availableHeight: availableHeight() },
EXPERIENCE_ORIGIN
);
}
// Every page load inside the frame: start at the visible height, then report the room.
frame.addEventListener("load", () => {
frame.style.height = availableHeight() + "px";
sendViewport();
});
window.addEventListener("resize", sendViewport);
if (window.visualViewport) window.visualViewport.addEventListener("resize", sendViewport);
window.addEventListener("message", (event) => {
if (event.origin !== EXPERIENCE_ORIGIN) return;
const msg = event.data;
if (!msg || typeof msg !== "object") return;
if (msg.type === "resize" && msg.target === "body") {
const height = msg.data && msg.data.height;
if (typeof height !== "number" || height < 100) return;
if (Math.abs(height - frame.getBoundingClientRect().height) <= 2) return;
frame.style.height = height + "px";
} else if (msg.type === "request-viewport") {
sendViewport();
} else if (msg.type === "scroll-to") {
const frameTop = frame.getBoundingClientRect().top + window.scrollY;
const room = Math.max(0, availableHeight() - msg.height);
window.scrollTo(0, Math.max(0, Math.round(frameTop + msg.top - headerOffset() - room / 2)));
}
});
// Back from sign-in, this page's own address carries the experience in `id`.
const returnedPath = new URLSearchParams(window.location.search).get("id");
const src = new URL(EXPERIENCE_ORIGIN + "/" + (returnedPath || EXPERIENCE_PATH));
src.searchParams.set("embedded", "1");
src.searchParams.set("frameorigin", window.location.href);
src.searchParams.set("frameparam", "id");
frame.src = src.toString();
</script>How the return from sign-in works:
frameoriginis your page's full address. Sign-in sends the visitor back there, with your page's other parameters kept.- The experience they were on, with its parameters, comes back in
id, URL-encoded. It always usesid, so setframeparamtoid, and useidfor nothing else on the page. - Your page rebuilds the frame from
id, as the last lines above do. Without them, the frame reopens your default address instead of the one the visitor signed in from.
If something's blocked
- Nothing appears, and the console shows no error: check your page's address has
id, andtargetIdmatches the container'sid. _om_async.push is not a function: the script has already loaded. Callinitas in Re-initialise in a single-page app.- The frame stays at screen height, with its own scrollbar:
domaindoesn't match the frame's host exactly, so the tag ignores its messages. - A loading spinner never clears:
initran while the previous frame was still in the page. Empty the container first. - The console reports a blocked script or frame: your content security policy is missing a host. See What the tag needs from your page.
- The console reports a blocked inline style: that's the tag's loading spinner. See What the tag needs from your page.
- Panels are the wrong height after the phone rotates, in a single-page app: the tag was re-initialised. See Re-initialise in a single-page app.