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.

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 the prefers-reduced-motion setting.
  • 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 .close button 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
DependenciesNoneNoneBootstrap CSS and JSNone
Vanilla JavaScriptYesYesYesYes
Focus trap and restoreYesYesYesDo it yourself
Backdrop and Escape closeYesEscape onlyYesDo it yourself
Back button / gesture closeYesNoNoDo it yourself
Layered stackingYesManualLimitedDo it yourself
Footer buttons APIYesNoMarkup onlyDo it yourself
Bring your own boxYesYesMarkup onlyYes
Custom width optionYesCSS onlyPreset sizesCSS only
CSS-class animationsYesCSS onlyBuilt inDo it yourself
Respects reduced motionYesN/APartialDo 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.

HTML
<link rel="stylesheet" href="hcg-modal.css">
<script src="hcg-modal.js"></script>

Or install it from npm:

Commands
npm install hcg-modal

Source code and package:

Basic usage

Call hcgModal() with options. It returns an instance with open() and close() methods.

JavaScript
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:

HTML
<button type="button" id="open-popup">Open popup</button>
JavaScript
// 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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

HTML
<div id="myBox" style="display:none">
  <div>My heading</div>
  <div>My content</div>
  <button class="close">Close</button>
</div>
JavaScript
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.

HTML
<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>
JavaScript
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.

JavaScript
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.

JSX
<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().

OptionDefaultDescription
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.
widthnullCustom width overriding the size preset. Number (px) or CSS length string.
position'center'center, top, or bottom.
scrollBodytruetrue: header and footer pinned, body scrolls. false: whole dialog scrolls.
closeOnBackdroptrueClose when the backdrop is clicked.
closeOnEsctrueClose when the Escape key is pressed.
closeOnBackButtonfalseClose when the browser or mobile back button is pressed.
showClosetrueShow the header close (X) button.
className''Extra class(es) on the overlay, for animation and theming.
boxnullUse your own element or HTML as the entire dialog.
buttonsnullArray of { text, type, onClick(instance) } footer buttons.
onOpennullCallback fired after the modal opens.
onClosenullCallback fired when the modal closes.

Instance methods

hcgModal() returns an instance with these methods.

JavaScript
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.

JavaScript
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:

  1. onOpen(instance) - after the modal opens and focus moves inside it.
  2. beforeClose(reason) - before any close; return false (or a promise resolving to false) to cancel it.
  3. button onClick(instance) - when a footer button is clicked, if the button defines one.
  4. onClose(instance) - when the modal closes.

The beforeClose callback receives a reason string telling you what triggered the close:

reasonTriggered by
buttonA footer button that has no custom onClick.
backdropClicking the backdrop behind the dialog.
escapePressing the Escape key.
close-buttonThe header X button, or a .close / data-hcg-close element.
backThe browser or mobile back button (with closeOnBackButton).
timerThe auto-close timer elapsing.
apiCalling close() in your own code with no reason.
JavaScript
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.

BrowserSupported
Chrome / Edge (Chromium)Yes
FirefoxYes
Safari (desktop and iOS)Yes
OperaYes
Internet ExplorerNo

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.