JavaScript tag reference

Install the JavaScript tag, set its options and handle the messages between your page and an embedded experience.

9 min read

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

EnvironmentScript address
Productionhttps://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:

Container + tag
<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-workshops opens the campaign's landing page.
  • https://www.lindenhall.example/promo?id=lindenhall-autumn-workshops?c=a315 opens 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 header
Content-Security-Policy: script-src 'self' https://cdn.21-digital.com; frame-src https://play.lindenhall.example

Keep 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:

Show the experience after a client-side navigation
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:

loginStatusThe tag addsEach time your page loads the frame
Not set, or anything but the string "True"login=0The experience starts signed out, even for a participant who signed in on an earlier visit
"True"login=1The 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:

Before the snippet
<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.

OptionDefaultWhat it does
domainnoneThe host your experiences are served from, here play.lindenhall.example. Required. Without a scheme, the tag uses https.
targetIdnoneThe 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
allowFullscreenoffSet to true to add the fullscreen attributes
sandboxAttributesunsetAdds a sandbox attribute with the value you give
stickyHeaderSelector#main-header, then headerThe element panels must stay below. false turns detection off.
topOffset0Pixels to keep clear at the top when no header is found, or detection is off
scrollIntoViewOnNavigatetrueScrolls 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:

Point the tag at your own header
_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

MessageShapeSent whenYour 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 viewportReply 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

MessageShapeSend itWhat 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 panelCloses 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 adds embedded=1, login, frameorigin and frameparam. 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 resends viewport when 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-to with a jump, not an animation.
  • Replies ready-to-close-modal to each close-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.

Parent page written by hand
<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:

  • frameorigin is 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 uses id, so set frameparam to id, and use id for 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, and targetId matches the container's id.
  • _om_async.push is not a function: the script has already loaded. Call init as in Re-initialise in a single-page app.
  • The frame stays at screen height, with its own scrollbar: domain doesn't match the frame's host exactly, so the tag ignores its messages.
  • A loading spinner never clears: init ran 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.

Next steps

On this page