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/reset and @shift-css/core/tokens for the base styles and design tokens.

Basic Usage

Basic modal

Modal Title

Your content goes here. Click outside or press Escape to close.

<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()">&times;</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

Small Modal

Best for confirmations and simple alerts. Max width: 400px.

Default Modal

Standard dialog size for most use cases. Max width: 500px.

Large Modal

For forms and detailed content. Max width: 700px.

This size works well when you need more horizontal space for complex layouts or data tables.

Full Screen Modal

Takes up the entire viewport. Perfect for immersive experiences or complex workflows.

<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()">&times;</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()">&times;</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()">&times;</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()">&times;</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

Centered Modal

Default position - centered in the viewport.

Top Modal

Anchored to the top of the viewport.

Bottom Modal

Anchored to the bottom of the viewport.

Left Modal

Anchored to the left side of the viewport. Great for sidebars or navigation panels.

Right Modal

Anchored to the right side of the viewport. Perfect for detail panels or settings.

<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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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

Contains the title and close button:

<header s-modal-header>
  <h2>Title</h2>
  <button s-modal-close aria-label="Close">&times;</button>
</header>

Body

Scrollable content area:

<div s-modal-body>
  <p>Content that scrolls when it overflows.</p>
</div>

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">&times;</button>

Form Integration

Use method="dialog" on a form to automatically close the modal when submitted:

Form modal

Contact Form

<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()">&times;</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">&times;</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 using showModal()
  • 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)
<!-- 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">&times;</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-labelledby pointing to the modal title
  • Use aria-describedby for 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

PropertyDescription
--_modal-bgBackground color (private)
--_modal-borderBorder color (private)
--_modal-radiusBorder radius (private)
--_modal-shadowBox shadow (private)
--_modal-paddingInternal padding (private)
--_modal-max-widthMaximum width (private)

All Attributes

AttributePurposeValues
s-modalModal containerBoolean
s-sizeModal size variantsm, lg, full
s-positionModal positiontop, bottom, left, right (centered by default)
s-modal-headerHeader sectionBoolean
s-modal-bodyScrollable bodyBoolean
s-modal-footerFooter with actionsBoolean
s-modal-closeClose buttonBoolean

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);
}
Search