Embedding in your app

Patterns for hosting a Landbot widget inside a real website or single-page app — controlling it from the parent page, sharing data, injecting CSS at runtime, and tearing down cleanly.

The widget snippets in the intro get you on screen. This page covers what comes after: making the widget cooperate with the page that hosts it.

Two scopes

Almost every parent-page integration touches two scopes:

  • siteScope — the parent window. This is where myLandbot (the value returned by the widget constructor) lives.
  • landbotScopethis inside a bot Custom JS block. This is the widget instance, equivalent to myLandbot but from inside the iframe.

landbotScope.window and landbotScope.document reach the widget frame's DOM. The parent's window and document reach the host page's DOM. Many launcher/proactive customisations need rules inserted in both stylesheets to take effect across the bubble and the open chat.

Mounting after page load

The canonical embed snippet on the Widgets overview loads the SDK as an ES module from a dynamically-inserted <script> tag and instantiates the widget once the module has loaded. That pattern works in static pages and is also a fine starting point for app code.

Sharing data with the bot

The parent page passes values into Landbot Fields with customData. The full lifecycle (at construction, after load, from inside a Code block) is on Setting variables.

// Parent page → bot
myLandbot.setCustomData({ email: 'visitor@example.com', plan: 'pro' });

The bot pushes data back to the parent by calling a function you defined globally on window:

// In a Code block, after the user picks a colour
window.changeBackgroundColor("@{color}");
<!-- on the parent page -->
<script>
  function changeBackgroundColor(color) {
    document.body.style.background = color;
  }
</script>

See JavaScript execution for the read/write patterns in detail.

Controlling the widget from parent JS

The instance you got back from the constructor exposes the full Landbot.Widget API plus widget-type-specific methods:

var myLandbot = new Landbot.Popup({ configUrl: '...' });

document.getElementById('chat-with-us').addEventListener('click', function () {
  myLandbot.open();
});

document.getElementById('book-a-demo').addEventListener('click', function () {
  myLandbot.sendMessage({
    type: 'button',
    message: 'Book a demo',
    payload: '#demo',
  });
});

For event-driven UIs (analytics, telemetry, state machines), subscribe to widget events:

myLandbot.core.events.on('new_message', function (msg) {
  analytics.track('bot_message_received', { type: msg.type });
});

myLandbot.core.events.on('widget_open', function () {
  analytics.track('bot_opened');
});

The full event list is on Landbot.Widget.

Injecting CSS at runtime

Four patterns, ordered from simplest to most robust.

From inside the bot, into a known stylesheet

If you've put any custom CSS in the builder's Design → Custom Code panel, Landbot creates a <style id="custom-styles"> tag you can write into:

var landbotScope = this;
landbotScope.window.document
  .getElementById("custom-styles").sheet
  .insertRule(`.MessageBubble{background-color:black!important;}`);

This pattern persists for the rest of the conversation — once added, the rule isn't reversible without another insertRule overriding it.

From inside the bot, into a fresh stylesheet

When you can't rely on #custom-styles existing, create your own stylesheet element:

var landbotScope = this;
landbotScope.window.document.head.insertAdjacentHTML(
  'beforeend',
  '<style id="my-custom">.MessageBubble{background:black!important;}</style>'
);

From the parent page, via a bot-side bridge

To push a complete CSS string from the parent at load time, expose a hook from the bot's Custom JS:

<!-- Bot: Design → Custom Code → JS -->
<script>
  let landbotScope = this;
  window.changeCSS = function (style) {
    landbotScope.config.style = style;
  };
</script>

Then call it from the parent page after the widget is ready:

let newStyle = `.input-button{background-color:yellow}`;

myLandbot.onLoad(function () {
  window.changeCSS(newStyle);
});

landbotScope.config.style accepts a full CSS string and overrides whatever was set in Design → Custom CSS.

From both scopes (launcher + chat)

