Skip to content

Instantly share code, notes, and snippets.

@westc
Last active July 4, 2026 01:40
Show Gist options
  • Select an option

  • Save westc/05da0fcb1664290cb7a44b21a6499697 to your computer and use it in GitHub Desktop.

Select an option

Save westc/05da0fcb1664290cb7a44b21a6499697 to your computer and use it in GitHub Desktop.
A lightweight, zero-dependency asynchronous modal prompt library for the browser built on vanilla JS and HTML5 <dialog>. Features strict object-based button bindings, dynamic form field generation, keyboard shortcut captures (Enter/Esc), and automatic theme matching with explicit runtime overrides. Completely encapsulated within a single global …
/**
* @typedef {Object} FieldOption
* @property {string} [value] - The underlying data value for the option. Defaults to the label if omitted.
* @property {string} label - The visible text display for the option.
*/
/**
* @typedef {Object} BoxField
* @property {string} label - The unique identifier and visible text label for the form field.
* @property {string} [description] - Optional subtext or instructions displayed below the label.
* @property {"text" | "textarea" | "select"} type - The input field control type.
* @property {FieldOption[]} [options] - Array of dropdown choices. Only applicable when type is "select".
* @property {string} [value] - The default initial value for the field.
*/
/**
* @typedef {Object} BoxButton
* @property {string} label - The text displayed inside the button.
* @property {string} [color] - A valid CSS color string (e.g., "#dc3545", "blue") to override the background.
* @property {"enter" | "esc"} [key] - Maps the keyboard trigger to this button. The last button defined with a mapping wins.
* @property {string} [icon] - An emoji string to display next to the button label.
*/
/**
* @typedef {Object} ShowBoxOptions
* @property {string} [title] - Optional title heading shown at the top of the box.
* @property {string} message - The main text description or question shown in the prompt body.
* @property {"error" | "question" | "warn" | "info" | string} [icon] - A predefined alert keyword or a custom emoji string.
* @property {BoxButton[]} buttons - An array of configuration objects defining the prompt's action buttons.
* @property {BoxField[]} [fields] - An optional array of input definitions to inject form controls into the prompt body.
* @property {string} [className] - An optional top-level class name added to the dialog element for custom CSS targeting.
* @property {boolean} [isDark] - Explicit theme toggle. If omitted, matches system preference dynamically.
*/
/**
* @typedef {Object} ShowBoxResponse
* @property {BoxButton} button - The configuration object of the button that was clicked or triggered via keyboard shortcuts.
* @property {Object<string, string>} data - A collection of key-value pairs matching form field values, keyed by their field `label`.
*/
/**
* Displays a custom, accessible async modal prompt using the HTML5 `<dialog>` API.
* Supports theme sensing, dynamic layout injects, form captures, and custom button bindings.
*
* @global
* @function showBox
* @param {ShowBoxOptions} options - The explicit configuration layout parameter object.
* @returns {Promise<ShowBoxResponse>} A promise that resolves when a selection action completes.
*/
var showBox = (() => {
function ensureStyles() {
if (document.getElementById("custom-msgbox-styles")) return;
const style = document.createElement("style");
style.id = "custom-msgbox-styles";
style.textContent = `
:root {
--mb-bg: #ffffff;
--mb-text: #212529;
--mb-border: #dee2e6;
--mb-input-bg: #ffffff;
--mb-input-text: #212529;
--mb-input-border: #ced4da;
--mb-desc-text: #6c757d;
--btn-bg: #0d6efd;
--btn-text: #ffffff;
--backdrop-bg: rgba(0, 0, 0, 0.5);
}
/* Default automatic dark mode detection fallback */
@media (prefers-color-scheme: dark) {
.custom-msgbox:not([data-theme="light"]) {
--mb-bg: #212529;
--mb-text: #f8f9fa;
--mb-border: #495057;
--mb-input-bg: #2b3035;
--mb-input-text: #f8f9fa;
--mb-input-border: #495057;
--mb-desc-text: #adb5bd;
--btn-bg: #0d6efd;
--btn-text: #ffffff;
--backdrop-bg: rgba(0, 0, 0, 0.7);
}
}
/* Explicit theme overrides passed via options */
.custom-msgbox[data-theme="dark"] {
--mb-bg: #212529;
--mb-text: #f8f9fa;
--mb-border: #495057;
--mb-input-bg: #2b3035;
--mb-input-text: #f8f9fa;
--mb-input-border: #495057;
--mb-desc-text: #adb5bd;
--btn-bg: #0d6efd;
--btn-text: #ffffff;
--backdrop-bg: rgba(0, 0, 0, 0.7);
}
.custom-msgbox[data-theme="light"] {
--mb-bg: #ffffff;
--mb-text: #212529;
--mb-border: #dee2e6;
--mb-input-bg: #ffffff;
--mb-input-text: #212529;
--mb-input-border: #ced4da;
--mb-desc-text: #6c757d;
--btn-bg: #0d6efd;
--btn-text: #ffffff;
--backdrop-bg: rgba(0, 0, 0, 0.5);
}
.custom-msgbox {
border: 1px solid var(--mb-border);
border-radius: 8px;
padding: 24px;
background: var(--mb-bg);
color: var(--mb-text);
max-width: 440px;
width: 100%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
font-family: system-ui, -apple-system, sans-serif;
}
.custom-msgbox::backdrop {
background: var(--backdrop-bg);
backdrop-filter: blur(2px);
}
.msgbox-content {
display: flex;
flex-direction: column;
gap: 18px;
margin: 0;
}
.msgbox-title {
margin: 0;
font-size: 1.25rem;
border-bottom: 1px solid var(--mb-border);
padding-bottom: 10px;
}
.msgbox-body {
display: flex;
align-items: center;
gap: 12px;
font-size: 1rem;
line-height: 1.5;
}
.msgbox-icon {
font-size: 2rem;
}
.msgbox-fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.msgbox-field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.msgbox-field-label {
font-weight: 600;
font-size: 0.9rem;
}
.msgbox-field-desc {
margin: 0 0 2px 0;
font-size: 0.8rem;
color: var(--mb-desc-text);
}
.msgbox-input {
width: 100%;
box-sizing: border-box;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--mb-input-border);
background-color: var(--mb-input-bg);
color: var(--mb-input-text);
font-family: inherit;
font-size: 0.95rem;
}
.msgbox-input:focus {
outline: 2px solid var(--btn-bg);
outline-offset: -1px;
}
.msgbox-textarea {
min-height: 80px;
resize: vertical;
}
.msgbox-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.msgbox-btn {
padding: 8px 18px;
border: none;
border-radius: 4px;
background-color: var(--btn-bg);
color: var(--btn-text);
cursor: pointer;
font-weight: 500;
font-size: 0.95rem;
}
.msgbox-btn:hover {
filter: brightness(0.9);
}
`;
document.head.appendChild(style);
}
return function ({ title, message, icon, buttons, fields, className, isDark }) {
return new Promise((resolve) => {
ensureStyles();
const dialog = document.createElement("dialog");
dialog.className = `custom-msgbox ${className || ""}`;
// Apply theme dataset if explicitly specified
if (typeof isDark === "boolean") {
dialog.setAttribute("data-theme", isDark ? "dark" : "light");
}
const iconMap = {
error: "❌",
question: "❓",
warn: "⚠️",
info: "ℹ️",
};
const iconEmoji = iconMap[icon] || icon || "";
dialog.innerHTML = `
<form method="dialog" class="msgbox-content">
${title ? `<h3 class="msgbox-title">${title}</h3>` : ""}
<div class="msgbox-body">
${iconEmoji ? `<span class="msgbox-icon">${iconEmoji}</span>` : ""}
<span class="msgbox-message">${message}</span>
</div>
${fields && fields.length ? `<div class="msgbox-fields"></div>` : ""}
<div class="msgbox-buttons"></div>
</form>
`;
const fieldsContainer = dialog.querySelector(".msgbox-fields");
const buttonContainer = dialog.querySelector(".msgbox-buttons");
// Render Form Fields
const fieldInputs = {};
if (fields && fields.length) {
fields.forEach((field, idx) => {
const fieldWrapper = document.createElement("div");
fieldWrapper.className = "msgbox-field-group";
const id = `mb-field-${idx}-${Date.now()}`;
let labelHtml = `<label for="${id}" class="msgbox-field-label">${field.label}</label>`;
let descHtml = field.description ? `<p class="msgbox-field-desc">${field.description}</p>` : "";
let inputHtml = "";
if (field.type === "textarea") {
inputHtml = `<textarea id="${id}" class="msgbox-input msgbox-textarea">${field.value || ""}</textarea>`;
} else if (field.type === "select") {
const optionsHtml = (field.options || []).map(opt => {
const val = opt.value !== undefined ? opt.value : opt.label;
const selected = val === field.value ? "selected" : "";
return `<option value="${val}" ${selected}>${opt.label}</option>`;
}).join("");
inputHtml = `<select id="${id}" class="msgbox-input msgbox-select">${optionsHtml}</select>`;
} else {
inputHtml = `<input type="text" id="${id}" class="msgbox-input" value="${field.value || ""}" />`;
}
fieldWrapper.innerHTML = `${labelHtml}${descHtml}${inputHtml}`;
fieldsContainer.appendChild(fieldWrapper);
fieldInputs[field.label] = fieldWrapper.querySelector(".msgbox-input");
});
}
const getFormData = () => {
const data = {};
for (const [label, inputNode] of Object.entries(fieldInputs)) {
data[label] = inputNode.value;
}
return data;
};
// Button processing
let enterBtnIndex = -1;
let escBtnIndex = -1;
buttons.forEach((btn, index) => {
if (btn.key === "enter") enterBtnIndex = index;
if (btn.key === "esc") escBtnIndex = index;
});
buttons.forEach((btn) => {
const buttonEl = document.createElement("button");
buttonEl.type = "button";
buttonEl.className = "msgbox-btn";
if (btn.color) buttonEl.style.setProperty("--btn-bg", btn.color);
buttonEl.innerHTML = btn.icon ? `${btn.icon} ${btn.label}` : btn.label;
buttonEl.addEventListener("click", () => {
cleanup();
resolve({ button: btn, data: getFormData() });
});
buttonContainer.appendChild(buttonEl);
});
const handleKeyDown = (e) => {
if (e.key === "Enter" && document.activeElement.tagName === "TEXTAREA") {
return;
}
if (e.key === "Enter" && enterBtnIndex !== -1) {
e.preventDefault();
cleanup();
resolve({ button: buttons[enterBtnIndex], data: getFormData() });
} else if (e.key === "Escape") {
e.preventDefault();
if (escBtnIndex !== -1) {
cleanup();
resolve({ button: buttons[escBtnIndex], data: getFormData() });
}
}
};
function cleanup() {
window.removeEventListener("keydown", handleKeyDown);
dialog.close();
dialog.remove();
}
window.addEventListener("keydown", handleKeyDown);
document.body.appendChild(dialog);
dialog.showModal();
});
};
})();

