Modals

A modal is a native <dialog class="modal">. The browser handles the focus trap, the top layer and Esc; the bundled script adds a close button, backdrop-click, groups and events. No library, no inline handlers.

Keyboard

Modal keyboard interaction
KeyAction
EscClose the modal; focus returns to the trigger (native).
Tab / Shift+TabMove between focusable elements, trapped inside the dialog (native).
Enter / SpaceActivate the focused control.
/ In a group, step to the previous / next modal. Ignored while a form field is focused.

Markup

  • data-modal-target="id" on any button opens that dialog; data-modal-close on anything inside closes it. Both work under a CSP. From your own code, toggleModal('id') is global.
  • .modal-content is the scrolling body; .modal-header / .modal-footer stay pinned and visible (the dialog is a flex column).
  • closedby="any" gives native click-outside-to-close where supported; the script covers the rest.
  • The dialog is its own surface at the .bg-soft level — don't add a .bg-* class. Override --jf-color-surface on .modal for a different fill.

Groups

Give modals a shared data-modalgroup="name" and the script adds prev / next arrows and wires the arrow keys. Stepping between grouped modals is a hard cut — the open / close animation only runs for a standalone modal. A nested modal (opened from inside another, outside a group) isn't supported: close one first.

Animation

Opening slides up + scales in over --jf-transition (@starting-style); closing is a slower opacity dissolve (--jf-duration-slow) held with allow-discrete — the fade-out needs the overlay property, so Firefox cuts straight to closed while Chromium and Safari fade. prefers-reduced-motion makes both instant. Retune by redeclaring --jf-transition / --jf-duration-slow on .modal.

Events

jfModalOpened and jfModalClosed fire on document however the modal opened or closed.


    document.addEventListener("jfModalOpened", (e) => {
      e.detail; // { newModalId, prevModalId, nextModalId }
    });
    document.addEventListener("jfModalClosed", (e) => {
      e.detail; // { closedModalId }
    });
  

Every open and close also pushes to window.dataLayer (GTM), if it exists — a different, richer shape than the event detail above, not a copy of it:


    // on open
    { event: "modalOpened", modalId: "#demo", modalTitle: "Modal title" }
    // on close
    { event: "modalClosed", modalId: "#demo", modalTitle: "Modal title", timeOpen: 12.4 }
  

modalId is #-prefixed; modalTitle resolves to a data-title on the <dialog>, then its first heading, then its first non-empty text. timeOpen (seconds, close only) has no equivalent on the jfModalClosed event above — it's dataLayer-only.

Overriding

  • One modal — set the --jf-modal-* custom properties (tabled below) on .modal, or --jf-color-surface for a different fill.
  • Everywhere — the Sass variables below, before import.

Variables

Sass variables

Modal Sass variables
NameDefaultNotes
$modal-max-width900px--jf-modal-max-width. Actual width is min(that, 100vw − 2 × outer-margin).
$modal-paddingtoken(space-md)--jf-modal-padding. Applied to .modal-header / .modal-content / .modal-footer.
$modal-outer-marginget-size(3) (48px)Minimum gap between the dialog and the viewport edge.
$modal-bordernoneBorder on the dialog panel.
$modal-border-radiustoken(radius-md)Panel corner radius.
$modal-backdrop-colortoken(color-black)The ::backdrop fill. Baked at build — ::backdrop reads no custom properties, so this is a Sass value only.
$modal-backdrop-opacity0.9Opacity of that backdrop. Also build-time.
$modal-close-icon-svgan × glyphData-URI for the injected close button. Uses currentColor, so it follows the button text colour.
$modal-arrow-icon-svga chevronData-URI for the group prev / next arrows.
$modal-navigation-arrow-sizeget-size(2) (32px)Size of the group prev / next buttons.

Custom properties

Modal custom properties
NameDefaultNotes
--jf-modal-max-width$modal-max-widthDeclared on dialog.modal — override it there or on a modal-specific class, not :root.
--jf-modal-padding$modal-paddingAs above.
--jf-color-surfacevar(--jf-color-bg-soft)The dialog is its own surface at the .bg-soft level. Repoint this for a different fill rather than adding a .bg-* class.