Launcher-bubble rules (.LivechatLauncher, .LandbotLivechat, .LandbotPopup) need to be inserted in the parent page's stylesheet, because the launcher is rendered outside the iframe. Chat-content rules need to be inserted in the bot's stylesheet. Many real customisations do both:

// In the bot's Custom JS
var siteScope = window;
var landbotScope = this;

// Parent: widen the launcher container
var sheet = siteScope.document.createElement("style");
siteScope.document.head.appendChild(sheet);
sheet.sheet.insertRule('.LandbotLivechat { min-width: 200px !important }', 0);

// Bot: widen the launcher inside the iframe
this.onLoad(function () {
  var landbotSheet = landbotScope.document.createElement("style");
  landbotScope.document.head.appendChild(landbotSheet);
  landbotSheet.sheet.insertRule('.LivechatLauncher { width: 200px !important }', 0);
});

Note document.styleSheets[0] is brittle — if the host page has its own stylesheet, index 0 won't be Landbot's. Create your own <style> element with createElement("style") and append it to head for predictable results.

Hiding the widget on specific pages

Combine a URL check with a parent-page rule:

var landbotScope = this;
this.onLoad(function () {
  if (window.location.href.includes('/pricing')) {
    landbotScope.document.styleSheets[0].insertRule(
      '.LandbotLivechat { display: none !important }'
    );
  }
});

Or hide via CSS alone, then reveal on .is-open (useful when you want to render only when the user opens the bubble manually):

<style>
  .LandbotLivechat         { display: none; }
  .LandbotLivechat.is-open { display: block; }
</style>

The same .is-open pattern works for .LandbotPopup.

Mobile-only rules

Use a media query inside the inserted rule, or detect with window.innerWidth:

var landbotScope = this;
this.onLoad(function () {
  landbotScope.document.styleSheets[0].insertRule(
    '@media only screen and (max-device-width: 767.9px) { .LivechatLauncher{display:none} }'
  );
});

SPA and framework hosts

Single-page apps make the embed snippet's "script-at-the-bottom-of-<body>" advice fall apart. Two things to remember:

  1. Mount once, after the route is rendered. The widget creates an iframe and attaches to body; calling the constructor in a component's mount lifecycle (React useEffect, Vue onMounted, etc.) is fine, but make sure you only do it once per page session — multiple instances will stack iframes.
  2. Tear down before navigation. Call myLandbot.destroy() in the cleanup phase. It removes the iframe and all DOM listeners — without it, navigating away and back creates duplicate widgets.

A complete Landbot.Container component in React, mounting into a useRef-managed div and destroying on unmount:

import React, { useRef, useEffect } from 'react';

export default function MyLandbot({ url }) {
  const containerRef = useRef(null);

  useEffect(() => {
    const _landbot = new Landbot.Container({
      container: containerRef.current,
      configUrl: url,
    });

    return () => _landbot.destroy();
  }, [url, containerRef]);

  return <div className="MyLandbot" ref={containerRef} />;
}

The [url, containerRef] dependency array re-creates the widget when the bot URL changes — handy for route-aware embeds. For other widget formats (Popup, Livechat, …) that mount themselves into body rather than a target element, drop the containerRef and the wrapping <div>; the cleanup pattern is otherwise identical.

If you need a stable singleton across route changes, hold the instance at app scope and don't destroy on unmount — re-use it on the next render.

Modals and overlays inside the bot

You can drop a <div> (and CSS) into the Custom JS panel to render a modal that opens in response to a Code block — useful for booking widgets, third-party iframes, image lightboxes. Register the open/close handlers on _landbot.window in onLoad, then trigger them from a flow Code block as this.window.openModal().

Warning The modal renders inside the bot's iframe. It can only overlay the bot's footprint, not the host page. This is fine for Fullpage, Popup (while open), and Livechat in expanded mode — the bot covers enough of the viewport for the modal to feel native. It is not suitable for a small Container or collapsed Livechat, where the modal would be clipped to a tiny region.

