Modal example

How it works

One dialog for the whole site, opened by any button. What goes inside is decided by the button.

Modal opens a dialog over the page and locks the scroll behind it. The dialog, ModalA, is rendered once by the Layout outside #swup, so it survives page transitions; every page only needs buttons. The opening, closing, the Escape key and the scroll lock come from the @terrahq/modal package. The class in src/js/handler/modal creates one instance per click and opens it with the button as trigger.

The content is where the handler does its work. Before the dialog opens, beforeOpen reads data-modal-variation from the button that was clicked and prepares the content for it: the default paragraph, a video, or a slider. On close everything injected is removed, so the next button finds the dialog clean. Each variation is a small function in variations/index.js, which keeps Handler.js a switch.

The markup

A button with the js--modal-button hook and data-modal-open set to the id of the dialog. The hook is for the handler; the attribute is what the package reads.

<!-- The trigger: the hook for the handler, and the id of the dialog to open for the package -->
<button class="c--btn-a js--modal-button" data-modal-open="my-modal">Open modal</button>

The dialog is already on every page. Its id is what the buttons point to; c--dialog-a__content is where the variations inject their content.

<!-- ModalA: rendered once by the Layout, outside #swup. Anything with data-modal-close closes it -->
<div id="my-modal" class="c--modal-a js--modal" aria-hidden="true">
  <div class="c--modal-a__overlay" data-modal-close></div>
  <div class="c--dialog-a">
    <h2 class="c--dialog-a__title">Modal Title</h2>
    <button class="c--dialog-a__btn" data-modal-close>×</button>
    <div class="c--dialog-a__content">
      <p>…</p>
    </div>
  </div>
</div>

The handler

Handler.js gives the package the dialog element and the open class, and hooks the two callbacks. beforeOpen receives the dialog and the trigger, which is how it knows which button asked and which variation to build:

this.config = ({element}) => ({
    selector: document.querySelector(".c--modal-a"),
    openClass: "c--modal-a--is-active",
    beforeOpen: (modalEl, trigger) => {
        const dialogContent = modalEl.querySelector(".c--dialog-a__content");
        const variation = trigger?.element?.getAttribute("data-modal-variation") || "default";
        const paragraph = dialogContent.querySelector("p");
        resetDialogContent({ dialogContent, paragraph });

        switch (variation) {
            case "video":
                videoVariation({ boostify: this.boostify, dialogContent, paragraph, trigger });
                break;

            case "slider":
                sliderVariation({ eventSystem: this.eventSystem, dialogContent, paragraph });
                break;

            case "hubspot":
                hubspotVariation({ boostify: this.boostify, dialogContent, paragraph, trigger });
                break;

            default:
                defaultVariation({paragraph})
        }
    },
    onClose: (modalEl, trigger) => {
        handleOnClose({
            modalEl,
            trigger,
            eventSystem: this.eventSystem
        });
    },
});

What the framework does for you

  • Lazy loading on click. Boostify uses the click method here: nothing is imported on scroll. The first click on a button loads Modal.js and the package, builds the instance and opens the dialog in one go.
  • Reopening. On close the instance is destroyed through the Modal:destroy event, but the click triggers stay alive, so the next click builds a fresh instance. No stale state between openings.
  • Page transitions. Before swup replaces the content the instances and the click triggers of the old page are destroyed; the new page registers its own buttons. The dialog itself is never replaced.
  • Cross-handler events. A variation can ask another handler for help through the event system: the slider variation fires Slider:load on open and Slider:destroy on close.

Content

The default. No variation: the dialog shows the paragraph it carries in the markup.

Default modal-open="my-modal"

A button with nothing but data-modal-open. The variation falls back to default, which makes sure the paragraph in c--dialog-a__content is visible. This is the case for static content written in the dialog itself: a notice, a short form, a message.

<button class="c--btn-a js--modal-button" data-modal-open="my-modal">Open modal</button>
export function defaultVariation(payload) {
    const { paragraph } = payload;
    if (paragraph) {
        u_style(paragraph, [{ display: "block" }]);
    }
}
Live example
modal-open="my-modal"

Video

The dialog hides its paragraph and plays an mp4 from the button's attribute.

