JavaScript Popup Modal (Vanilla JavaScript)


hcg-modal is a lightweight popup modal library for plain JavaScript pages. Add one stylesheet and one script, call hcgModal(), and open dialogs for confirmations, forms, alerts, or any overlay content without jQuery, a framework, or a build step. Everything below is copy-paste ready HTML and JavaScript you can drop into your own site.

What is hcg-modal?

hcg-modal is a lightweight vanilla JavaScript library for creating popup modal dialogs. It lets you add titles, HTML content, footer buttons, size and position presets, custom width, scroll modes, CSS animations, focus trapping, body scroll lock, layered stacking, back-button close, promise-based results, and your own HTML box as the dialog without jQuery or any framework.

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.
hcg-modal - JavaScript Popup Modal Library

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


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

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

There are no animation or duration JavaScript options. Pass a class through className and let CSS drive the effect. The modal reads your CSS transition time to know when to remove itself on close.

Built-in animation classes

Three ready-made classes ship in hcg-modal.css:

  • hcg-modal--anim-fade - plain opacity fade.
  • hcg-modal--anim-slide - slides in; direction follows position (top, center, or bottom). With position: 'bottom' it rises from the bottom edge like a sheet.
  • hcg-modal--anim-pop - combined rise, scale, and blur for a richer entrance.
JavaScript
hcgModal({ content: 'Fade', className: 'hcg-modal--anim-fade' }).open();
hcgModal({ content: 'Slide', className: 'hcg-modal--anim-slide', position: 'bottom' }).open();
hcgModal({ content: 'Pop', className: 'hcg-modal--anim-pop' }).open();

Built-in animations respect prefers-reduced-motion. Tune the speed with the --hcg-modal-duration CSS variable (see Theming).

Custom animation classes

Define a start state and a .hcg-modal--visible end state, add a transition, and pass your class through className:

CSS
.my-anim .hcg-modal-dialog { transform: translateY(40px); opacity: 0; }
.my-anim.hcg-modal--visible .hcg-modal-dialog { transform: none; opacity: 1; }
JavaScript
hcgModal({ content: 'Custom animation', className: 'my-anim' }).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. Each stacked layer is dismissed by one back press.

JavaScript
hcgModal({
  content: 'The back button closes me',
  closeOnBackButton: true
}).open();

With closeOnBackButton, closing calls history.back() asynchronously. Opening and closing modals extremely fast (for example programmatic toggles during SPA route changes) can race the history push and pop. In normal use this is not an issue; avoid rapid back-to-back open and close on the same tick if you rely on the back-button integration.


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, an optional value (for opened()), a type (primary, secondary, or danger), and an onClick callback that receives the modal instance.

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
});

Theming

Restyle the modal without editing hcg-modal.css by overriding CSS custom properties on .hcg-modal. Scroll lock also compensates for the scrollbar width so the page does not jump when a modal opens.

CSS
.hcg-modal {
  --hcg-modal-bg: #1e1e1e;
  --hcg-modal-color: #eaeaea;
  --hcg-modal-primary: #8b5cf6;
  --hcg-modal-radius: 14px;
  --hcg-modal-z: 99999;
}

Other variables you can override include --hcg-modal-overlay, --hcg-modal-shadow, --hcg-modal-border, --hcg-modal-duration, --hcg-modal-pad, and the per-type button colors (--hcg-modal-secondary, --hcg-modal-danger, and their text variants). Size presets also set --hcg-modal-width.

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.

MethodDescription
open()Open the modal. Returns the instance (chainable).
opened()Open the modal and return a Promise that resolves when it closes, to the clicked button's value (or undefined when dismissed).
close(reason)Close the modal. reason is optional and passed to beforeClose.
setContent(html|node)Replace the body content. A DOM node is moved in - clone it first if it must stay on the page.
setTitle(html)Replace the header title (creates the header if it was omitted).
isOpen()Returns whether the modal is currently open.
destroy()Immediately remove the modal and detach listeners. Unlike close(), it does not run beforeClose, play the exit animation, or fire onClose.
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();

Accessibility

hcg-modal is accessible by default. The dialog uses role="dialog" and aria-modal="true". Focus is trapped inside the open modal and restored to the previously focused element on close. Escape closes the modal when closeOnEsc is enabled. Built-in animations honor prefers-reduced-motion.

When a modal has no visible title, give it an accessible name with ariaLabel or point to a heading with ariaLabelledBy (the id of an element inside the dialog). ariaLabelledBy takes precedence over ariaLabel.

JavaScript
hcgModal({
  content: '<p>Your session expired.</p>',
  ariaLabel: 'Session expired',
  buttons: [{ text: 'OK', onClick: m => m.close() }]
}).open();

hcgModal({
  content: '<h2 id="dlg-title">Settings</h2><p>…</p>',
  ariaLabelledBy: 'dlg-title',
  showClose: false
}).open();

Security

The title and content options accept HTML strings and insert them as markup (content also accepts a DOM node). Never pass unsanitized user input to them, or you risk cross-site scripting (XSS). Button labels (buttons[].text) are inserted as plain text and are safe. Sanitize any user-derived HTML before passing it in, or pass a DOM node you built yourself.

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>

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.

License

hcg-modal is open source under the MIT license. Copyright HTML Code Generator.