If you need a modal that overlays the host page itself, render it on the parent page and have the bot call a window.openModal() function you define there (see JavaScript execution → Calling parent-page functions).

Worked example: Google Calendar booking modal

Open a Google Calendar appointment-scheduling page in a modal when the user taps a CTA. We skip Google's official button (it injects itself next to its own <script>, which would land in the bot's <head> if pasted into Custom JS) and iframe the booking URL directly — that's the URL Google's button loads internally, so we save the user a click. Three pieces, three places in the builder:

1. Design → Custom CSS

.gcal-modal {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 9999;
  justify-content: center;
  align-items: center;
}
.gcal-modal.is-active { display: flex; }
.gcal-modal-content {
  background: #fff;
  border-radius: 8px;
  width: 90%;
  max-width: 720px;
  height: 90%;
  max-height: 820px;
  position: relative;
  overflow: hidden;
}
.gcal-modal iframe { width: 100%; height: 100%; border: 0; }
.gcal-modal-close {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  font-size: 18px;
  cursor: pointer;
  z-index: 1;
}

2. Design → Advanced → Add JS

The iframe carries the booking URL on data-src, not src — that's the lazy-load trick. The iframe doesn't fetch Google until the modal first opens, so users whose flow never reaches the CTA pay nothing.

<div class="gcal-modal" id="gcalModal">
  <div class="gcal-modal-content">
    <button class="gcal-modal-close js-gcal-close" aria-label="Close">×</button>
    <iframe
      data-src="https://calendar.google.com/calendar/appointments/schedules/YOUR_SCHEDULE_ID?gv=true"
      title="Book an appointment"
      loading="lazy"></iframe>
  </div>
</div>

<script>
  var _landbot = this;

  this.onLoad(function () {
    var modal  = _landbot.document.getElementById('gcalModal');
    var iframe = modal.querySelector('iframe');

    _landbot.window.openBookingModal = function () {
      if (!iframe.src) iframe.src = iframe.dataset.src; // lazy load on first open
      modal.classList.add('is-active');
    };

    _landbot.window.closeBookingModal = function () {
      modal.classList.remove('is-active');
    };

    _landbot.document.querySelectorAll('.js-gcal-close').forEach(function (btn) {
      btn.addEventListener('click', _landbot.window.closeBookingModal);
    });

    // Click outside content to close
    modal.addEventListener('click', function (e) {
      if (e.target === modal) _landbot.window.closeBookingModal();
    });
  });
</script>

3. In the flow

[Message]   "Want to grab a slot on my calendar?"

[Buttons]   • "Book an appointment"  ← the CTA

[Code]      this.window.openBookingModal()

[Message]   "Take your time — I'll be here when you're done. 👋"

The Code block opens the modal as soon as the user taps the button. The flow continues to the next message immediately, so the post-booking message is already sitting in the chat when the user closes the modal.

Note Things to watch for

  • Iframe-blocking risk. The ?gv=true URL is what Google's own scheduling button loads in its popup, so it iframes fine today. If Google ever sets X-Frame-Options: DENY on it, the fallback is to drop the original <link> + <script> + IIFE snippet inside the modal content div (modified so the target points to a document.getElementById(...) inside the modal, not currentScript) and accept the two-click flow.
  • Widget mode matters. The modal lives inside the bot's iframe (see the warning above). On a small Container or collapsed Livechat the calendar will be unusable. Use this pattern on a Fullpage embed, an expanded Livechat, or a Popup-mode widget.
  • Lazy loading is intentional. The iframe only fetches Google when the user first opens the modal. Don't drop the data-srcsrc swap; bots that never reach the CTA shouldn't pay the cost.
  • Test with Share URL, not Preview. onLoad and this.window plumbing don't run in the builder's Preview pane.

See it in action

bidirectional-examples.vercel.app — four runnable patterns covering the techniques on this page: sending a custom query via sendMessage, per-product fresh context via destroy + recreate, and the bot-fills-a-form recipe.

See also