JavaScript Popup Modal (Vanilla JavaScript)
hcg-modal is a small, dependency-free popup modal written in plain JavaScript. Drop in one CSS file and one JS file, then call hcgModal() to open a modal. It supports size and width control, positions, scroll modes, CSS-driven animations, focus trapping, body scroll lock, layered stacking, back-button close, and using your own HTML box as the dialog. The examples below are copy-paste ready.
Table of Contents
Why use hcg-modal
hcg-modal focuses on being small and practical without pulling in a framework or extra dependencies. Here is what you get out of the box:
- Zero dependencies. Plain JavaScript and CSS - no jQuery, no build step. One small script and one stylesheet.
- Accessible by default. Focus is trapped inside the dialog and restored on close, with
role="dialog",aria-modal, and respect for theprefers-reduced-motionsetting. - Mobile and desktop friendly. An optional back-button close works with the hardware back button and the back gesture, as well as the desktop browser Back button.
- Mobile Back button close. On phones, the hardware or gesture Back button dismisses the modal instead of leaving the page, one layer at a time when modals are stacked.
- Layered stacking. Open modals on top of each other; the Escape key and the back button close them one layer at a time.
- Flexible content. Pass a title, HTML, or your own ready-made box as the entire dialog, with a
.closebutton wired automatically. - Themeable. Restyle with CSS custom properties, set a custom width, or drive animations entirely from your own CSS classes.
- Solid behavior. Body scroll lock, backdrop and Escape close, and a text-selection-safe backdrop so dragging a selection out does not close it.
Demo
Try hcg-modal right in your browser. The live examples below let you open real modals and experiment with every feature - sizes, positions, custom width, scroll modes, animations, auto-close timers, layered stacking, and bring-your-own-box dialogs. Click any button to see the modal in action, then copy the matching code straight into your own project.
Sizes
More demo (positions, custom width, scroll modes, animations, auto-close timers, layered stacking, and bring-your-own-box dialogs) More Demo modals
Comparison table
How hcg-modal compares with other common ways to build a modal.
| Feature | hcg-modal | Native <dialog> | Bootstrap modal | Build from scratch |
|---|---|---|---|---|
| Dependencies | None | None | Bootstrap CSS and JS | None |
| Vanilla JavaScript | Yes | Yes | Yes | Yes |
| Focus trap and restore | Yes | Yes | Yes | Do it yourself |
| Backdrop and Escape close | Yes | Escape only | Yes | Do it yourself |
| Back button / gesture close | Yes | No | No | Do it yourself |
| Layered stacking | Yes | Manual | Limited | Do it yourself |
| Footer buttons API | Yes | No | Markup only | Do it yourself |
| Bring your own box | Yes | Yes | Markup only | Yes |
| Custom width option | Yes | CSS only | Preset sizes | CSS only |
| CSS-class animations | Yes | CSS only | Built in | Do it yourself |
| Respects reduced motion | Yes | N/A | Partial | Do it yourself |
Installation
Include the stylesheet and the script. The script adds a single global function, hcgModal(). There is no build step and no dependencies.
<link rel="stylesheet" href="hcg-modal.css">
<script src="hcg-modal.js"></script> Or install it from npm:
npm install hcg-modal Basic usage
Call hcgModal() with options. It returns an instance with open() and close() methods.
const modal = hcgModal({
title: 'Hello',
content: '<p>This is a popup modal.</p>',
buttons: [
{ text: 'Close', type: 'secondary', onClick: m => m.close() }
]
});
modal.open(); Open a popup modal on button click:
<button type="button" id="open-popup">Open popup</button> // Create the modal once, then reuse it on every click.
const modal = hcgModal({
title: 'Popup Title',
content: '<p>This is a popup modal.</p>',
buttons: [
{ text: 'Close', type: 'secondary', onClick: m => m.close() }
]
});
document.getElementById('open-popup').addEventListener('click', () => modal.open());
Features and options
hcg-modal is a small, dependency-free popup modal for vanilla JavaScript. Below are the features and options you can mix and match - sizing, positioning, animations, scrolling, close behaviors, promises, timers, theming, and more.
Sizes
Pick a preset width with the size option: small, medium (default), large, or fullscreen. Choose the one that fits your content without overwhelming the screen.
hcgModal({ title: 'small', size: 'small', content: 'Small modal' }).open();
hcgModal({ title: 'large', size: 'large', content: 'Large modal' }).open();
hcgModal({ title: 'fullscreen', size: 'fullscreen', content: 'Full screen modal' }).open(); Custom width
Need an exact width? The width option overrides the size preset - pass a number for pixels (600) or any CSS length string like 70% or 40rem. It stays capped to the viewport on small screens.
hcgModal({ width: 600, content: '600px wide' }).open();
hcgModal({ width: '70%', content: '70% wide' }).open();
hcgModal({ width: '40rem', content: '40rem wide' }).open(); Positions
Place the dialog where it reads best with the position option: center (default), top, or bottom. Top and bottom work well for banners and mobile-style sheets.
hcgModal({ position: 'top', content: 'Pinned to the top' }).open();
hcgModal({ position: 'center', content: 'Pinned in the middle' }).open();
hcgModal({ position: 'bottom', content: 'Pinned to the bottom' }).open(); Scroll modes
For long content, the scrollBody option controls how it scrolls. true (default) caps the dialog to the viewport and scrolls only the body, keeping the header and footer pinned. false lets the whole dialog scroll inside the overlay.
hcgModal({ content: longHtml }).open(); // default: body scrolls
hcgModal({ scrollBody: false, content: longHtml }).open(); // whole dialog scrolls Animations
Animations are driven entirely by CSS through the className option - define a start state and a visible end state, and the same transition plays in on open and out on close. Built-in classes for scale, fade, and slide are included, or write your own.
hcgModal({ title: 'Fade', content: 'fade animation', className: 'hcg-modal--anim-fade' }).open();
hcgModal({ title: 'Slide', content: 'slide animation', className: 'hcg-modal--anim-slide' }).open();
hcgModal({ title: 'Pop', content: 'pop animation', className: 'hcg-modal--anim-pop' }).open(); Close behaviors
By default the modal closes on a backdrop click, the Escape key, and the close (X) button. Each can be turned off independently with closeOnBackdrop, closeOnEsc, and showClose.
hcgModal({
content: 'Try Esc, the backdrop, or the X button',
closeOnBackdrop: true,
closeOnEsc: true,
showClose: true
}).open(); Close on back button
Set closeOnBackButton to close the modal when the browser or mobile back button is pressed, instead of leaving the page. It works on desktop and mobile.
hcgModal({
content: 'The back button closes me',
closeOnBackButton: true
}).open(); Layered stacking
Open a modal on top of another and they stack on their own layers with correct z-index ordering. The Escape key and the back button close them from the top down, one at a time.
const second = hcgModal({ title: 'Second', content: 'On top of the first.' });
const first = hcgModal({
title: 'First',
content: 'Open another modal on top of this one.',
buttons: [
{ text: 'Close', type: 'secondary', onClick: (m) => m.close() },
{ text: 'Open second', type: 'primary', onClick: () => second.open() }
]
});
first.open(); Footer buttons and callbacks
The buttons array builds the footer. Each button has text, a type (primary, secondary, or danger), and an onClick callback that receives the modal instance so you can run an action and close it.
hcgModal({
title: 'Delete item?',
content: 'This action cannot be undone.',
buttons: [
{ text: 'Cancel', type: 'secondary', onClick: m => m.close() },
{ text: 'Delete', type: 'danger', onClick: m => {
// run your delete logic here
// delete_function()
m.close();
} }
]
}).open(); Await a result (promise)
Open the modal with opened() instead of open() to get a promise that resolves when it closes. Give each button a value; clicking it resolves the promise to that value, while dismissing (Escape, backdrop, X, back button) resolves to undefined. This turns a confirm dialog into a single readable line.
async function confirmDelete() {
const ok = await hcgModal({
title: 'Delete item?',
content: '<p>This cannot be undone.</p>',
buttons: [
{ text: 'Cancel', value: false },
{ text: 'Delete', type: 'danger', value: true }
]
}).opened();
if (ok) {
// user clicked Delete
}
}
// call it when you need the confirmation, e.g. from a button:
confirmDelete(); beforeClose veto
Use beforeClose to run a check before any close and cancel it by returning false. It can return a promise, so you can confirm or save asynchronously - handy for guarding unsaved changes.
hcgModal({
title: 'Edit profile',
content: 'form_html',
beforeClose: (reason) => {
if (hasUnsavedChanges()) {
return window.confirm('Discard your changes?'); // false keeps it open
}
return true;
}
}).open(); Auto-close timer
Set timer (in milliseconds) to dismiss the modal automatically, ideal for toast-style notifications. Add timerProgressBar for a shrinking bar that shows the remaining time and pauses while the pointer is over the modal.
hcgModal({
title: 'Saved',
content: '<p>This closes in 3 seconds.</p>',
timer: 3000,
timerProgressBar: true,
showClose: false
}).open(); Enter to confirm
With confirmOnEnter, pressing Enter triggers the primary footer button - natural for confirm dialogs and forms. It is ignored while focus is in a textarea or editable region, where Enter means a new line.
async function confirmSubmit() {
const v = await hcgModal({
title: 'Submit form?',
confirmOnEnter: true,
buttons: [
{ text: 'Cancel', value: false },
{ text: 'Submit', type: 'primary', value: true }
]
}).opened();
if (v === true) {
// user clicked Submit
}
}
// call it from a click handler, etc.
confirmSubmit(); Glassmorphism theme
Apply the built-in frosted-glass look by passing the hcg-modal--glass class through className. It blurs the backdrop and gives the dialog a translucent, modern finish.
hcgModal({
title: 'Glass',
content: 'A frosted, blurred backdrop.',
className: 'hcg-modal--glass'
}).open(); Bring your own box
Use a ready-made element as the entire dialog with the box option. When set, title, content, buttons, and showClose are ignored. A hidden (display:none) box is shown automatically. The modal moves the node into itself and removes it on close, so clone the original if you want to reopen it.
<div id="myBox" style="display:none">
<div>My heading</div>
<div>My content</div>
<button class="close">Close</button>
</div> const box = document.getElementById('myBox').cloneNode(true);
hcgModal({ box: box }).open(); Auto-wired close button
Any element inside the box or content with the class close or a data-hcg-close attribute is wired to close the modal automatically. Your own click handlers still work alongside it.
<div id="myBox" style="display:none">
<p>Content</p>
<button class="close">Cancel</button> <!-- auto-wired -->
<button data-hcg-close>Dismiss</button> <!-- auto-wired -->
</div>
<button id="open">Open</button> document.getElementById("open").addEventListener("click", () => {
const box = document.getElementById('myBox').cloneNode(true);
hcgModal({ box: box }).open(); // both buttons close the modal
}); Using with React
A thin React wrapper reuses the same vanilla modal and renders your React children into the dialog through a portal, so there is no second implementation to maintain. Load the vanilla hcg-modal.css and hcg-modal.js first, then use the controlled open prop.
import { useState } from 'react';
import 'hcg-modal/hcg-modal.css'; // modal styles
import 'hcg-modal/hcg-modal.js'; // defines window.hcgModal
import HcgModal from 'hcg-modal/react';
function Example() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open modal</button>
<HcgModal open={open} onClose={() => setOpen(false)} title="Edit profile">
<p>Real React children render here.</p>
<button onClick={() => setOpen(false)}>Done</button>
</HcgModal>
</>
);
} The open prop controls visibility and onClose fires for every close path (X button, Escape, backdrop, back button, or a .close element). Any other option - size, width, position, scrollBody, closeOnBackButton, className, and so on - is passed straight through as a prop.
<HcgModal
open={open}
onClose={() => setOpen(false)}
size="large"
width={600}
position="top"
closeOnBackButton
>
...
</HcgModal> Options reference
All options are optional. Pass them in a single object to hcgModal().
| Option | Default | Description |
|---|---|---|
| title | '' | Header title (HTML or text). Omit to hide the header. |
| content | '' | Body content: HTML string, text, or a DOM node. |
| size | 'medium' | small, medium, large, or fullscreen. |
| width | null | Custom width overriding the size preset. Number (px) or CSS length string. |
| position | 'center' | center, top, or bottom. |
| scrollBody | true | true: header and footer pinned, body scrolls. false: whole dialog scrolls. |
| closeOnBackdrop | true | Close when the backdrop is clicked. |
| closeOnEsc | true | Close when the Escape key is pressed. |
| closeOnBackButton | false | Close when the browser or mobile back button is pressed. |
| showClose | true | Show the header close (X) button. |
| className | '' | Extra class(es) on the overlay, for animation and theming. |
| box | null | Use your own element or HTML as the entire dialog. |
| buttons | null | Array of { text, type, onClick(instance) } footer buttons. |
| onOpen | null | Callback fired after the modal opens. |
| onClose | null | Callback fired when the modal closes. |
Instance methods
hcgModal() returns an instance with these methods.
const modal = hcgModal({ title: 'Demo', content: 'Hello' });
modal.open(); // open the modal
modal.close(); // close the modal
modal.setContent('<p>New body</p>'); // replace the body content
modal.setTitle('New title'); // replace the header title
modal.isOpen(); // returns true or false
modal.destroy(); // remove the modal and its listeners Callbacks
The onOpen and onClose options run at the two ends of a modal's life. onOpen fires right after the modal is shown and focus has moved inside it; onClose fires when it starts to close. Both receive the modal instance, so you can read its state or run setup and cleanup.
hcgModal({
title: 'Settings',
content: '<p>Adjust your preferences.</p>',
onOpen: (modal) => {
console.log('opened:', modal.isOpen()); // true
// focus a field, start a player, load data, etc.
},
onClose: (modal) => {
console.log('closed');
// save state, stop timers, send analytics, etc.
}
}).open(); Events and close reasons
hcg-modal uses callback hooks rather than DOM events. Across a single open/close cycle they fire in a predictable order:
onOpen(instance)- after the modal opens and focus moves inside it.beforeClose(reason)- before any close; returnfalse(or a promise resolving tofalse) to cancel it.- button
onClick(instance)- when a footer button is clicked, if the button defines one. onClose(instance)- when the modal closes.
The beforeClose callback receives a reason string telling you what triggered the close:
| reason | Triggered by |
|---|---|
| button | A footer button that has no custom onClick. |
| backdrop | Clicking the backdrop behind the dialog. |
| escape | Pressing the Escape key. |
| close-button | The header X button, or a .close / data-hcg-close element. |
| back | The browser or mobile back button (with closeOnBackButton). |
| timer | The auto-close timer elapsing. |
| api | Calling close() in your own code with no reason. |
hcgModal({
content: '<p>Watch the console when you close this.</p>',
beforeClose: (reason) => {
console.log('closing because:', reason); // e.g. 'escape' or 'backdrop'
return true; // false would keep it open
}
}).open(); Browser support
hcg-modal runs in all modern evergreen browsers and uses only standard DOM and CSS features - CSS custom properties, CSS transitions, and the History API for the optional back-button close. There are no polyfills to load.
| Browser | Supported |
|---|---|
| Chrome / Edge (Chromium) | Yes |
| Firefox | Yes |
| Safari (desktop and iOS) | Yes |
| Opera | Yes |
| Internet Explorer | No |
Frequently Asked Questions (FAQ)
Is hcg-modal free to use?
Yes. hcg-modal is open source under the MIT license and free to use in both personal and commercial projects.
Does hcg-modal have any dependencies?
No. It is written in plain JavaScript and CSS with no jQuery, no framework, and no build step. You include one stylesheet and one script.
How do I get the result of which button was clicked?
Open the modal with opened() instead of open(). It returns a promise that resolves to the clicked button's value, or to undefined when the modal is dismissed with Escape, the backdrop, the X button, or the back button.
How do I stop the modal from closing?
Use the beforeClose option. Return false (or a promise that resolves to false) to cancel the close - for example to confirm unsaved changes before letting the modal close.
Can I use hcg-modal with React?
Yes. A thin React wrapper renders your React children into the same vanilla modal through a portal, controlled by an open prop, so there is no second implementation to maintain.
Is hcg-modal accessible?
Yes. Focus is trapped inside the dialog and restored on close, the dialog uses role="dialog" and aria-modal, Escape closes it, and animations respect the prefers-reduced-motion setting.
Which browsers does hcg-modal support?
All modern evergreen browsers, including Chrome, Edge, Firefox, Safari on desktop and iOS, and Opera. Internet Explorer is not supported.