API reference
Forge Dialog is a dependency-free TypeScript dialog library built on the native
<dialog> element. Everything on this page is in the published package —
there are no plugins to install and no peer dependencies to satisfy.
Installation
Install from npm. The package name is one word; the library is called Forge Dialog.
npm install forgedialog
Import the API and a stylesheet:
import { alert, confirm, prompt, open } from 'forgedialog';
import 'forgedialog/style.css';
Or load the browser build directly. It defines the global
ForgeDialog — a JavaScript identifier, so it stays one word.
<link rel="stylesheet" href="https://unpkg.com/forgedialog/dist/index.css" />
<script src="https://unpkg.com/forgedialog/dist/index.global.js"></script>
<script>
ForgeDialog.alert('Hello!');
</script>
Requires Chrome/Edge 88+, Firefox 78+, Safari 15.4+, or Node.js 20+ for package tooling and server-side imports.
Entry points
Every entry point is tree-shakable and has its own gzip budget enforced in CI. Import the narrowest one that covers what you need.
| Import | Contains |
|---|---|
forgedialog |
Everything, with dragging, animations, and the full appearance applier already enabled. |
forgedialog/core |
open(), alert, confirm,
prompt, theming, labels, plugins, and the full appearance applier.
|
forgedialog/alert |
alert() alone. |
forgedialog/confirm |
confirm() alone. |
forgedialog/prompt |
prompt() alone. |
forgedialog/presentation |
drawer, bottomSheet, lightbox,
loading, toast, commandPalette,
notificationCenter.
|
forgedialog/workflows |
form, formWizard, wizard. |
forgedialog/interactions |
The draggable controller. Side-effectful: importing it enables dragging. |
forgedialog/animations |
Animation presets. Side-effectful: importing it enables animations. |
forgedialog/appearance |
The full appearance applier. Side-effectful: importing it upgrades the focused entries. |
forgedialog/react |
useForgeDialog() for React. |
forgedialog/vue |
useForgeDialog() for Vue. |
forgedialog/svelte |
The dialogTrigger action. |
forgedialog/web-component |
defineForgeDialog() and ForgeDialogElement. |
Capability imports
The single-purpose entries (/alert, /confirm,
/prompt) ship a lightweight appearance applier covering surface opacity,
backdrop, border, and shadow presets. Add capabilities by importing them for their side
effect:
import 'forgedialog/appearance'; // per-component colour, radius, hover
import 'forgedialog/interactions'; // dragging
import 'forgedialog/animations'; // animation presets
import { alert } from 'forgedialog/alert';
forgedialog and forgedialog/core already include the full
appearance applier, so those imports are only needed alongside a focused entry.
Stylesheets
forgedialog/style.css is the all-in-one sheet. To ship less CSS, compose
only the layers a page uses — core.css is always required.
import 'forgedialog/style/core.css'; // required
import 'forgedialog/style/forms.css'; // form fields and file dropzones
import 'forgedialog/style/workflows.css'; // wizard stepper
import 'forgedialog/style/toast.css';
import 'forgedialog/style/lightbox.css';
import 'forgedialog/style/command.css';
import 'forgedialog/style/draggable.css';
All rules live in the forgedialog cascade layer, so your own unlayered CSS
wins without needing !important.
alert, confirm, prompt
Promise-returning replacements for the browser built-ins.
| Signature | Resolves to |
|---|---|
alert(message, options?) |
Promise<void> |
confirm(message, options?) |
Promise<boolean> — false on Escape or backdrop
close
|
prompt(message, options?) |
Promise<string | null> — null when cancelled
|
await alert('Saved successfully.', { title: 'Success' });
const ok = await confirm('Delete this item?', { title: 'Please confirm' });
const name = await prompt('What is your name?', {
defaultValue: 'Ada Lovelace',
inputType: 'text',
inputLabel: 'Full name',
placeholder: 'Type a name',
validate: (value) => (value.trim() ? true : 'Name is required'),
});
alert and confirm get
role="alertdialog" automatically. prompt accepts the extra
options above on top of every DialogOptions field.
open()
The low-level API. It returns a DialogInstance immediately rather than a promise, so the dialog can be updated, moved, or closed from anywhere.
import { open } from 'forgedialog';
const dialog = open({
title: 'Custom dialog',
content: (container) => container.append(renderForm()),
buttons: [
{ text: 'Cancel', onClick: (i) => i.close(null) },
{ text: 'Save', role: 'primary', autoFocus: true, onClick: (i) => i.close(read()) },
],
});
const result = await dialog.whenClosed(); // the result, or undefined
const { result: r, reason } = await dialog.whenSettled(); // result plus why it closed
Content and untrusted HTML
content accepts a string (rendered as text), an element,
or a builder function. Markup needs an explicit decision:
// Untrusted markup: pass a sanitizer.
open({ html: userContent, sanitizeHtml: (h) => DOMPurify.sanitize(h) });
// Markup you already trust.
open({ unsafeHtml: '<p>Generated by us</p>' });
Close reasons
Every close carries a reason: 'button', 'escape',
'backdrop', 'api', 'abort', or
'destroy'. Read it with whenSettled().
DialogOptions
Accepted by open() and by every helper built on it.
| Option | Type | Description |
|---|---|---|
title |
string |
Header text. Omitted titles fall back to a visually hidden label so the dialog stays named for assistive technology. |
message |
string | HTMLElement |
Body text or element. |
content |
string | HTMLElement | (container) => void |
Body content. Strings render as text. |
html |
string |
Markup, rendered only when sanitizeHtml is also supplied. |
unsafeHtml |
string |
Markup inserted as-is. Only for content you control. |
sanitizeHtml |
(html: string) => string |
Sanitizer applied to html. |
buttons |
ButtonConfig[] |
Footer buttons. See below. |
type |
'alert' | 'confirm' | 'prompt' | 'form' | 'wizard' | 'custom'
|
Semantic kind. alert and confirm default to
role="alertdialog".
|
role |
'dialog' | 'alertdialog' |
Overrides the role derived from type. |
size |
'sm' | 'md' | 'lg' | 'xl' | 'fullscreen' |
Width preset. |
presentation |
'modal' | 'drawer-left' | 'drawer-right' | 'bottom-sheet' | 'lightbox'
|
How the dialog enters and where it sits. |
animation |
'fade' | 'scale' | 'slide' | 'spring' | 'bounce' | 'blur' | 'none'
|
Requires the animations capability. Honours prefers-reduced-motion.
|
appearance |
DialogAppearance |
Per-dialog styling. See Appearance. |
draggable |
boolean | DraggableOptions |
See Dragging. |
closable |
boolean |
Shows the header close button. Default true. |
closeOnEscape |
boolean |
Default true. |
closeOnOverlayClick |
boolean |
Default true. |
initialFocus |
string | HTMLElement | (root) => HTMLElement | null |
What receives focus on open. Defaults to the first tabbable element. |
restoreFocus |
boolean |
Returns focus to the trigger on close. Default true. |
portalTarget |
HTMLElement |
Where the dialog mounts. Defaults to document.body. |
className |
string |
Extra classes on the dialog element. |
labels |
Partial<DialogLabels> |
Per-dialog label overrides. |
signal |
AbortSignal |
Aborting closes the dialog with reason 'abort'. |
data |
unknown |
Arbitrary payload, readable from hooks. |
onOpen |
(instance) => void | Promise<void> |
After the dialog opens. |
onBeforeClose |
(instance, result) => boolean | void | Promise<…> |
Return false to keep it open. |
onClose |
(instance, result) => void | Promise<void> |
After the dialog closes. |
onError |
(error, instance) => void |
Observes lifecycle failures, which are cleaned up automatically. |
ButtonConfig
| Field | Type | Description |
|---|---|---|
text |
string |
Label. Required. |
role |
'primary' | 'secondary' | 'danger' |
Visual weight. |
onClick |
(instance) => void | Promise<void> |
Click handler. |
closesDialog |
boolean |
Closes the dialog after onClick. |
result |
TResult |
Value the dialog resolves with when this button closes it. |
autoFocus |
boolean |
Receives focus on open. |
disabled |
boolean |
Renders the button disabled. |
id |
string |
Element id, useful for tests. |
DialogInstance
| Member | Returns | Description |
|---|---|---|
open() |
Promise<void> |
Opens a dialog that is not open yet. |
close(result?, reason?) |
Promise<void> |
Closes with a result. |
cancel(reason?) |
Promise<void> |
Closes without a result. |
destroy() |
Promise<void> |
Closes and removes the element and its listeners. |
update(partialOptions) |
void |
Restyles or re-renders a dialog in place, while it is open. |
whenClosed() |
Promise<TResult | undefined> |
Resolves with the result. |
whenSettled() |
Promise<{ result, reason }> |
Resolves with the result and the close reason. |
isOpen() |
boolean |
Whether it is currently open. |
getState() |
DialogState |
'idle' | 'opening' | 'open' | 'closing' | 'closed' | 'destroyed'.
|
getPosition() |
{ x, y } |
Current drag offset. |
setPosition({ x, y }) |
{ x, y } |
Moves the dialog, clamped to its bounds. |
resetPosition() |
void |
Returns it to centre. |
element |
HTMLElement |
The dialog element. |
id |
string |
Unique id. |
Appearance
Appearance overrides are scoped to one dialog and can be changed at runtime with
update(). Opacity values are clamped to 0..1; plain numbers
for widths, radii, and blur are read as pixels.
| Field | Type | Applies to |
|---|---|---|
opacity |
number |
The whole dialog surface. |
surfaceColor |
string |
Dialog background. |
overlayOpacity |
number |
The backdrop behind it. |
backdropBlur |
number | string |
Backdrop blur radius. |
titleColor |
string |
Header text. |
titleBackground |
string |
Header fill. Transparent by default and clipped by the dialog's corners. |
titleOpacity |
number |
Header. |
contentColor |
string |
Body text. |
contentOpacity |
number |
Body. |
borderColor |
string |
Border. |
borderOpacity |
number |
Border. |
borderWidth |
number | string |
Border. |
borderStyle |
'none' | 'solid' | 'dashed' | 'dotted' | 'double' |
Border. |
radius |
number | string | DialogCornerRadius |
Corners. See Corner radius. |
shadow |
ShadowPreset | string | DialogShadowConfig |
See Shadows. |
hover |
DialogHoverAppearance |
See Hover. |
open({
title: 'Styled',
appearance: {
opacity: 0.96,
surfaceColor: '#12141a',
overlayOpacity: 0.55,
backdropBlur: 12,
titleColor: '#ffd166',
titleBackground: '#1b1740',
contentColor: '#c8ccd4',
borderColor: '#7c5cff',
borderWidth: 2,
radius: 20,
shadow: { angle: 135, distance: 24, blur: 60, opacity: 0.45 },
},
});
The full applier ships with forgedialog and forgedialog/core.
On a focused entry, add import 'forgedialog/appearance' — without it the
richer fields are ignored rather than throwing.
Shadows
shadow takes three shapes.
| Form | Example |
|---|---|
| A preset |
shadow: 'xl' — one of none, sm,
md, lg, xl
|
| Any CSS box-shadow | shadow: '0 10px 40px rgb(0 0 0 / 30%)' |
| Composed from parts | shadow: { angle: 90, distance: 18, blur: 48, opacity: 0.3 } |
| Part | Type | Meaning |
|---|---|---|
angle |
number |
Direction the shadow falls, in degrees: 0 up,
90 right, 180 down (the default),
270 left.
|
distance |
number | string |
How far it is thrown. |
blur |
number | string |
Softness. |
spread |
number | string |
Growth before blurring. |
color |
string |
Shadow colour. |
opacity |
number |
Strength, 0..1. |
inset |
boolean |
Casts the shadow inwards. |
Corner radius
radius takes one value for every corner, any CSS
border-radius string, or a per-corner object. Corners left out of that
object keep the theme radius rather than collapsing to zero.
appearance: { radius: 20 }
appearance: { radius: '20px 20px 4px 4px' }
appearance: { radius: { topLeft: 24, topRight: 24, bottomRight: 4, bottomLeft: 4 } }
Drawers stay square and bottom sheets round only their top corners unless
radius says otherwise.
Hover
hover restyles a dialog only while the pointer is over it, and only for
dialogs that ask for it — every other dialog stays completely static. It reuses the
colour, radius, and shadow fields and adds three of its own.
| Field | Type | Description |
|---|---|---|
lift |
number | string |
How far the dialog rises. |
scale |
number |
Scale factor, e.g. 1.02. |
duration |
number | string |
Transition length. |
Hover transforms honour prefers-reduced-motion and are suspended while a
dialog is being dragged, so they cannot push it outside its bounds.
Dragging
draggable: true is enough to move a dialog by its header. The object form
adds constraints, keyboard support, and persistence. Dragging needs the interactions
capability, which forgedialog enables for you.
| Option | Type | Description |
|---|---|---|
handle |
'header' | string | HTMLElement |
What starts a drag. Defaults to the header. |
axis |
'both' | 'x' | 'y' |
Locks movement to one axis. |
bounds |
'viewport' | HTMLElement | DOMRect |
Area the dialog is clamped to. |
initialPosition |
{ x, y } |
Starting offset. |
keyboard |
boolean |
Arrow-key movement when the handle has focus. |
keyboardStep |
number |
Pixels per key press. |
persistKey |
string |
Remembers the position across sessions under this key. |
onDragStart / onDrag / onDragEnd |
(event) => void |
Each receives { position, originalEvent }. |
Bottom sheets keep their own swipe-to-dismiss gesture and ignore general dragging.
Drawers, sheets, lightboxes, loading
import { drawer, bottomSheet, lightbox, loading } from 'forgedialog';
drawer({ title: 'Settings', side: 'right', content: renderSettings });
bottomSheet({ title: 'Share', content: renderShare });
lightbox('/photo.jpg', { alt: 'A photo', caption: 'Taken in 2026' });
const task = loading('Uploading…');
task.update('Almost there…');
await task.close();
These are open() with a presentation preset, so every
DialogOptions field still applies.
Forms
form() builds a validated dialog from a field list and resolves with the
values, or null if cancelled. Field names are inferred into the result
type, so values.email is typed without a cast.
import { form } from 'forgedialog';
const values = await form(
[
{ name: 'email', type: 'email', label: 'Email', required: true },
{ name: 'plan', type: 'select', label: 'Plan', options: [
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
] },
{ name: 'notes', type: 'textarea', label: 'Notes', rows: 4 },
],
{ title: 'Sign up', submitText: 'Create account' },
);
Field types: text, password, email,
number, date, textarea, select,
checkbox, radio, file. Each field takes
label, defaultValue, placeholder,
helpText, required, and a validate function;
numbers add min, max, step, and files add
accept, multiple, maxFiles, and
maxSizeBytes.
formWizard(steps, options?) spreads the same fields over several steps,
with nextText, backText, finishText, and an
onStepChange callback.
Wizards
wizard() is the general form: each step renders whatever it likes, can
validate, and can choose the next step from the data collected so far.
import { wizard } from 'forgedialog';
const flow = wizard({
initialData: { email: '', plan: 'free' },
persistKey: 'signup', // resumes where the user left off
confirmUnsaved: true, // asks before discarding progress
steps: [
{ id: 'account', title: 'Account', render: renderAccount, validate: validateAccount },
{ id: 'plan', title: 'Plan', render: renderPlan,
next: (data) => (data.plan === 'pro' ? 'billing' : 'review') },
{ id: 'billing', title: 'Billing', render: renderBilling },
{ id: 'review', title: 'Review', render: renderReview },
],
});
const data = await flow.result; // the data, or null if cancelled
The controller also exposes next(), back(),
goTo(stepId), getData(), and the underlying
instance.
Toasts and notifications
import { toast, notificationCenter, getNotificationHistory } from 'forgedialog';
toast('Saved', { tone: 'success' });
// A duration of 0 or Infinity keeps it up until dismiss() is called.
const upload = toast('Uploading…', { duration: 0 });
upload.dismiss();
toast('Message sent', { action: { text: 'Undo', onClick: undoSend } });
notificationCenter(); // a dialog listing past toasts
getNotificationHistory(); // the same list as data
Tones are info, success, warning, and
danger. clearNotificationHistory() empties the log.
Command palette
import { commandPalette } from 'forgedialog';
commandPalette([
{ id: 'new', label: 'New document', shortcut: '⌘N', run: createDocument },
{ id: 'search', label: 'Search', keywords: ['find', 'filter'], run: openSearch },
]);
Typing filters on the label and on keywords; the palette resolves with the
id of the command that ran.
Theming
import { setTheme, getTheme, setThemePreset } from 'forgedialog';
setTheme('dark'); // 'light' | 'dark' | 'system'
getTheme();
setThemePreset('glass'); // 'default' | 'minimal' | 'glass' | 'material'
setTheme() writes data-fd-theme on <html>.
Your own stylesheet can read the same attribute so the page and the dialogs switch
together — that is exactly what this site does.
CSS custom properties
Everything visual is a variable on :root, so a theme can be written without
touching JavaScript.
| Variable | Default |
|---|---|
--fd-color-surface |
#ffffff |
--fd-color-text |
#1a1d23 |
--fd-color-text-muted |
#5b6270 |
--fd-color-border |
#e2e4e9 |
--fd-color-overlay |
rgba(15, 17, 21, 0.5) |
--fd-color-primary / --fd-color-primary-text |
#315fce / #ffffff |
--fd-color-secondary / --fd-color-secondary-text
|
#eceef2 / #1a1d23 |
--fd-color-danger / --fd-color-danger-text |
#e5484d / #ffffff |
--fd-radius |
10px |
--fd-shadow |
0 20px 60px rgba(15, 17, 21, 0.25) |
--fd-spacing-sm / md / lg |
8px / 16px / 24px |
--fd-font-family |
system UI stack |
--fd-duration-fast / --fd-duration-normal |
120ms / 200ms |
--fd-easing |
cubic-bezier(0.16, 1, 0.3, 1) |
--fd-z-index-base |
1000 |
The per-dialog variables the appearance API writes (--fd-dialog-radius,
--fd-dialog-shadow, --fd-dialog-title-background, and the
rest) can also be set by hand if you would rather style in CSS than in JavaScript.
Labels and i18n
import { setLabels, getLabels } from 'forgedialog';
setLabels({
ok: 'Đồng ý',
cancel: 'Huỷ',
close: 'Đóng',
submit: 'Gửi',
fieldRequired: 'Bắt buộc',
promptPlaceholder: 'Nhập nội dung',
notifications: 'Thông báo',
});
Labels set this way apply everywhere; a single dialog can override them with its own
labels option.
Plugins and hooks
Five lifecycle hooks fire for every dialog: beforeOpen,
afterOpen, beforeClose, afterClose, and
beforeDestroy.
import { on, off, registerPlugin } from 'forgedialog';
// A single hook.
on('afterOpen', ({ instance, options }) => track('dialog', options.title));
// Or a plugin bundling several.
registerPlugin({
name: 'analytics',
install: (api) => api.on('afterClose', ({ reason }) => track('close', reason)),
hooks: {
beforeClose: ({ preventClose, result }) => {
if (result === undefined) preventClose?.();
},
},
});
A hook receives { instance, options, result, reason, preventClose }.
Calling preventClose() in beforeClose keeps the dialog open.
Framework adapters
Each adapter is a thin wrapper over open() that ties a dialog's lifetime to
the component's, so nothing is left mounted after unmount.
// React and Vue share the same hook shape.
import { useForgeDialog } from 'forgedialog/react';
const { open } = useForgeDialog();
await open({ title: 'Hi' }).whenClosed();
// Svelte: an action on the triggering element.
import { dialogTrigger } from 'forgedialog/svelte';
// <button use:dialogTrigger={{ title: 'Hi' }}>Open</button>
// Web component.
import { defineForgeDialog } from 'forgedialog/web-component';
defineForgeDialog(); // <forge-dialog open title="Hi" message="…" size="md">
TypeScript, SSR, and CSP
-
Types ship for both ESM and CJS.
open<T>()threads its result type throughbuttons,whenClosed(), and the hooks. - Importing the package on a server does not touch the DOM, so it is safe in SSR builds; the dialog is only created when you call the API in a browser.
-
The bundle contains no
evaland nonew Function, which is enforced in CI, so it runs under a strict Content Security Policy. This site is served under one. - Every entry point has an enforced gzip budget, checked on each pull request.