Modal
Interrupts the page for one focused task, trapping keyboard focus until it's resolved.
When to Use
Variants
Sizes
Five widths. Pick the smallest that fits — a large modal holding one sentence reads as an error.
| Size | Use for |
|---|---|
sm | Confirmations and single-question prompts |
default | Most cases — a short form or a paragraph with actions |
lg | Multi-field forms |
xl | Dense content such as a picker with a preview |
full | Near-fullscreen. Consider whether this should be a page instead. |
<?php ws_modal_start(['id' => 'my-modal', 'title' => 'Modal Title', 'size' => 'lg']); ?>
<p>Modal content here</p>
<?php ws_modal_end(); ?>Presentation
Beyond the centred dialog, variant offers drawer (slides from the edge) and bottom-sheet (rises from the bottom, suited to touch).
<?php ws_modal_start(['id' => 'filters', 'title' => 'Filters', 'variant' => 'drawer']); ?>
Coloured header
Sets a custom header background. Pass a token reference rather than a literal hex so the modal follows the palette.
<?php ws_modal_start([
'id' => 'planner-modal',
'title' => 'Save Workshop',
'headerColor' => 'var(--phase-learn)',
]); ?>Title adornments
An icon renders in a pill beside the title, and subtitle adds a line beneath it. The subtitle accepts HTML for inline emphasis.
<?php ws_modal_start([
'id' => 'import',
'title' => 'Import session',
'subtitle' => 'From <strong>Q3 Planning Offsite</strong>',
'icon' => 'hub',
]); ?>States
| State | Behavior |
|---|---|
| Closed | Default. The overlay is in the DOM without .is-open and hidden by CSS — markup renders inline on the page, wherever you placed it. |
| Open | wsModalOpen(id) adds .is-open, sets body { overflow: hidden } to stop background scroll, engages the focus trap, and fires a bubbling ws-modal-open event. |
| Closing | wsModalClose(id) reverses all of that, releases the trap, and fires ws-modal-close. |
| Backdrop click | Closes when backdrop is true — wired at DOM-ready via data-close-on-click, and only when the click target is the overlay itself. |
| Escape | A document-level handler closes whichever overlay has .is-open, unless it carries data-closable="false". |
| Not closable | closable: false hides the close button and blocks Esc. Backdrop dismissal is controlled separately by backdrop — set both to false for a modal that can only be resolved by acting. |
document can track modal usage rather than wiring each one.
Real-World Usage
A destructive confirmation: small size, the consequence stated plainly in the body, and a Modal Footer putting the danger action on the right.
<?php ws_modal_start([
'id' => 'confirm-delete',
'title' => 'Delete this workshop?',
'size' => 'sm',
]); ?>
<p><strong>Q3 Planning Offsite</strong> and its 12 agenda items
will be permanently removed. This cannot be undone.</p>
<?= ws_modal_footer([
'cancel' => ['text' => 'Keep it'],
'primary' => ['text' => 'Delete workshop', 'class' => 'ws-btn--danger'],
]) ?>
<?php ws_modal_end(); ?>Options
Content goes between ws_modal_start() and ws_modal_end(). ws_modal($content, $options) is the same thing for a string.
| Option | Type | Default | Purpose |
|---|---|---|---|
id | string | 'ws-modal-' . uniqid() | Effectively required — the JS API targets it. Auto-generated IDs can't be reopened. |
title | string | null | Header title. Also the modal's accessible name via aria-labelledby. |
subtitle | string | null | Line below the title. HTML allowed, so escape any user data. |
icon | string | null | Material icon in a pill beside the title |
size | string | 'default' | sm | default | lg | xl | full |
variant | string | 'default' | default | drawer | bottom-sheet |
closable | bool | true | Show the close button. Spelled closable, not closeable — this page previously documented the wrong spelling, which is silently ignored. |
backdrop | bool | true | Close on backdrop click |
headerColor | string | null | Custom header background |
class | string | '' | Additional CSS classes |
attrs | array | [] | Extra HTML attributes |
JavaScript API
wsModalOpen('my-modal'); // open
wsModalClose('my-modal'); // close
wsModalToggle('my-modal'); // toggle
document.addEventListener('ws-modal-open', e => { /* e.target is the overlay */ });
document.addEventListener('ws-modal-close', e => { /* … */ });Accessibility
Partially conformant with the WAI-ARIA Dialog (Modal) pattern. The table below reflects what the code actually does — two commonly-assumed behaviours are missing.
| Concern | Behavior |
|---|---|
| ARIA | The dialog carries role="dialog", aria-modal="true", and aria-labelledby pointing at the title element. The close button is labelled aria-label="Close modal". |
| Focus trap | Implemented. wsTrapFocus() collects focusable elements and wraps Tab and Shift+Tab at the ends. The listener is removed on close, and re-binding is guarded against duplicates. |
| Initial focus | Focus moves to the first focusable element inside the modal once the open transition settles. If there are none, the trap exits early and focus stays put. |
| Escape | Closes the open overlay from a document-level handler, honouring closable. |
| Focus return | Implemented. The element focused before opening is restored on close, so keyboard users resume where they were rather than at the top of the page. |
| Background scroll | Locked via body { overflow: hidden } while open. |
tests/modal-focus.spec.js:
(1) Initial focus now actually enters the dialog. It never did: the overlay animates in from
visibility: hidden, and an unrendered element can't take focus, so the focus() call was a silent no-op and focus stayed on the trigger — outside the trap. wsFocusWhenVisible() now waits for the transition to settle. Waiting a single frame isn't enough; the overlay reads visible while still mid-fade and only becomes focusable once painted.
(2) Focus returns to the trigger.
wsModalOpen() records document.activeElement and wsModalClose() restores it, guarded on isConnected in case the trigger was removed meanwhile.
(3)
closable: false blocks Esc. The template emits data-closable="false" and the handler skips those overlays.
inert or aria-hidden is applied outside the dialog, so a screen reader can still browse the page behind it — the focus trap covers Tab but not virtual-cursor navigation. Fixing it properly means marking siblings inert on open and restoring them on close, which touches page structure rather than the component alone.
Tokens
| Token | Used for |
|---|---|
--modal-bg / --modal-radius / --modal-shadow | Dialog surface, corners, and elevation |
--modal-backdrop | Overlay scrim |
--z-modal / --z-modal-backdrop | Stacking order |
--border-default / --gray-100 / --gray-200 | Header and footer dividers |
--gray-300 / --gray-500 | Close button, resting and hover |
--text-dark / --text-inverse | Title, and title on a coloured header |
--text-h2 / --text-h4 / --text-meta | Title and subtitle type scale |
--font-heading / --font-semibold | Fraunces title and its weight |
--radius-2xl / --radius-md | Dialog and icon-pill corners |
--space-2 / --space-3 | Header and footer internal spacing |
--transition-fast / --transition-slow | Close-button hover, and the open/close animation |
CSS Classes
| Class | Purpose |
|---|---|
.ws-modal-overlay | Backdrop and positioning layer. Its ID is {id}-overlay — the node the JS toggles. |
.ws-modal-overlay--drawer | Drawer-variant overlay |
.ws-modal-overlay.is-open | Open state |
.ws-modal | The dialog — carries role="dialog" |
.ws-modal--sm / --default / --lg / --xl / --full | Size modifiers |
.ws-modal--drawer / --bottom-sheet | Presentation variants |
.ws-modal__header | Header bar |
.ws-modal__header--illustrated | Header with artwork |
.ws-modal__title-wrap / __title / __subtitle | Title block |
.ws-modal__icon | Icon pill beside the title |
.ws-modal__close | Close button |
.ws-modal__body | Scrollable content region |
.ws-modal__footer / --between / --start | Footer and its alignment modifiers |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_modal_start(), ws_modal_end(), ws_modal() |
includes/components/modal.php | Opening markup — overlay, dialog, header, ARIA |
includes/components/modal-close.php | Closing markup emitted by ws_modal_end() |
includes/components/components.css | Styles (.ws-modal rules) |
includes/components/components.js | wsModalOpen/Close/Toggle(), wsTrapFocus(), wsReleaseFocus(), wsFocusWhenVisible(), and the Esc/backdrop handlers |
tests/modal-focus.spec.js | Playwright coverage for initial focus, focus return, and Esc with closable: false |