Why Modals Are Worth Solving Properly
Modal dialogs have a deservedly bad reputation. They interrupt reading, block content, and often arrive in frustrating sequences—a cookie banner followed by a chat widget, then a newsletter prompt. But for all their faults, modals remain a core UI pattern. The problem is that they are deceptively hard to build correctly. Beyond the visuals, there are focus traps, scroll locking, and dismissal semantics to manage.
The promise of Web Components is that this complexity can be encapsulated once and then reused without any JavaScript configuration. With support now available in every major browser, that promise is finally practical.
The approach described here is the foundation of a working modal called CTA Modal, which is available as a demo page and a Git repository. The goal is simple: author only HTML, and get a fully functional modal with rich interaction for free.
Encapsulation as a Feature
Web Components bundle HTML, CSS, and JavaScript into sealed units. Styles from outside a component do not leak in, and styles from inside do not leak out. That isolation can initially feel limiting—after all, CSS is supposed to cascade. But the trade-off is significant: a component built this way is not tied to any particular JavaScript framework. The phrase "use the platform" captures this idea, and the platform now has excellent cross-browser support to back it up.
Still, there are essential details to establish before digging into the code.
The Problem With HTML Authoring
Standard HTML provides <dialog> as a built-in element, but implementing a modal with rich behavior requires more than the default browser capabilities. When you want custom interactions, accessible focus management, or animations, the declarative features alone fall short of what users expect from a polished modal experience.
Fundamental Interaction Requirements
Getting a custom modal interaction right means accounting for a set of non-negotiable behaviors that users implicitly expect:
- Dismissal paths: Users should be able to close the modal by clicking a close button, clicking outside the modal area, or pressing the Escape key.
- Background behavior: While a modal is open, interaction with the page behind it should be appropriately restricted.
- Responsive display: The modal should be usable on small mobile screens as well as large desktop viewports.
Returning Focus Where It Belongs
One of the most common and least visible failures in modal implementations is focus management. When a modal opens, focus should move into the dialog. When it closes, focus should return to the element that opened it. Keyboard and screen reader users rely on this behavior to understand where they are in the page. Unfortunately, this is the easiest part to get wrong when hand-rolling a modal with vanilla JavaScript or jQuery.
Aiming for Framework-Independent Reuse
The infrastructure needed to handle all of these cases—focus management, keyboard events, and viewport changes—quickly adds up. Every project ends up re-solving the same problems. By building this behavior into a web component, the logic lives in one place and works anywhere the component is dropped in, regardless of whether the rest of the site runs React, Vue, or nothing at all.
A Solid Foundation for Interaction
The result of this effort is a custom element where the API surface is essentially just HTML attributes. All of the behavioral complexity is buried inside the component's implementation, which means the element can be used repeatedly without additional scripting. This is the payoff for taking the time to handle the hard interaction details once.
The process of building such a modal inevitably involves revisiting prior implementations that relied on bespoke JavaScript and jQuery. The web component approach avoids those pitfalls entirely, because all of the problematic logic is locked inside the sealed, reusable capsule.
Even for those skeptical about modals in general, the improved user experience and reduced maintenance burden make this approach worth considering.
Inside the Component File
The cta-modal.ts file is organized into distinct sections, starting with a conditional wrapper, followed by constants for styles and markup, the main CtaModal class, and a DOM-loaded callback.
Conditional Wrapper and Reusable Variables
A single top-level if statement wraps the entire file’s code. This serves two purposes: it checks for browser support for window.customElements, and it provides a way to maintain variable scope. Declaring variables with const or let inside this block prevents them from leaking into the global scope, unlike older var declarations.
Within this wrapper, primitive values are defined for reuse throughout the JavaScript class. Some notable ones include:
ANIMATION_DURATION— Set to250milliseconds. This value syncs CSS animation timing with a JavaScriptsetTimeout.DATA_SHOWandDATA_HIDE— Strings for the HTML data attributes'data-cta-modal-show'and'data-cta-modal-hide'that control show/hide behavior and CSS animation timing.PREFERS_REDUCED_MOTION— A media query checking if the user has setprefers-reduced-motiontoreduceat the OS level, used in both CSS and JS to disable animations.FOCUSABLE_SELECTORS— A string containing CSS selectors for all potentially focusable elements within the modal, used withquerySelectorAllfor readability.
Note that the string uses both type='hidden' and tabindex="0" with different quotation marks deliberately; this is revisited later in the code.
Component Styles and Markup
The styles section is a multiline string containing a <style> tag. Styles inside a Web Component are scoped and do not leak to the rest of the page. The code uses embedded variables via string interpolation to reference PREFERS_REDUCED_MOTION for disabling animations, and DATA_SHOW, DATA_HIDE, and ANIMATION_DURATION for shared animation control.
The modal markup itself is straightforward. It includes slots for content injection, a scrollable area, focus traps, a semi-transparent overlay, a dialog window, and a close button. Content is inserted via two named slots:
<div slot="button">maps to<slot name='button'>,<div slot="modal">maps to<slot name='modal'>.
Focus traps are elements positioned before and after the modal dialog; if they receive focus, they redirect it back inside. The dialog div itself is given semantic attributes: aria-modal='true', role='dialog', and tabindex'-1'. These allow the browser to treat the <div> as a dialog and permit focus placement via JavaScript. The native dialog element is avoided due to cross-browser quirks and because it cannot have a tabindex attribute, which is necessary for focusing.
Constructor and Binding
The component’s constructor runs automatically when a <cta-modal> tag is parsed. Calling super initializes the parent HTMLElement class. The constructor then calls this._bind() to manage event handler contexts, attaches the shadow DOM with the component markup, queries reference elements for later use, and calls helper methods to read attributes from the tag.
Binding this context is important because DOM event handlers can change the meaning of this. Rather than explicitly binding each function, this._bind() loops through all class properties that are functions and automatically binds them, preventing repetitive code.
Lifecycle Methods
By extending HTMLElement, the component inherits several built-in lifecycle callbacks:
observedAttributes— Declares which attributes the browser should watch for changes.attributeChangedCallback— Invoked when a watched attribute changes, triggering a function to read its new value.connectedCallback— Called when a<cta-modal>tag is added to the page. Used to add event handlers, similar to React’scomponentDidMount.disconnectedCallback— Called when the tag is removed. Used to remove event handlers, similar tocomponentWillUnmount.
These lifecycle method names are not prefixed with an underscore (_), unlike custom methods. This convention makes it clear which functions are native and which are custom, and it allows minifiers to safely mangle the custom names while preserving the required native ones.
Event Handling and Attribute Detection
Helper functions register and remove callbacks for button clicks, element focus, keyboard presses, and overlay clicks. Separate functions handle reading tag attributes and setting corresponding class flags, such as _isAnimated, _isActive, and _isStatic. These functions also set accessibility properties like aria-label on the close button and modal dialog. The aria-label on the dialog is used instead of aria-labelledby because browsers currently cannot correlate aria-labelledby in the shadow DOM with an id in the light DOM.
Focus Management and Behavioral Helpers
The _focusElement function focuses an element that was active before the modal opened, while _focusModal places focus on the dialog and scrolls the backdrop to the top. Another function checks if a given element resides outside the <cta-modal> tag, returning a boolean used to trap tab navigation inside the modal.
A function to detect motion preference reuses the earlier variable and combines it with an animated="false" flag check. It returns true only if the user has not requested reduced motion and the component is not explicitly set to be static.
The core show/hide function toggles the modal’s active state. If the modal is inactive, it shows and animates in; if active, it hides and animates out. It also caches the currently active element to restore focus upon closing. The CSS variables ANIMATION_DURATION, DATA_SHOW, and DATA_HIDE are reused here to keep animations in sync.
Event Handlers and Registration
Event handlers cover specific interactions:
- Click overlay: Closes the modal unless the
static="true"attribute is set. - Click toggle: Uses event delegation on the slot elements. Any child with the class
cta-modal-togglethat is activated via mouse click,enter, orspacebartoggles the modal state. - Focus element: Fires whenever any element receives focus. Based on modal state and which element was focused, it traps tab navigation within the dialog using
FOCUSABLE_SELECTORS. - Keyboard: The
escapekey closes the modal when active. Thetabkey triggers logic to adjust which element is focused.
Finally, a DOM loaded callback waits for the page to be ready, then registers the <cta-modal> tag with the browser, enabling the new custom element to be parsed and used like any other HTML tag.
Shrinking the Final Payload
Beyond the TypeScript-to-JavaScript transpile step, there’s a second stage worth highlighting: aggressive minification. After the initial build, the JS output is passed through Terser, which is safe to mangle every private method name — the ones prefixed with an underscore like _bind and _addEvents — down to single letters.
That alone makes a meaningful dent in the byte count. To push further, a custom minifyWebComponent.js process compresses the embedded <style> and markup. This means class names, attributes, and selectors get rewritten throughout both the CSS and HTML. For instance, class='cta-modal__overlay' shrinks to class=o, and the quotes are dropped entirely because the browser doesn’t require them to resolve the intent.
One notable exception: the [tabindex="0"] selector must keep its quotes. Removing them around the 0 appears to break parsing in querySelectorAll. In the HTML itself, however, tabindex='0' can safely become tabindex=0.
The cumulative reduction in file size is substantial, measured in bytes:
- Un-minified: 16,849
- After Terser minification: 10,230
- After the custom script: 7,689
For context, the favicon.ico on Smashing Magazine weighs 4,286 bytes. So the entire modal component adds only a few kilobytes of overhead in exchange for functionality that requires nothing more than writing a bit of HTML to use.
Wrapping Up
That covers the full build. There are no frameworks to learn unless you want them, and you can genuinely start building Web Components with vanilla JS and no build pipeline at all. The platform gives you everything you need, and there has never been a better time to #UseThePlatform.
Alternatives Worth Evaluating
Naturally, there are many other modal implementations available. The list below is not exhaustive, but it represents a solid cross-section of what’s out there. Each of these requires at least some additional JavaScript authored by the end-user developer — a contrast to the CTA Modal approach, where the developer only has to write the HTML.
Flat HTML & JS:
Web Components:
jQuery:
React:
Vue:




