Skip to main content Accessibility Feedback

<modal-toggle> + <modal-content>

Wrapper components that make working with the <dialog> element a bit easier.

Source Code

Basic Usage

Wrap a <dialog> element in the <modal-content> component.

If you add a [close] attribute to any <button> element in the component, activating the button will automatically close the <dialog> element.

<modal-content id="greeting">
	<dialog>
		<p>Hey there!</p>
		<button close>Close</button>
	</dialog>
</modal-content>

To open the modal, you can run the open() method on your custom element.

let modal = document.querySelector('modal-content');
modal.open();

You can also wrap any button or link in a <modal-toggle> custom element to turn it into a toggle for your modal.

Include a [target] attribute, and provide a selector for the modal to open as its value. If you include a [hidden] attribute, the toggle will automatically become visible after the JavaScript has instantiated.

<modal-toggle target="#greeting" hidden>
	<button>Open the Modal</button>
</modal-toggle>

If you set the method to dialog on a <form> element inside a <dialog>, the form will automatically close the <dialog> element instead of submitting.

<modal-content>
	<dialog>    
		<form method="dialog">
			<button>Close</button>
		</form>
	</dialog>
</modal-content>

If you include a value on the submit button, it gets assigned to the .returnValue property on the <dialog> element.

In this example, clicking [close] button results in an empty string ('') for a .returnValue, while clicking the Ok button results in a returnValue of 'ok'.

<modal-content>
	<dialog>    
		<form method="dialog">
			<button close>Cancel</button>
			<button value="ok">Ok</button>
		</form>
	</dialog>
</modal-content>

If you include a [modal-value] attribute on any form field, the <modal-content> component will automatically update the .returnValue when the value of that field changes.

<modal-content>
	<dialog>
		<form method="dialog">

			<label for="wizard">Who is your favorite wizard?</label>
			<input type="text" id="wizard" name="wizard" modal-value>
		
			<button close>Cancel</button>
			<button>Submit</button>

		</form>
	</dialog>
</modal-content>

You can detect when modals are opened, closed, and cancelled, and get the the .returnValue, using one of the custom modal events.

Events

The <modal-content> component emits a few custom events to let you detect user interactions and extend it’s functionality beyond what it offers out-of-the-box.

  • modal:open is emitted when the modal is opened.
  • modal:cancel is emitted when the user clicks a [close] button, submits a [method="dialog"] form with no .returnValue, or closes the modal with the Escape key.
  • modal:submit is emitted when the modal has a .returnValue. The event.detail property is .returnValue value.

All events are emitted on the <modal-content> element, and bubble so that you can use event delegation.

You can listen for events with the Element.addEventListener() method.

document.addEventListener('modal:submit', function (event) {

	// The `<modal-content>` element
	console.log(event.target);

	// The .returnValue property
	console.log(event.detail);

});

Styling

The browser automatically adds an [open] attribute to the <dialog> element when it’s visible. You can hook into it for styling if desired.

dialog[open] {
	/* Style your open modal*/
}

You can use the dialog::backdrop pseudo-element to style the overlay that covers the page content when a modal is open.

dialog::backdrop {
	background-color: rgba(0, 0, 0, 0.7);
}

The <modal-content> element adds a [modal-open] attribute to the body when a modal is open, and removes it when the modal is closed.

You can hook into this attribute to prevent the page behind the modal from scrolling.

[modal-open] {
	overflow: hidden;
}

Ajax Modals

You can use <modal-toggle> and <modal-content> to create progressively enhanced modals that load HTML from another webpage.

First, include a link to your external HTML as a fallback if JavaScript isn’t supported or is not yet available. Do not use the [hidden] attribute in this case.

<modal-toggle target="#tos">
	<a href="/terms-of-service">Terms of Service</a>
</modal-toggle>

Next, create your <modal-content> with some sort of “loading” indicator.

<modal-content id="tos">
	<p>Loading...</p>
</modal-content>

Finally, run a modal:open event listener on your #tos component the first time it opens using the once option with the addEventListener() method.

Use the fetch() method to asynchronously get the HTML. Then, parse it to a text string, and inject it into the modal.

// Get the #tos <modal-content>
let tos = document.querySelector('#tos');

// The first time it opens, asynchronously load HTML
tos.addEventListener('modal:open', async function (event) {

	// Fetch external HTML file
	let response = await fetch(event.target.href);

	// Parse the response
	let html = await response.text();

	//  Inject the HTML into the modal
	tos.innerHTML = html;

}, {once: true});

The Web Components

