Calendly's scheduler, embedded inline instead of linked to.
The consultation calendar used to be an outbound link to calendly.com. It is
now an inline embed: the scheduler sits in the page — the closing
#schedule band, and a panel in the contact dialog — so a visitor books
without leaving the site.
Why not Calendly's widget.js?
Calendly's documented inline embed is a <div class="calendly-inline-widget">
plus their widget.js. For an inline embed that script does three things:
it builds an <iframe> whose URL carries embed_domain and
embed_type=Inline, shows a spinner, and (with data-resize) sizes the
frame from the calendly.page_height messages the framed page posts. This
module does the same from first-party code, so the site's CSP only has to
allow https://calendly.com on frame-src, and no third-party script runs
in the page that holds the intake form. Calendly documents the plain iframe
as a supported alternative.
Authored markup
A mount that shows the scheduler as soon as the visitor scrolls near it:
<div class="calendly-embed" data-calendly="https://calendly.com/…"
data-calendly-title="Pick a time (Calendly scheduler)">
<p class="calendly-fallback"><a class="btn" href="https://calendly.com/…">Schedule…</a></p>
<p class="calendly-alt">Calendar not loading? <a href="https://calendly.com/…">Book on Calendly</a></p>
</div>
.calendly-fallbackis the no-JavaScript fallback (and what prints): the plain link out. It hides once the frame is in place..calendly-altshows only beside a live frame: the way out for a visitor whose network or content blocker refuses the frame, which a page can't detect across origins.
A mount that waits to be asked for — the dialog's — is authored hidden
and opened by a disclosure button:
<button type="button" data-calendly-toggle aria-expanded="false"
aria-controls="cm-booking">Book time with us</button>
<div id="cm-booking" class="calendly-embed calendly-embed--panel"
data-calendly="…" hidden>…</div>
The style guide borrows the dialog but its stylesheet is built from
style-guide.html and src/js/ alone, so the class names in these two
examples are also what keeps the embed's rules in style-guide.css.
Toggle clicks are handled by delegation, so a dialog that modal.js imports
into a secondary page later needs no setup call.
When the frame loads
Never on page load. A visible mount loads when it scrolls within 600px of
the viewport; a hidden one when its toggle opens it. Closing the contact
dialog (contact:close) collapses its panel and removes the frame, so every
open starts fresh and a closed dialog keeps no third-party frame alive.
Warming, so a click doesn't wait
Calendly's scheduler takes a second or two to boot, which the visitor used to
spend watching the loading note. Two steps move that off the click. Both wait
for the page to have loaded and the browser to be idle, so neither competes
with the page itself, and neither reaches Calendly for a visitor who is not
being offered a calendar (data-booked, below):
- a
preconnectto Calendly, which opens the connection — DNS, TCP, TLS — and fetches nothing: no page of Calendly's loads, so none of its cookies are set. Skipped once a mount has loaded, which is a real connection already. - the contact dialog's booking panel is loaded while still hidden, on
contact:open. Its toggle lives inside the dialog, so the dialog opening is the earliest a visitor can be reaching for it.contact:closetakes the frame out again, so each open warms afresh — and a dialog closed before the idle moment arrives is never warmed at all.
What the hidden frame buys is the network, not the drawing. It fetches
Calendly's page, its stylesheet and script chunks and its initial_settings
availability call, then stops: Chromium throttles rendering in a frame that
isn't visible, so it reports no height while it waits, and the layout its
zero width would otherwise imply decides nothing — it lays out at the panel's
real width once the panel opens. Measured on the homepage 2026-09-18 — two
sets of three runs, clicking "Book time with us" two seconds after opening the
dialog — the calendar arrived in a median 1.4s warmed against 4.5s cold. The
loading note still shows for a moment; what has gone is the wait for the
fetching. Calendly's own boot time is variable, so treat these as a shape
rather than a promise.
The #schedule band needs none of this — it already loads 600px before it
reaches the viewport.
Messages from the frame
Only messages whose origin is https://calendly.com and whose source is
one of our frames are read (so two embeds on a page never resize each
other, the limit Calendly's own script has). calendly.page_height sizes
the frame. calendly.date_and_time_selected marks a change of step, which
replaces the frame's content from the top: the embed is scrolled back into
view if its top has left the screen.
The site's own thanks
calendly.event_scheduled means the booking is made. If the mount authors a
confirmation —
<div class="calendly-booked" data-calendly-booked role="status" hidden>…</div>
— the frame is removed and that panel shown and focused in its place, so the
visitor ends on the site's thanks (the beacon's light comes on, as for a
sent message) and not on Calendly's confirmation and sign-up prompt. A
calendly:booked event then bubbles from the mount; modal.js uses it to
tidy the dialog around the panel. Calendly tells the page that a booking
was made, never which slot (the payload is two API URIs the browser can't
read), so the panel's copy points at Calendly's confirmation email for the
date and time. A mount with no such panel keeps Calendly's own confirmation.
Once booked, not asked again
A booking is also remembered: localStorage gets BOOKED_KEY with the
time, and for BOOKED_TTL the site stops inviting that visitor to
book. data-booked on <html> is the switch and src/input.css does the
hiding: every [data-calendly] mount (bar the one showing its thanks), every
[data-calendly-toggle], and anything marked data-calendly-offer (the
success panel's "Book time with us"). Copy marked data-calendly-after,
authored hidden, stands in where a sentence needs rewording. It is a
stylesheet rule so that a dialog modal.js imports later needs no setup.
Only the fact and the time of a booking are stored, never its details.
The footer's Calendly icon stays, and it still has to lead somewhere. A link
to #schedule is an explicit ask for the calendar, so following one brings
everything back for that page view: a click on this page, or arriving at
index.html#schedule from another page (a fresh navigation only, since a
reload or the back button lands on that URL without anyone asking). The
record is left alone, so the next page view is quiet again.
Calendly's cookie notice is deliberately not suppressed
(hide_gdpr_banner): hiding it would make this site responsible for
consent to Calendly's cookies.
Loaded as an ES module on every page that carries the contact dialog.
- Source:
Members
(inner, constant) BAND_HASH
The fragment naming the booking band: a link to it is an ask for the calendar. @type {string}
- Source:
(inner, constant) BOOKED_KEY
localStorage key recording that this visitor booked, and when ({ at: ms }). @type {string}
- Source:
(inner, constant) BOOKED_TTL
How long a booking keeps the invitations to book out of the way, in ms (30 days). @type {number}
- Source:
(inner, constant) CALENDLY_ORIGIN
The only origin this module will frame or listen to. @type {string}
- Source:
(inner, constant) IDLE_TIMEOUT
How long to wait for an idle moment before warming anyway, in ms. @type {number}
- Source:
(inner, constant) MAX_HEIGHT
Tallest the frame may be sized, in px. @type {number}
- Source:
(inner, constant) MIN_HEIGHT
Shortest height worth acting on, in px: Calendly reports a few px while it boots, and those are skipped. @type {number}
- Source:
(inner, constant) UTM_KEYS
Campaign parameters passed from the page's URL to the booking, as Calendly's script does. @type {string[]}
- Source:
(inner) dialogOpen
Whether the contact dialog is open, so warming that arrives after it closes can be dropped. @type {boolean}
- Source:
(inner, constant) live
Every mount with a frame in it, and that frame. @type {Map<HTMLElement, HTMLIFrameElement>}
- Source:
(inner, constant) root
<html>, which carries data-booked for the stylesheet. @type {HTMLElement}
- Source:
(inner) warmed
Whether anything has opened a connection to Calendly yet. @type {boolean}
- Source:
Methods
(inner) arrivedAsking() → {boolean}
Whether the visitor got here by following a link to the booking band (the footer's Calendly icon on another page). Only a fresh navigation counts: a reload or the back button lands on the same URL without anyone asking.
- Source:
Returns:
true if this page view began as an ask for the calendar.
- Type
- boolean
(inner) embedUrl(base) → {string|null}
The URL to frame: the mount's Calendly link plus the two parameters that
tell Calendly it is an inline embed (which is what makes it post its height
and booking events to this page), and any campaign parameters on the page.
A query string authored on the mount is kept: the pages pass
hide_event_type_details=1, which drops Calendly's own event header from
the calendar step (it returns on the final step) and with it ~180px.
Parameters:
| Name | Type | Description |
|---|---|---|
base |
string | undefined | The mount's |
- Source:
Returns:
The frame URL, or null if base isn't a Calendly URL.
- Type
- string | null
(inner) hasBooked() → {boolean}
Whether this visitor booked within the last BOOKED_TTL. An expired,
future-dated or unreadable record is deleted, as theme.js does with its own.
- Source:
Returns:
true while a booking is remembered.
- Type
- boolean
(inner) load(mount) → {void}
Puts the scheduler into a mount: a loading note, then the frame. The
authored fallback stays in the DOM (hidden by .is-live) for print.
Does nothing if the mount is already live or names no Calendly URL.
Parameters:
| Name | Type | Description |
|---|---|---|
mount |
HTMLElement | A |
- Source:
Returns:
- Type
- void
(inner) noteBooked(mount) → {void}
A booking was just made in mount: remember it, and stop inviting this
visitor to book. The mount itself stays, because it holds the confirmation.
Parameters:
| Name | Type | Description |
|---|---|---|
mount |
HTMLElement | The |
- Source:
Returns:
- Type
- void
(inner) rememberBooked() → {void}
Records that a booking was just made: the time, and nothing about it.
- Source:
Returns:
- Type
- void
(inner) resetBooked(mount) → {void}
Puts a mount back to its authored state after a booking: thanks hidden, and the fallback link allowed to show again.
Parameters:
| Name | Type | Description |
|---|---|---|
mount |
HTMLElement | A |
- Source:
Returns:
- Type
- void
(inner) setOpen(btn, open) → {HTMLElement|null}
Opens or closes a disclosure button's scheduler panel.
Parameters:
| Name | Type | Description |
|---|---|---|
btn |
HTMLElement | A |
open |
boolean |
|
- Source:
Returns:
The panel, or null if aria-controls names nothing.
- Type
- HTMLElement | null
(inner) showAgain() → {void}
Brings the invitations to book back for this page view, because the visitor asked for the calendar. The record stays, so the next page view is quiet again.
- Source:
Returns:
- Type
- void
(inner) showBooked(mount) → {boolean}
Swaps a mount's frame for its authored thanks, once Calendly reports the
booking. Does nothing on a mount that authors no [data-calendly-booked]
panel, which leaves Calendly's own confirmation showing in the frame.
Parameters:
| Name | Type | Description |
|---|---|---|
mount |
HTMLElement | A |
- Source:
Returns:
true if the thanks was shown, false if the mount has none.
- Type
- boolean
(inner) unload(mount) → {void}
Takes the scheduler back out of a mount and restores its authored state.
Parameters:
| Name | Type | Description |
|---|---|---|
mount |
HTMLElement | A |
- Source:
Returns:
- Type
- void
(inner) warmConnection() → {void}
Opens the connection to Calendly without fetching anything from it, so the
first frame starts at its HTTP request instead of three round trips before
it. A preconnect is DNS, TCP and TLS only: no page of Calendly's is
loaded, so none of its cookies are set, and because nothing is fetched there
is no CSP directive for it to pass (the frame-src grant still covers the
frame itself). No crossorigin — the frame's navigation isn't a CORS
request, and an anonymous connection wouldn't be the one it reuses.
- Source:
Returns:
- Type
- void
(inner) warmDialog() → {void}
Loads the contact dialog's booking panel while it is still hidden, so the toggle only has to reveal it. Called when the dialog opens, which is the earliest a visitor can be reaching for a toggle that lives inside it.
- Source:
Returns:
- Type
- void
(inner) whenIdle(fn) → {void}
Runs fn once the page has finished loading and the browser has a spare
moment — immediately idle if loading is already done. Warming is worth
nothing if it slows the page down to do it, so nothing here jumps the queue;
IDLE_TIMEOUT keeps a page that never goes quiet from putting it off
for ever. (Safari only got requestIdleCallback in 18.2, hence the timer.)
Parameters:
| Name | Type | Description |
|---|---|---|
fn |
function | Callback to run. |
- Source:
Returns:
- Type
- void