Canvas Design System
Main Site Tokens

Modal

Interrupts the page for one focused task, trapping keyboard focus until it's resolved.

When to Use

Use when: A task must be finished or abandoned before the page continues — confirming a destructive action, a short focused form, a detail view that would otherwise lose the reader's place.
Don't use when: The content belongs on a page of its own, or the message needs no decision — use Toast or Alert. Never stack one modal on another.

Variants

Sizes

Five widths. Pick the smallest that fits — a large modal holding one sentence reads as an error.

SizeUse for
smConfirmations and single-question prompts
defaultMost cases — a short form or a paragraph with actions
lgMulti-field forms
xlDense content such as a picker with a preview
fullNear-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

StateBehavior
ClosedDefault. The overlay is in the DOM without .is-open and hidden by CSS — markup renders inline on the page, wherever you placed it.
OpenwsModalOpen(id) adds .is-open, sets body { overflow: hidden } to stop background scroll, engages the focus trap, and fires a bubbling ws-modal-open event.
ClosingwsModalClose(id) reverses all of that, releases the trap, and fires ws-modal-close.
Backdrop clickCloses when backdrop is true — wired at DOM-ready via data-close-on-click, and only when the click target is the overlay itself.
EscapeA document-level handler closes whichever overlay has .is-open, unless it carries data-closable="false".
Not closableclosable: 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.
info Both events bubble, so a single listener on 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.

OptionTypeDefaultPurpose
idstring'ws-modal-' . uniqid()Effectively required — the JS API targets it. Auto-generated IDs can't be reopened.
titlestringnullHeader title. Also the modal's accessible name via aria-labelledby.
subtitlestringnullLine below the title. HTML allowed, so escape any user data.
iconstringnullMaterial icon in a pill beside the title
sizestring'default'sm | default | lg | xl | full
variantstring'default'default | drawer | bottom-sheet
closablebooltrueShow the close button. Spelled closable, not closeable — this page previously documented the wrong spelling, which is silently ignored.
backdropbooltrueClose on backdrop click
headerColorstringnullCustom header background
classstring''Additional CSS classes
attrsarray[]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.

ConcernBehavior
ARIAThe 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 trapImplemented. 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 focusFocus 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.
EscapeCloses the open overlay from a document-level handler, honouring closable.
Focus returnImplemented. 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 scrollLocked via body { overflow: hidden } while open.
check_circle Fixed July 2026, covered by 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.
warning Still open: background content is not inert. No 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

TokenUsed for
--modal-bg / --modal-radius / --modal-shadowDialog surface, corners, and elevation
--modal-backdropOverlay scrim
--z-modal / --z-modal-backdropStacking order
--border-default / --gray-100 / --gray-200Header and footer dividers
--gray-300 / --gray-500Close button, resting and hover
--text-dark / --text-inverseTitle, and title on a coloured header
--text-h2 / --text-h4 / --text-metaTitle and subtitle type scale
--font-heading / --font-semiboldFraunces title and its weight
--radius-2xl / --radius-mdDialog and icon-pill corners
--space-2 / --space-3Header and footer internal spacing
--transition-fast / --transition-slowClose-button hover, and the open/close animation

CSS Classes

ClassPurpose
.ws-modal-overlayBackdrop and positioning layer. Its ID is {id}-overlay — the node the JS toggles.
.ws-modal-overlay--drawerDrawer-variant overlay
.ws-modal-overlay.is-openOpen state
.ws-modalThe dialog — carries role="dialog"
.ws-modal--sm / --default / --lg / --xl / --fullSize modifiers
.ws-modal--drawer / --bottom-sheetPresentation variants
.ws-modal__headerHeader bar
.ws-modal__header--illustratedHeader with artwork
.ws-modal__title-wrap / __title / __subtitleTitle block
.ws-modal__iconIcon pill beside the title
.ws-modal__closeClose button
.ws-modal__bodyScrollable content region
.ws-modal__footer / --between / --startFooter and its alignment modifiers

Files

FilePurpose
includes/components/helpers.phpws_modal_start(), ws_modal_end(), ws_modal()
includes/components/modal.phpOpening markup — overlay, dialog, header, ARIA
includes/components/modal-close.phpClosing markup emitted by ws_modal_end()
includes/components/components.cssStyles (.ws-modal rules)
includes/components/components.jswsModalOpen/Close/Toggle(), wsTrapFocus(), wsReleaseFocus(), wsFocusWhenVisible(), and the Esc/backdrop handlers
tests/modal-focus.spec.jsPlaywright coverage for initial focus, focus return, and Esc with closable: false