<modal-toggle>
customElements.define('modal-toggle', class extends HTMLElement {

	/**
	 * Instantiate the component
	 */
	constructor () {

		// Inherit parent class properties
		super();

		// Set properties
		this.modal = document.querySelector(this.getAttribute('target'));
		this.toggle = this.querySelector('button, a');
		this.handler = this.createHandler();

		// Show button
		this.removeAttribute('hidden');

		// If toggle is a link, add proper role
		if (this.toggle.matches('a')) {
			this.toggle.setAttribute('role', 'button');
		}

	}

	/**
	 * Create the event handler function
	 * @return {Function} The handler function
	 */
	createHandler () {
		return (event) => {
			event.preventDefault();
			if (!this.modal) return;
			this.modal.open();
		};
	}

	/**
	 * Add event listener
	 */
	connectedCallback () {
		this.toggle.addEventListener('click', this.handler);
	}

	/**
	 * Remove event listener
	 */
	disconnectedCallback () {
		this.toggle.removeEventListener('click', this.handler);
	}

});
<modal-content>
customElements.define('modal-content', class extends HTMLElement {

	/**
	 * Instantiate the component
	 */
	constructor () {

		// Inherit parent class properties
		super();

		// Set properties
		this.modal = this.querySelector('dialog');
		this.form = this.querySelector('[method="dialog"]');
		this.cancelling = false;
		this.clickHandler = this.createClickHandler();
		this.closeHandler = this.createCloseHandler();
		this.cancelHandler = this.createCancelHandler();
		this.inputHandler = this.createInputHandler();

		// Setup the DOM
		this.setup();

	}

	/**
	 * Prevent form buttons from submitting
	 */
	setup () {
		let btns = this.querySelectorAll('form [close]');
		for (let btn of btns) {
			btn.setAttribute('type', 'button');
		}
	}

	/**
	 * Create the click handler callback function
	 * @return {Function} The callback function
	 */
	createClickHandler () {
		return (event) => {
			if (!event.target.closest('[close]')) return;
			this.modal.returnValue = '';
			this.close('cancel', true);
		};
	}

	/**
	 * Create the close handler callback function
	 * @return {Function} The callback function
	 */
	createCloseHandler () {
		return (event) => {
			if (this.cancelling) {
				this.cancelling = false;
				return;
			}
			this.close(this.modal.returnValue ? 'submit' : 'cancel');
		};
	}

	/**
	 * Create the cancel handler callback function
	 * @return {Function} The callback function
	 */
	createCancelHandler () {
		return (event) => {
			this.close('cancel', true);
		};
	}

	/**
	 * Create the input handler callback function
	 * @return {Function} The callback function
	 */
	createInputHandler () {
		return (event) => {
			if (!event.target.matches('[modal-value]')) return;
			this.modal.returnValue = event.target.value || '';
		};
	}

	/**
	 * Attach event listeners
	 */
	connectedCallback () {
		if (!this.modal) return;
		this.modal.addEventListener('click', this.clickHandler);
		this.modal.addEventListener('close', this.closeHandler);
		this.modal.addEventListener('cancel', this.cancelHandler);
		if (!this.form) return;
		this.modal.addEventListener('input', this.inputHandler);
	}

	/**
	 * Remove event listeners
	 */
	disconnectedCallback () {
		if (!this.modal) return;
		this.modal.removeEventListener('click', this.clickHandler);
		this.modal.removeEventListener('close', this.closeHandler);
		this.modal.removeEventListener('cancel', this.cancelHandler);
		this.modal.removeEventListener('input', this.inputHandler);
	}

	/**
	 * Emit a custom event
	 * @param  {String} type The event type name
	 */
	emit (type) {

		// Set the event detail
		let detail = type === 'submit' ? this.modal.returnValue : null;

		// Create a new event
		let event = new CustomEvent(`modal:${type}`, {
			bubbles: true,
			detail
		});

		// Dispatch the event
		return this.dispatchEvent(event);

	}

	/**
	 * Open the modal
	 */
	open () {

		// If there's no modal, bail
		if (!this.modal) return;

		// Show the modal
		this.modal.showModal();

		// Add styling hook to body
		document.body.setAttribute('modal-open', '');

		// Emit an open event
		this.emit('open');

	}

	/**
	 * Close the modal
	 * @param  {string}  type   The close type
	 * @param  {boolean} cancel If true, temporary set cancel state
	 */
	close (type, cancel = false) {

		// If there's no modal, bail
		if (!this.modal) return;

		// Remove the style hook from the body
		document.body.removeAttribute('modal-open');

		// Temporarily set cancel state
		this.cancelling = cancel;

		// Close the modal
		this.modal.close();

		// Emit a close event
		this.emit(type);

	}

});

Find this useful? You can support my work by purchasing an annual membership.