A full-height modal variant that slides in from the right side of the screen. Extends modal behavior with slide animation, responsive sizing, and structured layout.
<!-- Trigger Button -->
<button
data-panel="unique-panel-id"
role="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="unique-panel-id">
Open Panel
</button>
<!-- Panel Dialog -->
<dialog
id="unique-panel-id"
class="elv-slide-out-panel elv-slide-out-panel--half"
aria-modal="true"
role="dialog">
<div class="elv-slide-out-panel__inner">
<!-- Header -->
<div class="elv-slide-out-panel__header">
<div class="elv-slide-out-panel__header-top">
<div>
<h2 class="elv-slide-out-panel__title">Panel Title</h2>
<p class="elv-slide-out-panel__subtitle">Optional subtitle or description</p>
</div>
<button
class="elv-slide-out-panel__close"
aria-label="Close panel">
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
<!-- Body -->
<div class="elv-slide-out-panel__body">
<p>Panel content goes here. This area is scrollable when content overflows.</p>
</div>
<!-- Footer (optional) -->
<div class="elv-slide-out-panel__footer">
<button class="btn btn--secondary">Cancel</button>
<button class="btn btn--primary">Save Changes</button>
</div>
</div>
</dialog>
<!-- Third Screen: 33% on desktop, 50% on tablet, 100% on mobile -->
<dialog class="elv-slide-out-panel elv-slide-out-panel--third">...</dialog>
<!-- Half Screen (default): 50% on tablet+, 100% on mobile -->
<dialog class="elv-slide-out-panel elv-slide-out-panel--half">...</dialog>
<!-- Full Screen: 100% on all viewports -->
<dialog class="elv-slide-out-panel elv-slide-out-panel--full">...</dialog>
Vanilla JavaScript implementation with focus trap, focus restoration, and accessibility features.
/**
* SlideOutPanel Controller
*
* Features:
* - Native <dialog> element with showModal() API
* - Slide-in animation from right (500ms ease-in-out)
* - Focus trap (tab cycles within panel)
* - Focus restoration to trigger element
* - Click outside to close
* - ESC key to close (native)
* - Body scroll lock when open
* - ARIA state management
*/
class SlideOutPanel {
constructor(dialogElement) {
this.dialog = dialogElement;
this.triggerElement = null;
this.focusableElements = [];
// Check if backdrop click should close panel (default: true)
this.dismissOnBackdropClick = this.dialog.getAttribute('data-dismiss-on-backdrop-click') !== 'false';
this.init();
}
init() {
// Add body scroll lock class (activates when any dialog[open] exists)
if (!document.body.classList.contains('elv-has-dialog-scroll-lock')) {
document.body.classList.add('elv-has-dialog-scroll-lock');
}
// Bind event listeners
this.dialog.addEventListener('click', this.handleBackdropClick.bind(this));
this.dialog.addEventListener('keydown', this.handleKeyDown.bind(this));
// Find and setup close buttons
const closeButtons = this.dialog.querySelectorAll('.elv-slide-out-panel__close');
closeButtons.forEach(btn => {
btn.addEventListener('click', () => this.close());
});
}
open(triggerElement) {
// Store trigger for focus restoration
this.triggerElement = triggerElement;
// Update trigger ARIA state
if (this.triggerElement) {
this.triggerElement.setAttribute('aria-expanded', 'true');
}
// Close any other open dialogs
document.querySelectorAll('dialog[open]').forEach(d => {
if (d !== this.dialog) {
d.close();
}
});
// Open the dialog
this.dialog.showModal();
// Setup focus trap
this.setupFocusTrap();
// Focus first element
requestAnimationFrame(() => {
this.focusFirstElement();
});
}
close() {
// Close the dialog
this.dialog.close();
// Update trigger ARIA state
if (this.triggerElement) {
this.triggerElement.setAttribute('aria-expanded', 'false');
this.triggerElement.focus();
}
}
setupFocusTrap() {
// Get all focusable elements within dialog
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
this.focusableElements = Array.from(
this.dialog.querySelectorAll(focusableSelector)
);
}
focusFirstElement() {
if (this.focusableElements.length > 0) {
this.focusableElements[0].focus();
}
}
handleBackdropClick(event) {
// Close if click is directly on dialog element (backdrop), not on content
// Only if dismissOnBackdropClick is enabled
if (event.target === this.dialog && this.dismissOnBackdropClick) {
this.close();
}
}
handleKeyDown(event) {
// Focus trap: cycle focus within panel
if (event.key === 'Tab' && this.focusableElements.length > 0) {
const firstElement = this.focusableElements[0];
const lastElement = this.focusableElements[this.focusableElements.length - 1];
if (event.shiftKey) {
// Shift+Tab: if on first element, jump to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: if on last element, jump to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
}
}
// Initialize all panels on page load
document.addEventListener('DOMContentLoaded', () => {
// Create panel instances
const panels = new Map();
document.querySelectorAll('.elv-slide-out-panel').forEach(dialog => {
panels.set(dialog.id, new SlideOutPanel(dialog));
});
// Setup trigger buttons
document.querySelectorAll('[data-panel]').forEach(trigger => {
trigger.addEventListener('click', (e) => {
const panelId = trigger.getAttribute('data-panel');
const panel = panels.get(panelId);
if (panel) {
panel.open(trigger);
}
});
// Keyboard support for triggers
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
trigger.click();
}
});
});
});
aria-controls, aria-haspopup="dialog", aria-expanded) and dialog (aria-modal="true", role="dialog")aria-label="Close panel"ease-in-out easingrgba(32, 31, 35, 0.4))data-dismiss-on-backdrop-click="false":has() selector"false" on the <dialog> element to prevent closing when clicking the backdrop. Useful for required forms or critical workflows. Default is "true" (clicking backdrop closes panel).100vh)#ffffff (neutral-0)0px 0px 1px 0px rgba(32,31,35,0.32), -18px 0px 28px -4px rgba(32,31,35,0.15))#f7f6f7 (neutral-5)#e7e5e8 (medium)overflow-y: auto#e7e5e8 (medium)
Uses native <dialog> element (Chrome 37+, Firefox 98+, Safari 15.4+) and CSS :has()
selector (Chrome 105+, Firefox 121+, Safari 15.4+). For older browsers, consider a polyfill or fallback implementation.