Variation modal-variation="video"modal-video-url="…mp4"

Set data-modal-variation="video" and give the URL in data-modal-video-url. On open, the paragraph is hidden and Boostify's videoPlayer builds a 16:9 <video> with controls and autoplay, appended to the dialog content. The player carries the js-modal-dynamic class, which is how the close handler finds and removes it. Without the URL nothing is injected and the dialog opens empty.

<button
  class="c--btn-a js--modal-button"
  data-modal-open="my-modal"
  data-modal-variation="video"
  data-modal-video-url="https://placeholder.terrahq.com/1-min-video.mp4"
>Open video</button>
export function videoVariation(payload) {
    const { boostify, dialogContent, paragraph, trigger } = payload;

    if (paragraph) {
        u_style(paragraph, [{ display: "none" }]);
    }

    const videoUrl = trigger.element.getAttribute("data-modal-video-url");
    if (!videoUrl) return;

    boostify.videoPlayer({
        url: { mp4: videoUrl },
        style: { aspectRatio: "16/9", width: "100%" },
        attributes: {
            class: "js-modal-dynamic video-file",
            autoplay: true,
            controls: true,
        },
        appendTo: dialogContent,
    });
}
Live example
modal-variation="video"modal-video-url="…mp4"

Slider

The dialog injects a slider and asks the Slider handler to build it. Two handlers, one event each way.

Variation modal-variation="slider"

Set data-modal-variation="slider". On open, the variation hides the paragraph, injects the markup of a c--slider-a with the js--slider hook, and fires loadEvent({ library: "Slider", where: "Modal" }). The Modal handler does not build sliders; it tells the Slider handler that there is a new one to build.

<button class="c--btn-a js--modal-button" data-modal-open="my-modal" data-modal-variation="slider">Open slider</button>
export function sliderVariation(payload) {
    const { eventSystem, dialogContent, paragraph } = payload;
    if (paragraph) {
        u_style(paragraph, [{ display: "none" }]);
    }

    dialogContent.innerHTML += `
        <div class="c--slider-a js-modal-dynamic">
            <div class="c--slider-a__wrapper js--slider" data-slider-items="2">
                <div class="c--slider-a__wrapper__item">
                    <p class="f--font-c">1</p>
                </div>
                <div class="c--slider-a__wrapper__item">
                    <p class="f--font-c">2</p>
                </div>
                <div class="c--slider-a__wrapper__item">
                    <p class="f--font-c">3</p>
                </div>
                <div class="c--slider-a__wrapper__item">
                    <p class="f--font-c">4</p>
                </div>
                <div class="c--slider-a__wrapper__item">
                    <p class="f--font-c">5</p>
                </div>
                <div class="c--slider-a__wrapper__item">
                    <p class="f--font-c">6</p>
                </div>
            </div>
        </div>
    `;

    eventSystem.loadEvent({ library: "Slider", where: 'Modal' });
}

The Slider handler listens for Slider:load, queries the page again so it finds the markup that was just injected, and assigns instances with forceLoad: true. That flag skips the viewport check and Boostify: the slider is built right away, which matters because a dialog that has just opened has not been scrolled to.

this.emitter.on("Slider:load", (payload) => {
    this.DOM = this.updateTheDOM;

    super.assignInstances({
        elementGroups: [
            {
                elements: this.DOM.sliderElement,
                config: this.config,
                boostify: { distance: 30 },
            },
        ],
        forceLoad: true,
    });
});

On close the flow runs backwards. handleOnClose removes everything injected, and if a slider was there fires destroyEvent for Slider, so its instance is destroyed before its markup disappears. Then it fires destroyEvent for Modal, which destroys the modal instance but keeps the click triggers, ready for the next opening:

export function handleOnClose(payload) {
    const { modalEl, eventSystem } = payload;
    if (!modalEl) return;

    const dialogContent = modalEl.querySelector(".c--dialog-a__content");
    if (!dialogContent) return;

    const hasSlider = dialogContent.querySelector(".js--slider");

    dialogContent.querySelectorAll(".js-modal-dynamic").forEach((el) => el.remove());

    if (hasSlider) {
        eventSystem.destroyEvent({library: 'Slider', where: 'Modal'})
    }

    eventSystem.destroyEvent({library: 'Modal', where: 'Modal'})
}
Live example
modal-variation="slider"