showBox() 📦

A lightweight, high-performance, and dependency-free asynchronous modal prompt library for modern web browsers. Built on vanilla JavaScript and the native HTML5 <dialog> API, showBox combines the simplicity of classic Visual Basic alert structures with modern web workflows like asynchronous promises, accessibility, and dynamic theme detection.

✨ Key Features

  • Zero Dependencies: Written in pure vanilla JS—no React, Vue, or bulky UI libraries required.
  • Native Accessibility: Utilizes the HTML5 <dialog> engine (.showModal()), which inherently traps focus and darkens backgrounds out-of-the-box.
  • Dynamic Form Capture: Easily inject text, textarea, or select inputs right inside your box and receive the formatted payload on close.
  • Keyboard Shortcut Interceptors: Intelligently binds Enter (Submit) and Escape (Cancel) macros to designated buttons (with safeguards ensuring Enter doesn't break <textarea> formatting).
  • Automatic & Forced Theme Tuning: Automatically responds to OS/Browser theme targets (@media prefers-color-scheme) with provisions to manually force-toggle isDark: true|false layouts at runtime.
  • Pristine Global Scope: Encapsulated inside an IIFE, exporting exactly one clean function to the global footprint: showBox.

🚀 Quick Start & API Example

Copy the function source code into your project, then call it using async/await syntax:

const response = await showBox({
  title: "Database Cluster Update",
  message: "Please complete your configurations before firing the deployment trigger.",
  icon: "warn", // Uses built-in ⚠️ wrapper (or pass custom emoji string like "🚀")
  isDark: true, // Optional override: explicitly force dark mode
  fields: [
    { 
      label: "Username", 
      type: "text", 
      value: "admin" 
    },
    { 
      label: "Environment", 
      type: "select", 
      options: [
        { label: "Development", value: "dev" },
        { label: "Staging", value: "stage" },
        { label: "Production", value: "prod" }
      ],
      value: "dev" 
    },
    { 
      label: "Deploy Notes", 
      description: "Any extra deployment remarks go here.", 
      type: "textarea" 
    }
  ],
  buttons: [
    { label: "Abort Changes", key: "esc", color: "#6c757d" },
    { label: "Authorize Push", key: "enter", icon: "💾" }
  ]
});

// Capture results
console.log("Clicked Button Object:", response.button);
console.log("Captured Data Payload:", response.data); 
/* Output 'response.data':
{
  "Username": "admin",
  "Environment": "dev",
  "Deploy Notes": "Your custom notes text..."
}
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment