A booking widget is the one piece of third-party interface a visitor is guaranteed to study. They are about to hand over a name, an email address and half an hour of their week. Anything that reads as this bit is not really ours costs you something at the wrong moment.
Most embedded schedulers read that way, and not for want of trying. It follows from a decision taken in the first hour: the widget ships as an iframe. No number of colour parameters in a query string gets it back.
The iframe is not a lazy choice, though. It is the right choice for plenty of embedded things, and knowing which ones is the useful part.
An iframe is a second document, and that is the whole story
Hold on to the word document. An <iframe> is not a decorated <div>. The browser builds a second document inside it: its own DOM tree, its own CSSOM, its own layout pass, its own viewport. Everything below follows from that.
It cannot inherit, in either direction
CSS inheritance travels down a tree, and there is no edge between your page and the document in the frame. Nothing crosses. The frame starts from the browser's default stylesheet plus whatever CSS the widget shipped with. What it never starts from is yours: not your typeface, not your text colour, not your radius.
It fails the other way too: a cross-origin frame cannot read the host's computed styles, so it cannot go and look them up. That is deliberate, because if a framed script could call getComputedStyle on its parent, :visited link colours would leak a visitor's browsing history. The isolation does real security work. It also leaves the widget blind.
It has its own stacking context and its own scroll
Contents cannot paint outside the frame's box, so a dropdown or date popover that overflows is clipped at its edge. position: fixed resolves against the frame's viewport rather than the window, so a modal opens in the middle of the widget. Media queries resolve against the frame's box, so a widget in a 360px sidebar thinks it is on a phone.
A scrollIntoView inside the frame moves the inner document while the page stays put. That is the trapdoor behind the commonest complaint about these widgets: you pick a date, the next step renders below the fold of the frame, and nothing appears to happen.
It cannot size itself
The frame has a height because you gave it one. The content has a height because of what is in it. The parent cannot measure a cross-origin child to reconcile them, so the child measures itself with a ResizeObserver and posts the number out through postMessage. Without that handshake the fallback is a fixed height, and a fixed height is a guess: too low leaves a scrollbar mid-page, too high leaves dead space under the form.
Three ways to put a widget on somebody else's page
1. An iframe
Isolated in both directions. Your CSS is the only CSS, your JavaScript is unreachable from outside, and a host carrying a fifteen-year-old global reset cannot touch you.
Use one when isolation is the requirement rather than the side effect. A card number field belongs in an iframe: the point is that the host page must not be able to read it. So does anything rendering content you did not write. If the two sides should not fully trust each other, the iframe is not a compromise. It is the answer.
2. A script that mounts into the host DOM
An ordinary script tag finds a mount element and builds real elements inside it. They belong to the host document like any others, so they inherit its font, take part in its layout and need no sizing handshake. One scroll container, one stacking root, media queries that mean what they say. The cost is that you are a guest in a document you do not control.
3. A custom element with a shadow root
The middle position, and a good one. attachShadow gives you a subtree the host's selectors do not match into, so their global button { } rule cannot reach your buttons and your styles cannot leak out. It is still one document: inline layout, normal sizing, the page's own scrolling and stacking.
Inheritance still crosses. font-family, color, line-height and custom properties all pass from the host element into the shadow tree. The boundary blocks selectors and lets inheritance through, so you choose your seams. The trade is that you have to design them: anything you did not expose, nobody can override, and the host's utility classes do not reach inside.
What inheriting the design means once you implement it
Inheriting the font is free: put an element in the tree and it inherits. Everything else has to be read off the page, and that is fussier than it sounds.
Font family and text colour come off the mount element. Background does not. A mount inside three nested transparent divs computes to rgba(0, 0, 0, 0), and treating transparent as black inverts the theme on a white page, so you walk up until you find an ancestor that is painted.
Radius is not one value on one element either: sample the buttons, inputs and cards on the page and take whichever radius occurs most. Accent is the interesting one: a site with a design system has already named its brand colour, so --brand or --color-accent is worth checking first, and a site without one has a big filled button instead.
// Read the page, not a config file.
const mount = document.querySelector("#setupp");
const styles = getComputedStyle(mount);
const surface = {
fontBody: styles.fontFamily,
foreground: styles.color,
background: nearestPaintedBackground(mount), // walks up past transparent
accent: namedBrandColour(styles) || largestFilledButton(),
radius: commonestRadius("button, input, .card"),
};
// Then express EVERYTHING downstream as a variable, so the host page
// can override any single one of them and keep the rest.
root.style.setProperty("--stp-font", surface.fontBody);
root.style.setProperty("--stp-fg", surface.foreground);
root.style.setProperty("--stp-accent", surface.accent);
root.style.setProperty("--stp-radius", surface.radius + "px");The second half matters more. Reading the page is only useful if everything downstream resolves through a variable: --stp-fg, --stp-fg-muted, --stp-border, --stp-border-strong, --stp-accent, --stp-accent-fg, --stp-radius and a dozen more, under a rule that no booking component may name a colour directly. That is the difference between inheriting and almost inheriting. One hardcoded hex on one border gives the widget away.
What you owe a document you are a guest in
Mounting into the host DOM gives up the iframe's guarantee that you cannot break the page. You have to earn it instead, and this is the part most write-ups leave out.
- Prefix everything. Every class, custom property, data attribute and key you set on
window. Setupp usesstp-and--stp-throughout. A generic.cardin a third-party widget collides eventually, and the collision is reported as your bug. - Never write a global selector. No
* { box-sizing: border-box }, no barebutton { }, no unscoped@font-face. Resetting box sizing across a whole page because your own layout assumed it is the rudest thing an embed can do. - Do not assume a reset, or its absence. Set what you depend on inside your own scope: box sizing, margins on what you render, and
button, which arrives with a background, a border and a padding you do not want. - Touch nothing you did not create. No restyling the host's elements, no listeners on
documentyou never remove, no rewritinghistoryon a page whose routing you know nothing about. Read the page, do not edit it. - Survive being mounted twice. Somebody will paste the snippet into a template that renders in a loop. Two copies on one page should give two working widgets or one, never a broken one. Duplicate
idattributes are where it breaks first.
Popups and the third-party cookie question
The popup blocker. Browsers block window.open when it is not called directly in response to a user gesture, and an embed reaching for a window inside an async callback after a fetch has already lost that gesture. An in-DOM mount opens no window, so the question never arises. Narrow, though: an iframe that never opens a popup is not affected either.
Third-party cookies. Content in a cross-origin frame is third-party content, and browsers keep restricting what it may store. Safari has blocked third-party cookies by default for years. Firefox partitions them. Chrome's stated position has changed more than once, which is itself a reason not to build on an assumption either way.
An in-DOM mount sidesteps the category, because the script runs in the host page's own first-party context. Do not take that further than it goes. The widget's calls are still cross-origin and carry no cookies unless you deliberately send them. Better not to need any: Setupp's embed calls public endpoints that take no credential, so it has exactly the access a stranger on the booking page has.
Where Setupp lands, and what it actually does
Two lines of markup: a <div> to mount into, and a <script> carrying a workspace, an event and a mount selector. The loader reads the surface it landed on, derives the tokens and renders the booking flow as real elements in your page. No frame, no origin swap, no redirect off your domain.
The constraint that makes it work is dull: booking components are forbidden from hardcoding a colour and read only from custom properties. The homepage demo runs identical markup on two opposed designs, one serif on cream paper with 3px corners and one monospaced and stark white with 12px. Only the page around it differs. Set the --stp-* values yourself and it uses yours instead.
What renders inside is the hosted page's flow, not a reduced version. If your event type puts qualifying questions in front of the calendar, the embed shows the questions first and the calendar only once the rules clear on the server. If it is a multi-host event type, it shows whatever availability that shape produces. Layout runs on container queries, so it sizes itself from the space you gave it, not the window.
The caveats. The embed is a paid-plan feature, so on the free tier the hosted booking page is what you get and the pricing page says which tier changes that. A strict script-src has to allow the loader, like any third-party script. And if what you want is isolation rather than inheritance, none of this is an argument. Use an iframe. It is very good at its job.