HubSpot form

The dialog loads the HubSpot embed script on first open and renders the form inside it.

Variation modal-variation="hubspot"modal-hs-portal="…"modal-hs-form="…"

Set data-modal-variation="hubspot" and add the three HubSpot identifiers on the button: data-modal-hs-portal, data-modal-hs-form and data-modal-hs-region. On open, the variation hides the paragraph, appends a container with the js-modal-dynamic class and calls boostify.loadscript to fetch the HubSpot embed script. Once the script is ready it calls hbspt.forms.create, which renders an iframe into the container.

On subsequent openings window.hbspt is already defined, so the script load is skipped and the form renders immediately. On close the container carries js-modal-dynamic, which is how handleOnClose finds and removes it — no extra destroy step needed. If the modal is closed before the script finishes loading, the guard in the callback prevents a render into a node that no longer exists.

<button
  class="c--btn-a js--modal-button"
  data-modal-open="my-modal"
  data-modal-variation="hubspot"
  data-modal-hs-portal="51865332"
  data-modal-hs-form="24bbfeb8-a327-435d-8ef8-8c1c52b6a5aa"
  data-modal-hs-region="na1"
>Open HubSpot form</button>
export function hubspotVariation(payload) {
    const { boostify, dialogContent, paragraph, trigger } = payload;

    if (paragraph) {
        u_style(paragraph, [{ display: "none" }]);
    }

    const portalId = trigger.element.getAttribute("data-modal-hs-portal");
    const formId   = trigger.element.getAttribute("data-modal-hs-form");
    const region   = trigger.element.getAttribute("data-modal-hs-region") || "na1";

    if (!portalId || !formId) return;

    const container = document.createElement("div");
    container.className = "js-modal-dynamic js-modal-hs-form";
    dialogContent.appendChild(container);

    // hbspt already available (previous open, cookie banner or any other HubSpot embed on the page)
    if (window.hbspt) {
        window.hbspt.forms.create({ portalId, formId, region, target: ".js-modal-hs-form" });
    } else {
        // First open: load the embed script, then render. Guard against the modal
        // closing before the async load finishes
        boostify.loadScript({ url: "//js.hsforms.net/forms/embed/v2.js" }).then(() => {
            if (!document.querySelector(".js-modal-hs-form")) return;
            window.hbspt.forms.create({ portalId, formId, region, target: ".js-modal-hs-form" });
        });
    }
}
Live example
modal-variation="hubspot"modal-hs-portal="…"modal-hs-form="…"

Options reference

What goes on the button, what goes in the dialog, and what the handler sets.

Reference

Attributes on the button

AttributeValuesWhat it does
data-modal-openid of the dialog, requiredWhich dialog the button opens. my-modal is the id of ModalA
data-modal-variationdefault (when absent), video, slider, hubspotWhat beforeOpen puts in the dialog content
data-modal-video-urlURL of an mp4The file the video variation plays. Without it nothing is injected
data-modal-hs-portalHubSpot portal IDRequired for the hubspot variation. Without it nothing is injected
data-modal-hs-formHubSpot form IDRequired for the hubspot variation. Without it nothing is injected
data-modal-hs-regionna1 (default), eu1Optional. HubSpot data residency region

Attributes in the dialog

AttributeWhereWhat it does
data-modal-closeany element inside the dialogCloses the dialog on click. ModalA puts it on the overlay and on the × button. Escape closes it too

Config in Handler.js

OptionValue setWhat it does
selectordocument.querySelector(".c--modal-a")The dialog element. The package wants the element, not a string
openClassc--modal-a--is-activeClass added to the dialog while open. The SCSS of c--modal-a fades it in and enables pointer events on it
beforeOpen(modalEl, trigger) => …Resets the content and builds the variation read from trigger.element
onClose(modalEl, trigger) => …Calls handleOnClose: removes injected content, destroys Slider and Modal

The package has more hooks and options the handler leaves at their defaults: onShow, beforeClose, openTrigger (data-modal-open), closeTrigger (data-modal-close), disableScroll (true) and debug (false). Add them to the config when a project needs them.