Rich, custom desktop notifications for any Electron application.
Toasts, action buttons, inline quick reply, progress bars and image cards, stacked in a single host window, with correct behavior on Windows, macOS and Linux.
![]() |
![]() |
- Why
- Installation
- Quick start
- Architecture
- Variants
- API reference
- Configuration
- Theming
- Platform support
- Works in any Electron app
- Migrating from v1
- Development
The built-in Notification API gives you a title, a body and an icon. Anything richer, such as a reply field, a progress bar, or action buttons that also work off macOS, means building windows yourself, and that is where the hard parts live: measuring content height, stacking, repositioning on dismiss, not stealing focus, not blocking app shutdown.
PingMeUp is that layer, done once:
| Five variants | toast, actions, input (quick reply), progress, image |
| One window for the whole stack | A single renderer process, not one per notification |
| Content-measured height | Cards grow to fit real text, so nothing is clipped and no space is left over |
| Never steals focus | The window becomes focusable only while a text field is on screen |
| Click-through | Gaps between cards don't swallow clicks |
| Queue, tags, multi-monitor | maxVisible overflow queue, replace-by-tag, per-display targeting |
| Zero setup | Pure JavaScript, no build step, works inside asar |
npm install pingmeupElectron 25 or newer is a peer dependency. All calls happen in the main process.
const { app } = require('electron');
const pingmeup = require('pingmeup');
pingmeup.configure({ appName: 'My App' });
app.whenReady().then(() => {
pingmeup.success('Deploy finished', 'Version 2.0.0 is live');
});Calls made before app.whenReady() are queued, not lost. The host window is created on the first notification and released when the last one disappears.
PingMeUp keeps one host window per monitor: a transparent, frameless column anchored to the configured edge, sized to the cards it is showing. Every card lives inside it.
the rest of the screen is not part of the window
┌─ host window (transparent, fits the stack) ─┐
│ ┌───────────┐ │
│ click-through in the gaps │ card 3 │ │
│ ├───────────┤ │
│ │ card 2 │ │
│ ├───────────┤ │
│ │ card 1 │ │
└──────────────────────────────└───────────┘──┘
This is what makes the rest work:
- Stacking, reordering, entering and leaving are pure CSS. The main process never repositions windows, so there is none of the stutter you get from moving N windows at once.
- Height is measured from the rendered content before the enter animation starts, so a card is exactly as tall as its text.
- The window is only as tall as the stack. Showing one card on a 1080p screen takes a window of about 125px, not the 1032px of the work area, so the screen above the cards is never the notification window's to intercept. The window resizes once per card that enters or leaves, never per animation frame.
- Inside the window, only the card rectangles capture the mouse. The gaps between cards and the padding around them stay click-through, and the main process decides from the real cursor position, so a card closing under a resting pointer cannot leave the region stuck.
- The window is focusable only while an
inputcard exists, so a plain toast can never pull you out of what you were doing.
Icon, title, body and a timer bar. Auto-dismisses; hovering pauses the countdown.
pingmeup.notify({
title: 'Backup complete',
body: '1,204 files archived.',
type: 'success',
duration: 5000,
data: { jobId: 42 },
onClick: data => openJob(data.jobId),
});Decision buttons. Never auto-dismisses.
pingmeup.notify({
title: 'New order #1234',
body: 'Maria Santos, $45.90',
type: 'success',
variant: 'actions',
actions: [
{ id: 'reject', label: 'Reject', danger: true },
{ id: 'accept', label: 'Accept', primary: true },
],
onAction: actionId => console.log(actionId), // 'reject' | 'accept'
});An inline text field. Enter triggers the primary action, Esc closes. Focusing the field cancels auto-dismiss permanently, so a half-typed reply is never thrown away.
pingmeup.notify({
title: 'João Silva',
body: 'Do you have soda?',
type: 'message',
variant: 'input',
inputPlaceholder: 'Type your reply...',
actions: [
{ id: 'open', label: 'Open' },
{ id: 'send', label: 'Send', primary: true },
],
onAction: (actionId, text) => {
if (actionId === 'send') sendMessage(text);
},
});Determinate or indeterminate progress. Never auto-dismisses.
const id = pingmeup.notify({ title: 'Syncing', variant: 'progress' });
pingmeup.updateProgress(id, 50, '50 of 100 files');
pingmeup.updateProgress(id, 100, 'Done');
setTimeout(() => pingmeup.dismiss(id), 1500);
pingmeup.notify({ title: 'Connecting', variant: 'progress', indeterminate: true });An avatar or thumbnail in place of the type icon.
pingmeup.notify({
title: 'New profile photo',
body: 'Maria Santos updated her picture.',
variant: 'image',
image: 'https://example.com/avatar.jpg',
});Returns the notification id as a string.
| Option | Type | Default | Description |
|---|---|---|---|
title |
string |
'' |
Heading |
body |
string |
'' |
Message text (alias: message) |
type |
'info' | 'success' | 'warning' | 'error' | 'message' |
'info' |
Icon and accent color |
variant |
'toast' | 'actions' | 'input' | 'progress' | 'image' |
'toast' |
Layout |
icon |
string |
none | URL or file path; replaces the type icon |
image |
string |
none | URL or file path (image variant) |
duration |
number |
5000 |
ms; 0 never expires. actions and progress force 0 |
progress |
number |
0 |
0 to 100 |
indeterminate |
boolean |
false |
Progress bar without a known percentage |
actions |
Action[] |
[] |
Up to 4: { id, label, primary?, danger? } |
inputPlaceholder |
string |
'Type here...' |
|
inputValue |
string |
'' |
Initial value |
inputMultiline |
boolean |
false |
Use a textarea |
tag |
string |
none | Replaces the previous notification with the same tag |
priority |
'low' | 'normal' | 'high' | 'critical' |
'normal' |
critical bypasses do-not-disturb and focus suppression |
requireInteraction |
boolean |
false |
Never auto-dismiss |
sound |
SoundSpec |
config | See sound |
silent |
boolean |
false |
Mute this one notification |
display |
'primary' | 'cursor' | 'active' | number |
config | Target monitor |
useNative |
boolean |
false |
Force the OS Notification for this call |
data |
object |
{} |
Arbitrary payload forwarded to every callback |
Callbacks: onShow(data), onClick(data), onAction(actionId, inputValue, data), onClose(data), onDismiss(reason, data).
pingmeup.update(id, { title, body, progress, indeterminate, type, icon, image }); // boolean
pingmeup.updateProgress(id, 75, 'Almost there'); // boolean
pingmeup.dismiss(id); // boolean
pingmeup.dismissAll();
pingmeup.dismissByTag('chat:123'); // number of notifications closed
pingmeup.isActive(id); // boolean
pingmeup.getActiveCount(); // currently on screen
pingmeup.getQueuedCount(); // waiting for a free slot
pingmeup.platformInfo(); // { platform, wayland, transparency, position, nativeBlur }const { info, success, warning, error, message } = require('pingmeup');
success('Saved', 'Your changes were stored.');
error('Upload failed', 'Check your connection.', { duration: 0 });pingmeup.on('action', ({ id, actionId, inputValue, data }) => { /* ... */ });Available events: show, click, action, close, dismiss, queued, suppressed.
Each instance owns its own config, queue and windows.
const { NotificationCenter } = require('pingmeup');
const alerts = new NotificationCenter({ position: 'top-right', maxVisible: 2 });
const chat = new NotificationCenter({ position: 'bottom-right', sound: 'ping' });pingmeup.configure({
appName: 'My App',
position: 'auto', // resolves to the OS convention
width: 360,
margin: 12,
gap: 10,
maxVisible: 4, // overflow goes to a queue
duration: 5000,
theme: 'auto', // follows nativeTheme
animation: 'slide', // 'slide' | 'fade' | 'scale' | 'none'
sound: false,
doNotDisturb: false,
suppressWhenFocused: false, // true, or a specific BrowserWindow
display: 'primary', // 'primary' | 'cursor' | 'active' | display id
fallbackToNative: true,
keepAlive: false,
allowFocusSteal: true,
logger: false, // true logs to console, or pass your own
});configure() can be called at any time; live windows reapply the layout immediately.
sound accepts false, true, 'chime', 'ping', 'alert' or { file, volume }. The presets are synthesized with the Web Audio API, so there are no bundled assets and the output is identical on all three platforms.
pingmeup.configure({ doNotDisturb: true }); // only priority: 'critical' gets through
pingmeup.configure({ suppressWhenFocused: mainWindow }); // stay quiet while the app is in fronttheme: 'auto' follows nativeTheme.shouldUseDarkColors. Override any token:
pingmeup.configure({
accents: {
info: '#60a5fa',
success: '#34d399',
message: '#a78bfa',
},
tokens: {
surface: 'rgba(20, 20, 24, 0.97)',
radius: '14px',
fg: '#ffffff',
},
});Every key in tokens becomes the CSS custom property --pmu-<key>. The full token list is at the top of renderer/host.css.
| Windows | macOS | Linux | |
|---|---|---|---|
| Default position | bottom right | top right | top center |
| Font stack | Segoe UI Variable | SF Pro / system | Inter, Ubuntu, Cantarell, Noto |
| Above fullscreen apps | yes | yes (screen-saver level, all workspaces) |
window-manager dependent |
| Click-through in gaps | setIgnoreMouseEvents with forward |
same | setShape from card rects, where the WM supports it |
| Transparency | yes | yes | detected; falls back to a solid surface without a compositor |
| Wayland | n/a | n/a | falls back to the native Notification |
Why Wayland falls back. The protocol does not let a client position its own windows: setPosition and setBounds are ignored by the compositor. A corner-anchored stack cannot be guaranteed, so delivering the system notification is more honest than an overlay in an arbitrary place. Set fallbackToNative: false to attempt the overlay anyway.
Native mode delivers title, body, icon and onClick everywhere; action buttons and quick reply on macOS only; progress nowhere.
Focus on macOS. Clicking a text field activates the whole application, which is inherent to the platform. Set allowFocusSteal: false to keep the window permanently non-focusable, which effectively downgrades the input variant to actions.
This is a design requirement, not a coincidence.
- Nothing to configure in your build. Plain JavaScript, no compile step, and it runs packed inside
asar. - IPC channels are namespaced under
pmu:, and handlers only accept messages originating from the library's own host windows, so no renderer in your app can forge or manipulate notifications. - Compatible with
sandbox: true,contextIsolation: trueandnodeIntegration: false. That is exactly how the host window itself runs. - Lazy initialization. Calling before
app.whenReady()is safe. - It will not interfere with your app's shutdown. The host window is released once the stack empties so it never holds back
window-all-closed. And when your app has no windows of its own, as in a tray-only app, the overlay is hidden instead of destroyed: destroying it would firewindow-all-closedin an app that never had a window, and Electron's default behavior for that event is to quit. A tray app would be killed by showing a notification. - Automatic cleanup on
before-quit. - Silent by default. Nothing is written to your app's stdout unless you ask for it (
logger: trueorPINGMEUP_DEBUG=1).
Version 1 declared an API in types.ts that the manager never implemented. Version 2 is a rewrite; the surface is close, but there are changes.
| v1 | v2 |
|---|---|
new ElectronNotificationManager() |
require('pingmeup') directly, or new NotificationCenter() |
manager.show({ message }) |
notify({ body }); message is still accepted as an alias |
await manager.show(...) |
notify(...) is synchronous and returns the id |
manager.close(id) |
dismiss(id) |
manager.closeAll() |
dismissAll() |
theme: 'windows' | 'macos' | 'linux' |
theme: 'auto' | 'light' | 'dark'; platform look is automatic |
'achievement', 'weather' and similar types |
removed; they were never implemented |
| nothing equivalent | variant, actions, input, progress, tag, queue, multi-monitor |
git clone https://github.com/ATLADevelopers/PingMeUp.git
cd PingMeUp
npm install
npm run exampleThe example app is a control panel covering every variant, position and theme, with a live event log.
npm run check:types # tsc against the public typings
npm run test:hit-region # drives a real host window: window size and click-through
npm run test:release # the rules that pick the next version numbertest:hit-region opens real notification windows for a few seconds, so expect them on screen while it runs.
src/
index.js public API and default center
manager.js lifecycle, queue, tags, callbacks, suppression
host-window.js per-display overlay, IPC routing, click-through, focus
options.js normalization and validation
platform.js OS detection and per-platform defaults
native-fallback.js OS Notification fallback
logger.js
renderer/
host.html/css/js the stack: cards, animations, timers
preload.js closed IPC bridge
types/index.d.ts
Nobody picks a version number. Every push to main runs .github/workflows/auto-release.yml, which reads the commits since the last v* tag and decides the bump from their subjects:
| Commit subject | Bump |
|---|---|
feat: |
minor |
fix:, perf:, revert: |
patch |
type!: or BREAKING CHANGE: in the body |
major |
docs:, chore:, ci:, test:, refactor:, style:, build: |
none |
A push carrying only chores ends without a release, and those commits are listed in the notes of the next release that does happen. The job then bumps package.json, tags, writes release notes grouped by section, and publishes to npm over OIDC trusted publishing, with no token anywhere.
To see what the current main would release, without publishing anything:
node .github/scripts/analyze-release.jsTo override the level, run the workflow manually from the Actions tab and fill in the level input. Pushing a tag by hand does nothing: main is what drives releases.
MIT © Anderson Targino

