Modal component
Published on:
I’ve developed a Modal component and wrote it as a Custom Element or Web Component. This Modal primarily handles the opening and closing of modals, no matter what’s inside it.
It offers slots for placing content and includes these options/props/features:
- You can set the text for the button that opens the modal.
- Customize the open button with your preferred button styles.
- Add an icon to the button.
- Adjust the blur level for the overlay.
- Tailor the dialog’s style using CSS custom properties for theme customization.
- Enjoy a smooth fading effect when the modal appears and disappears.
- NEW! optionally set max-width for the modal. Defaults to ‘min-content’ (maxWidth prop).
Example
Modal content here
Same component, different content.
Code
HTML
See the HTML in a modal. Inception!
<base-modal
hidden
modalTitle="Modal"
overlayBlur="8"
>
<div class="slot-content" slot="content">
Modal content here
</div>
</base-modal>
JS - Custom Element
// noinspection JSUnusedLocalSymbols
/**
* Represents a custom modal web component.
* @extends {HTMLElement}
*/
class BaseModal extends HTMLElement {
/**
* An array of attribute names to observe for changes.
* @returns {string[]}
*/
static get observedAttributes() {
return ["open", "openButtonText", "modalTitle"];
}
/**
* Creates a new instance of the BaseModal.
*/
constructor() {
super();
this.svgLoaded = false;
this.shadow = this.attachShadow({ mode: "open" });
}
/**
* Generates the HTML template for the modal.
* @returns {string} The HTML template string.
*/
generateTemplate() {
const openButtonText = this.getAttribute("openButtonText") || "Open";
const modalTitle = this.getAttribute("modalTitle") || "Modal title";
return `
<style>
@keyframes modal-open {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-close {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
:host {
display: inline-block;
position: relative;
box-sizing: border-box;
}
:host * {
box-sizing: border-box;
}
:host([open]) {
}
.BaseModal {
}
.BaseModal__overlay {
display: flex;
opacity: 0;
background-color: rgba(0,0,0, 0.5);
position: fixed;
pointer-events: none;
top:0;
left:0;
bottom:0;
right:0;
z-index: -1;
justify-content: center;
align-items:center;
}
:host([open]) .BaseModal__overlay {
opacity: 1;
z-index: 9999999;
pointer-events: all;
backdrop-filter: blur(${this.overlayBlur}px);
-webkit-backdrop-filter: blur(${this.overlayBlur}px);
animation: .3s ease 0s 1 forwards modal-open;
}
:host([open]) .BaseModal__overlay.--modal-closing-animation {
animation: .6s ease 0s 1 forwards modal-close;
}
.BaseModal__content {
background-color: var(--color-background-modal-content,#fff);
color: var(--color-text-modal-content,#000);
border-radius: 8px;
margin: 0 auto;
width: 100vw;
max-height: 90vh;
max-width: 90vw;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px;
gap: 2rem;
}
@media only screen and (min-width: 768px) {
.BaseModal__content {
width: 50vw;
min-width: 25vw;
max-width: 640px;
}
}
.BaseModal__header {
display: flex;
justify-content: center;
position:relative;
width: 100%;
}
.BaseModal__header-title {
font-size: 1.375rem;
color: var(--color-text-modal-title, #000);
text-align: center;
padding: 0 3rem;
width: 100%;
}
.BaseModal__main {
width: 100%;
}
.modal_close {
position: absolute;
right: 0;
padding: 0;
background-color: var(--color-modal-button-close-icon-background, pink);
color: var(--color-modal-button-close-icon, currentColor);
font-size: 0;
border: 0;
transition: all .3s ease;
border-radius: 50px;
}
.modal_close:hover {
background-color: var(--color-modal-button-close-icon-background-hover, deeppink);
color: var(--color-modal-button-close-icon-hover, currentColor);
}
</style>
<div class="BaseModal">
<button type="button" class="button modal_open" part="button" data-style="plain" data-size="small">${openButtonText}</button>
<div class="BaseModal__overlay" title="Click this overlay to close the modal.">
<div class="BaseModal__content" title="Click the X icon to close the modal.">
<div class="BaseModal__header">
<span class="BaseModal__header-title">${modalTitle}</span>
<button type="button" class="button modal_close" data-style="plain" data-icon-only="true">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M16 2C8.2 2 2 8.2 2 16s6.2 14 14 14s14-6.2 14-14S23.8 2 16 2zm0 26C9.4 28 4 22.6 4 16S9.4 4 16 4s12 5.4 12 12s-5.4 12-12 12z"/><path fill="currentColor" d="M21.4 23L16 17.6L10.6 23L9 21.4l5.4-5.4L9 10.6L10.6 9l5.4 5.4L21.4 9l1.6 1.6l-5.4 5.4l5.4 5.4z"/></svg>
</button>
</div>
<div class="BaseModal__main">
<slot name="content" />
</div>
<!-- <div class="BaseModal__footer">
<slot name="footer" />
</div> -->
</div>
</div>
</div>
`;
}
/**
* Called when the element is added to the DOM.
*/
connectedCallback() {
// Generate and append the template to the shadow DOM
// noinspection DuplicatedCode
this.shadowRoot.innerHTML = this.generateTemplate();
// TODO: try without shadow dom
if (this.iconName) {
// console.log(this.iconName);
this.loadSvg();
}
// remove hidden attr
this.removeAttribute("hidden");
// event handler on button
const thisOpenButtonElement = this.shadowRoot.querySelector(".modal_open");
const thisCloseButtonElement = this.shadowRoot.querySelector(".modal_close");
const thisModalOverlay = this.shadowRoot.querySelector(".BaseModal__overlay");
thisOpenButtonElement.addEventListener("click", this.openModal.bind(this));
thisCloseButtonElement.addEventListener("click", this.closeModal.bind(this));
thisModalOverlay.addEventListener("click", this.closeModal.bind(this));
// Event handler for the modal content
const modalContentElement = this.shadowRoot.querySelector(".BaseModal__content");
modalContentElement.addEventListener("click", (event) => {
// Prevent the click event from propagating to the overlay
event.stopPropagation();
});
// Event handler for the modal-close animation
/*const modalOverlayElement = this.shadowRoot.querySelector(".BaseModal__overlay");
modalOverlayElement.addEventListener("animationend", this.AnimationEndListener, false);*/
}
/**
* Called when the element is removed from the DOM.
*/
disconnectedCallback() {
console.log("disconnectedCallback called");
}
/**
* Gets the 'open' attribute value.
* @returns {boolean} True if the modal is open, false otherwise.
*/
get open() {
return this.hasAttribute("open");
}
/**
* Sets the 'open' attribute value.
* @param {boolean} val - True to open the modal, false to close it.
*/
set open(val) {
// Reflect the value of the open property as an HTML attribute.
if (val) {
this.setAttribute("open", "");
} else {
this.removeAttribute("open");
}
}
/**
* Gets the 'overlayBlur' attribute value.
* @returns {string|undefined} The value of the 'overlayBlur' attribute.
*/
get overlayBlur() {
if (this.hasAttribute("overlayBlur")) {
return this.getAttribute("overlayBlur");
}
}
/**
* Gets the 'icon-name' attribute value.
* @returns {string|undefined} The value of the 'icon-name' attribute.
*/
get iconName() {
if (this.hasAttribute("icon-name")) {
return this.getAttribute("icon-name");
}
}
/**
* Gets the 'icon-size' attribute value.
* @returns {string|undefined} The value of the 'icon-size' attribute.
*/
get iconSize() {
if (this.hasAttribute("icon-size")) {
return this.getAttribute("icon-size");
}
}
/**
* Called when observed attributes change.
* @param {string} attrName - The name of the attribute that changed.
* @param {string} oldValue - The previous value of the attribute.
* @param {string} newValue - The new value of the attribute.
*/
attributeChangedCallback(attrName, oldValue, newValue) {
if (newValue !== oldValue) {
this.attrName = newValue;
}
}
/**
* Opens the modal.
*/
openModal() {
document.querySelector("body").classList.add("BaseModal_open");
this.open = true;
// prevent document scrolling while open
document.querySelector("body").style.overflow = "hidden";
}
/**
* Closes the modal.
*/
closeModal() {
// Event handler for the modal-close animation
const modalOverlayElement = this.shadowRoot.querySelector(".BaseModal__overlay");
modalOverlayElement.classList.add("--modal-closing-animation");
modalOverlayElement.addEventListener("animationend", (event) => {
// Prevent the click event from propagating to the overlay
event.stopPropagation();
if (event.animationName === 'modal-close') {
this.open = false;
modalOverlayElement.classList.remove("--modal-closing-animation");
if (document.querySelector("body").classList.length === 0) {
document.querySelector("body").removeAttribute("class");
}
document.querySelector("body").style.removeProperty("overflow");
if (document.querySelector("body").style.length === 0) {
document.querySelector("body").removeAttribute("style");
}
document.querySelector("body").classList.remove("BaseModal_open");
}
});
}
/**
* Loads an SVG icon for the modal open button.
*/
loadSvg() {
if (!this.svgLoaded) {
// get icon-name
const iconName = this.iconName;
// Use this.iconSize to access the value of the "icon-size" attribute
const iconSize = this.iconSize;
fetch("/images/svg/" + iconName + ".svg")
.then((response) => response.text())
.then((svgText) => {
// get the button
const openButton = this.shadowRoot.querySelector(".modal_open");
openButton.insertAdjacentHTML("afterbegin", svgText);
// find the svg icon element
const svgIcon = openButton.querySelector("svg");
svgIcon.setAttribute("part", "icon-svg");
this.svgLoaded = true;
})
.catch((error) => {
console.error("Error loading SVG:", error);
});
}
}
}
// Define the custom element.
customElements.define("base-modal", BaseModal);