Modal
CSS-only modal dialog with backdrop, size variants, and entrance animations
Overview
The s-modal attribute provides a CSS-only modal component using the native <dialog> element. It includes backdrop styling, size variants, entrance animations, and full accessibility support.
Import
/* Full framework (includes all components) */
@import "@shift-css/core";
/* Or import just the modal component */
@import "@shift-css/core/components/modal";
Note: When importing individual components, you also need
@shift-css/core/resetand@shift-css/core/tokensfor the base styles and design tokens.
Basic Usage
Basic modal
<button s-btn="primary" onclick="document.getElementById('demo-basic').showModal()">
Open Modal
</button>
<dialog s-modal id="demo-basic">
<header s-modal-header>
<h2>Modal Title</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Your content goes here. Click outside or press Escape to close.</p>
</div>
<footer s-modal-footer>
<button s-btn="secondary" onclick="this.closest('dialog').close()">Cancel</button>
<button s-btn="primary" onclick="this.closest('dialog').close()">Confirm</button>
</footer>
</dialog> Open the modal with JavaScript:
document.querySelector('[s-modal]').showModal();
Size Variants
Size variants
<div class="demo-row">
<button s-btn onclick="document.getElementById('demo-sm').showModal()">Small (sm)</button>
<button s-btn onclick="document.getElementById('demo-default').showModal()">Default</button>
<button s-btn onclick="document.getElementById('demo-lg').showModal()">Large (lg)</button>
<button s-btn onclick="document.getElementById('demo-full').showModal()">Full Screen</button>
</div>
<dialog s-modal s-size="sm" id="demo-sm">
<header s-modal-header>
<h2>Small Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Best for confirmations and simple alerts. Max width: 400px.</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" onclick="this.closest('dialog').close()">Got it</button>
</footer>
</dialog>
<dialog s-modal id="demo-default">
<header s-modal-header>
<h2>Default Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Standard dialog size for most use cases. Max width: 500px.</p>
</div>
<footer s-modal-footer>
<button s-btn="secondary" onclick="this.closest('dialog').close()">Cancel</button>
<button s-btn="primary" onclick="this.closest('dialog').close()">Confirm</button>
</footer>
</dialog>
<dialog s-modal s-size="lg" id="demo-lg">
<header s-modal-header>
<h2>Large Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>For forms and detailed content. Max width: 700px.</p>
<p>This size works well when you need more horizontal space for complex layouts or data tables.</p>
</div>
<footer s-modal-footer>
<button s-btn="secondary" onclick="this.closest('dialog').close()">Cancel</button>
<button s-btn="primary" onclick="this.closest('dialog').close()">Save Changes</button>
</footer>
</dialog>
<dialog s-modal s-size="full" id="demo-full">
<header s-modal-header>
<h2>Full Screen Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Takes up the entire viewport. Perfect for immersive experiences or complex workflows.</p>
</div>
<footer s-modal-footer>
<button s-btn="secondary" onclick="this.closest('dialog').close()">Exit</button>
<button s-btn="primary" onclick="this.closest('dialog').close()">Continue</button>
</footer>
</dialog> Position Variants
Modals are centered by default (no attribute needed). Use s-position to anchor them to a specific edge:
Position variants
<div class="demo-row">
<button s-btn onclick="document.getElementById('demo-center').showModal()">Center (default)</button>
<button s-btn onclick="document.getElementById('demo-top').showModal()">Top</button>
<button s-btn onclick="document.getElementById('demo-bottom').showModal()">Bottom</button>
<button s-btn onclick="document.getElementById('demo-left').showModal()">Left</button>
<button s-btn onclick="document.getElementById('demo-right').showModal()">Right</button>
</div>
<dialog s-modal id="demo-center">
<header s-modal-header>
<h2>Centered Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Default position - centered in the viewport.</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" onclick="this.closest('dialog').close()">Close</button>
</footer>
</dialog>
<dialog s-modal s-position="top" id="demo-top">
<header s-modal-header>
<h2>Top Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Anchored to the top of the viewport.</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" onclick="this.closest('dialog').close()">Close</button>
</footer>
</dialog>
<dialog s-modal s-position="bottom" id="demo-bottom">
<header s-modal-header>
<h2>Bottom Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Anchored to the bottom of the viewport.</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" onclick="this.closest('dialog').close()">Close</button>
</footer>
</dialog>
<dialog s-modal s-position="left" id="demo-left">
<header s-modal-header>
<h2>Left Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Anchored to the left side of the viewport. Great for sidebars or navigation panels.</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" onclick="this.closest('dialog').close()">Close</button>
</footer>
</dialog>
<dialog s-modal s-position="right" id="demo-right">
<header s-modal-header>
<h2>Right Modal</h2>
<button s-modal-close aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<p>Anchored to the right side of the viewport. Perfect for detail panels or settings.</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" onclick="this.closest('dialog').close()">Close</button>
</footer>
</dialog> Sub-Components
Header
Contains the title and close button:
<header s-modal-header>
<h2>Title</h2>
<button s-modal-close aria-label="Close">×</button>
</header>
Body
Scrollable content area:
<div s-modal-body>
<p>Content that scrolls when it overflows.</p>
</div>
Footer
Action buttons, typically right-aligned:
<footer s-modal-footer>
<button s-btn="secondary" formmethod="dialog">Cancel</button>
<button s-btn="primary">Save</button>
</footer>
Close Button
Circular button with hover states:
<button s-modal-close aria-label="Close">×</button>
Form Integration
Use method="dialog" on a form to automatically close the modal when submitted:
Form modal
<button s-btn="primary" onclick="document.getElementById('demo-form').showModal()">
Open Form Modal
</button>
<dialog s-modal id="demo-form">
<form method="dialog">
<header s-modal-header>
<h2>Contact Form</h2>
<button s-modal-close aria-label="Close" type="button" onclick="this.closest('dialog').close()">×</button>
</header>
<div s-modal-body>
<div class="demo-stack">
<div>
<label s-field-label for="form-name">Name</label>
<input s-input type="text" id="form-name" placeholder="Your name">
</div>
<div>
<label s-field-label for="form-email">Email</label>
<input s-input type="email" id="form-email" placeholder="you@example.com">
</div>
<div>
<label s-field-label for="form-message">Message</label>
<textarea s-input id="form-message" rows="3" placeholder="Your message"></textarea>
</div>
</div>
</div>
<footer s-modal-footer>
<button s-btn="secondary" value="cancel">Cancel</button>
<button s-btn="primary" value="submit">Send Message</button>
</footer>
</form>
</dialog> Scrollable Content
Long content automatically scrolls within the body:
<dialog s-modal>
<header s-modal-header>
<h2>Terms of Service</h2>
<button s-modal-close aria-label="Close">×</button>
</header>
<div s-modal-body>
<!-- Long content scrolls here -->
<p>Lorem ipsum...</p>
<p>More content...</p>
</div>
<footer s-modal-footer>
<button s-btn="primary" formmethod="dialog">Accept</button>
</footer>
</dialog>
JavaScript API
The native <dialog> element provides built-in methods:
const modal = document.querySelector('[s-modal]');
// Open as modal (with backdrop)
modal.showModal();
// Open as non-modal dialog
modal.show();
// Close the modal
modal.close();
// Close with a return value
modal.close('confirmed');
// Listen for close
modal.addEventListener('close', () => {
console.log('Modal closed with:', modal.returnValue);
});
// Listen for cancel (ESC key)
modal.addEventListener('cancel', (e) => {
// Optionally prevent ESC from closing
// e.preventDefault();
});
Accessibility
The native <dialog> element provides:
- Focus trapping: Focus stays within the modal when open
- ESC to close: Press Escape to dismiss the modal
aria-modal: Automatically set when usingshowModal()- Inert background: Content behind the modal is not interactive
- Focus restoration: Focus returns to the trigger element on close
WCAG 2.1 Compliance
Shift CSS modals include:
- Touch targets: Close button meets 44×44px minimum (WCAG 2.5.5)
- Focus indicators: Visible focus ring for keyboard navigation (WCAG 2.4.7)
- Reduced motion: Animations respect
prefers-reduced-motion(WCAG 2.3.3) - High contrast: Supports Windows High Contrast Mode (WCAG 1.4.11)
Recommended Markup
<!-- Full accessible modal example -->
<dialog
s-modal
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<header s-modal-header>
<h2 id="modal-title">Accessible Title</h2>
<button s-modal-close aria-label="Close modal">×</button>
</header>
<div s-modal-body>
<p id="modal-description">
This description helps screen reader users understand the modal's purpose.
</p>
<!-- Additional content -->
</div>
<footer s-modal-footer>
<button s-btn="secondary" type="submit" formmethod="dialog">Cancel</button>
<button s-btn="primary" type="submit">Confirm</button>
</footer>
</dialog>
Screen Reader Tips
- Always include
aria-labelledbypointing to the modal title - Use
aria-describedbyfor modals with important context - Ensure the close button has
aria-label="Close modal"or similar
Animation
Modals animate in with a scale and fade effect:
@keyframes s-modal-scale-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
Animation respects prefers-reduced-motion (handled globally in reset.css).
How It Works
Modals use the Layered Intent pattern with :where() for zero-specificity base styles:
:where(dialog[s-modal]) {
--_modal-bg: var(--s-surface-raised);
--_modal-border: var(--s-border-muted);
--_modal-radius: var(--s-radius-xl);
--_modal-shadow: var(--s-shadow-2xl);
--_modal-padding: var(--s-space-4);
--_modal-max-width: 32rem;
background-color: var(--_modal-bg);
border-radius: var(--_modal-radius);
box-shadow: var(--_modal-shadow);
/* Auto-contrast text */
@supports (color: oklch(from red l c h)) {
color: oklch(
from var(--_modal-bg)
clamp(0.15, calc((0.6 - l) * 1000 + 0.15), 0.95) 0 0
);
}
}
CSS Custom Properties
| Property | Description |
|---|---|
--_modal-bg | Background color (private) |
--_modal-border | Border color (private) |
--_modal-radius | Border radius (private) |
--_modal-shadow | Box shadow (private) |
--_modal-padding | Internal padding (private) |
--_modal-max-width | Maximum width (private) |
All Attributes
| Attribute | Purpose | Values |
|---|---|---|
s-modal | Modal container | Boolean |
s-size | Modal size variant | sm, lg, full |
s-position | Modal position | top, bottom, left, right (centered by default) |
s-modal-header | Header section | Boolean |
s-modal-body | Scrollable body | Boolean |
s-modal-footer | Footer with actions | Boolean |
s-modal-close | Close button | Boolean |
Customization Examples
Custom Max Width
dialog[s-modal] {
--_modal-max-width: 40rem;
}
Remove Border Radius
dialog[s-modal] {
--_modal-radius: 0;
}
Custom Backdrop
dialog[s-modal]::backdrop {
background: oklch(0.2 0.05 280 / 0.8);
backdrop-filter: blur(8px);
}
Slide-in Animation
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
dialog[s-modal][open] {
animation: slide-in var(--s-duration-300) var(--s-ease-out);
}