Skip to content
Coastal Healthcare Advocates

Module: calendly

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-fallback is the no-JavaScript fallback (and what prints): the plain link out. It hides once the frame is in place.
  • .calendly-alt shows 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 preconnect to 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:close takes 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 data-calendly value.

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 [data-calendly] element.

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 [data-calendly] element the booking came from.

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 [data-calendly] element.

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 [data-calendly-toggle] button.

open boolean

true to show the panel (and load it), false to hide it.

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 [data-calendly] element.

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 [data-calendly] element.

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