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
| Key | Action |
|---|---|
| Esc | Close the modal; focus returns to the trigger (native). |
| Tab / Shift+Tab | Move between focusable elements, trapped inside the dialog (native). |
| Enter / Space | Activate 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
| Name | Default | Notes |
|---|---|---|
| $modal-max-width | 900px | → --jf-modal-max-width. Actual width is min(that, 100vw − 2 × outer-margin). |
| $modal-padding | token(space-md) | → --jf-modal-padding. Applied to .modal-header / .modal-content / .modal-footer. |
| $modal-outer-margin | get-size(3) (48px) | Minimum gap between the dialog and the viewport edge. |
| $modal-border | none | Border on the dialog panel. |
| $modal-border-radius | token(radius-md) | Panel corner radius. |
| $modal-backdrop-color | token(color-black) | The ::backdrop fill. Baked at build — ::backdrop reads no custom properties, so this is a Sass value only. |
| $modal-backdrop-opacity | 0.9 | Opacity of that backdrop. Also build-time. |
| $modal-close-icon-svg | an × glyph | Data-URI for the injected close button. Uses currentColor, so it follows the button text colour. |
| $modal-arrow-icon-svg | a chevron | Data-URI for the group prev / next arrows. |
| $modal-navigation-arrow-size | get-size(2) (32px) | Size of the group prev / next buttons. |
Custom properties
| Name | Default | Notes |
|---|---|---|
| --jf-modal-max-width | $modal-max-width | Declared on dialog.modal — override it there or on a modal-specific class, not :root. |
| --jf-modal-padding | $modal-padding | As above. |
| --jf-color-surface | var(--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. |