Modal
Native <dialog> styling. The framework supplies the look
and ships two open mechanisms: showModal() (the full modal
contract, requires one line of JS) and :target (zero JS, with
documented trade-offs).
Live
Interactive: JS-free (:target)
<p>
<a class="button" href="#demo-modal-jsfree-dialog">Open (no JS)</a>
</p>
<dialog class="modal" id="demo-modal-jsfree-dialog">
<a class="modal__dismiss" href="#" aria-label="Close"></a>
<div class="modal__content">
<header class="modal__header">
<h3 style="margin: 0;">Read-only notice</h3>
<a href="#" aria-label="Close" class="button" style="font-size: 1.25rem; line-height: 1; padding: 0.25rem 0.5rem; min-width: 0;">×</a>
</header>
<div class="modal__body">
<p>
This dialog opens via <code>:target</code>. Clicking the
tinted area outside the panel navigates the URL hash away,
which closes the modal. <strong>Caveat:</strong> Escape does
nothing; focus is not trapped; the page behind is not
<code>inert</code>.
</p>
</div>
<footer class="modal__footer">
<a class="button" href="#">Close</a>
</footer>
</div>
</dialog> @use '@adnap/krysalicss' with (
$feature-list: ('base-reset', 'base-global', 'module-modal')
); Interactive: JS-enhanced (showModal())
<p>
<button class="button" type="button" onclick="document.getElementById('demo-modal-js-dialog').showModal()">Open (with JS)</button>
</p>
<dialog class="modal" id="demo-modal-js-dialog">
<form method="dialog" class="modal__content">
<header class="modal__header">
<h3 style="margin: 0;">Confirm action</h3>
<button type="button" aria-label="Close" formnovalidate
onclick="this.closest('dialog').close()"
style="font-size: 1.25rem; line-height: 1; padding: 0.25rem 0.5rem; min-width: 0;">×</button>
</header>
<div class="modal__body">
<p>
This dialog opens via <code>dialog.showModal()</code>. Escape
closes it, focus is trapped inside, the page behind is marked
<code>inert</code>. Clicking outside the dialog also closes it
(wired below). Closing returns the button's
<code>value</code> via <code>dialog.returnValue</code>.
</p>
</div>
<footer class="modal__footer">
<button value="cancel">Cancel</button>
<button value="ok" class="button is-primary">Overwrite</button>
</footer>
</form>
</dialog> @use '@adnap/krysalicss' with (
$feature-list: ('base-reset', 'base-global', 'module-modal')
); Open mechanisms
| Capability | dialog.showModal() | :target fragment |
|---|---|---|
| JS required | One call: dialog.showModal() | None |
| Visual backdrop | Yes (UA paints ::backdrop) | Yes (framework emulates via ::before) |
| Background scroll lock | Yes (UA inerts the page) | Yes (framework: html:has(.modal:target) { overflow: hidden; }) |
| Focus trapped inside dialog | Yes (UA-managed) | No (CSS cannot move focus) |
Background marked inert (clicks/tabs blocked) | Yes | No (CSS cannot suppress events on siblings) |
| Escape key closes | Yes | No (CSS has no key listener) |
| Click-outside closes | Optional (consumer wires) | Optional: include <a href="#" class="modal__dismiss"> |
| Close from inside | <form method="dialog"> submit, dialog.close() | Any link to a different fragment, e.g. <a href="#"> |
| Suitable for a11y-critical confirmations | Yes | No (use showModal()) |
Pick deliberately.
The :target path is JS-free but is not a true modal: focus
is not trapped, the background is not inert, and Escape
does not close. Screen-reader and keyboard users can interact with the
page behind it. Use :target for read-only notices, link
cards, or non-blocking surfaces; reserve showModal() for
decisions that must own the user's attention until resolved.
Markup: showModal() + method="dialog"
<dialog class="modal" id="confirm">
<form method="dialog" class="modal__content">
<header class="modal__header">
<h2>Confirm action</h2>
<button type="button" aria-label="Close" formnovalidate
onclick="this.closest('dialog').close()">×</button>
</header>
<div class="modal__body">
<p>This will overwrite your saved changes.</p>
</div>
<footer class="modal__footer">
<button value="cancel">Cancel</button>
<button value="ok" class="button is-primary">Overwrite</button>
</footer>
</form>
</dialog>
<button onclick="document.getElementById('confirm').showModal()">
Open modal
</button>
<script>
// Close on click outside the dialog content. `<dialog>` dispatches the
// click on the dialog itself when the user clicks the backdrop area; the
// bounding-rect check avoids closing on clicks inside the dialog padding.
document.getElementById('confirm').addEventListener('click', (event) => {
const dialog = event.currentTarget;
if (event.target !== dialog) return;
const rect = dialog.getBoundingClientRect();
const inside = event.clientY >= rect.top && event.clientY <= rect.bottom
&& event.clientX >= rect.left && event.clientX <= rect.right;
if (!inside) dialog.close();
});
</script> Markup: :target fallback
The optional <a class="modal__dismiss"> first child is the
click-outside-to-close target. Omit it if you want the user to close only
via the explicit "Close" link.
<a href="#info">Open</a>
<dialog class="modal" id="info">
<!-- Optional: click-outside-to-close. -->
<a class="modal__dismiss" href="#" aria-label="Close"></a>
<div class="modal__content">
<header class="modal__header">
<h2>Read-only notice</h2>
<a href="#" aria-label="Close" class="button">×</a>
</header>
<div class="modal__body">
<p>This dialog opens via the URL fragment. Close by clicking outside, by following another link, or by activating "Close" below.</p>
</div>
<footer class="modal__footer">
<a href="#" class="button">Close</a>
</footer>
</div>
</dialog> Scope
- Confirmations, destructive-action warnings, focused single-task forms.
- Inline page notices that should not trap focus:
Alert (rendered in flow, not a
<dialog>).
Accessibility
- A visible close button is required inside
.modal__headeror.modal__footerfor every:target-driven dialog. The.modal__dismissclick-outside anchor is click-only — there is no keyboard equivalent for the backdrop area, so keyboard users dismiss the modal exclusively through the explicit Close button. - The
:targetmechanism cannot trap focus, mark the page behindinert, or listen forEscape. For full keyboard accessibility (focus trap, ESC-to-close, background inertness) usedialog.showModal()— the browser-native path emitted by the'js'control mode. - Label the close control with
aria-label="Close"so screen readers announce its purpose when the icon (×) carries no text.
Variables
| Variable | Default | Notes |
|---|---|---|
$selector | '.modal' | Applied to <dialog>. |
$content-selector | '.modal__content' | Inner flex container. |
$header-selector | '.modal__header' | Title row. |
$body-selector | '.modal__body' | Prose body. |
$footer-selector | '.modal__footer' | Action row, right-aligned. |
$dismiss-selector | '.modal__dismiss' | Optional click-outside anchor inside :target-opened dialogs. |
$max-width | 32rem | Hard cap on dialog width. |
$padding | $size-normal | Surface padding. |
$gap | $size-small (0.75rem) | Vertical gap between header / body / footer slots inside .modal__content. |
$border | 0 | Resets the Chromium <dialog> UA border so theming owns the surface edge. |
$border-radius | $global-border-radius | Surface radius. |
$backdrop-color | rgba(0,0,0,0.55) | Fallback tint when no theme provides --kc-modal-backdrop. Applied via ::backdrop (showModal) and ::before (:target). |
$backdrop-blur | 6px | Blur applied to content behind the backdrop. Set to 0 to disable. |
See Combinations and light variants for $enable-light, $default-combination, and $controls — cross-cutting plumbing common to every theme-aware component.
Override example
@use '@adnap/krysalicss/module/modal' with (
$max-width: 40rem,
$padding: 1.5rem,
$backdrop-color: rgba(0, 0, 0, 0.7),
); Tokens consumed
| Token | Used for |
|---|---|
--kc-modal-bg / --kc-modal-fg | Default surface. |
--kc-modal-backdrop | Backdrop tint, swapped per theme. Default theme leaves it unset (falls back to $backdrop-color); the dark theme emits a translucent-light wash so the dark surface stands out. |