| name | notify-library |
| description | Use @ajosecortes/notify to add notification toasts in TypeScript/JavaScript web apps. A zero-dependency, framework-agnostic notification library with variants, actions, CSS variable theming, and accessibility built in. Use when the user asks for toast notifications, notification system, toast messages, alert toasts, snackbar, or wants to display success/error/warning/info messages in a web app. Works with vanilla JS, React, Vue, Svelte, Angular, or any DOM environment. |
Notify — Zero-Dependency Notification Library
You have access to @ajosecortes/notify, a TypeScript notification library with zero runtime dependencies. It is framework-agnostic (works in any DOM environment), CSS-variable-driven (full visual control without touching JS), and accessibility-first (ARIA live regions, keyboard navigation, screen reader announcements).
The canonical implementation lives at /home/ajosecortes/development/projects/notify/src/notify.ts. For exhaustive reference, consult docs/api-reference.md in that project root.
Quick Start
import { notify } from '@ajosecortes/notify';
notify('Operation completed');
const handle = notify({
message: 'File saved',
duration: 3000,
variant: 'success',
actions: [
{
label: 'Undo',
onClick: (event, handle) => {
undoChange();
handle.close();
},
},
],
});
notify.success('Success!');
notify.error('Something went wrong');
notify.warning('Be careful');
notify.info('Heads up');
API Surface
notify(options: string | NotifyOptions): NotifyHandle
The main function. Creates, renders, and returns a handle to a notification element in a fixed-position container appended to document.body.
When called with a string, the string becomes the message and all other options fall through to global config → library defaults.
When called with an object, each provided property overrides the corresponding global/default value.
Variant Helpers
notify.success(options): NotifyHandle
notify.error(options): NotifyHandle
notify.warning(options): NotifyHandle
notify.info(options): NotifyHandle
These accept the same arguments as notify() except variant is fixed. Each variant comes with:
- A CSS modifier class (
notify-notification--{variant}) that changes background, border, and icon color
- A built-in inline SVG icon (checkmark for success, X-circle for error, triangle for warning, info-circle for info)
- The
default variant has no icon
notify.configure(config: Partial<NotifyConfig> | null): void
Sets global defaults. Each call merges with existing config — does not replace. Pass null to reset to factory defaults.
notify.configure({ placement: 'top-right', maxVisible: 3 });
notify.configure({ duration: 5000 });
notify.configure(null);
notify.dismissAll(): void
Removes all active notifications immediately — no exit animations, no onClose callbacks. Use for route transitions, component unmount, or cleanup.
useEffect(() => {
return () => notify.dismissAll();
}, []);
NotifyHandle
interface NotifyHandle {
close: () => void;
update: (options: Partial<NotifyOptions>) => void;
}
update() supports three properties:
message: Changes the displayed text (uses textContent, safe from XSS)
duration: Clears existing timer, starts a new one with new duration. If new duration is 0, no timer is started. If old was 0 and new > 0, a timer starts for the first time.
variant: Swaps the CSS class (notify-notification--oldVariant removed, --newVariant added)
Other properties (actions, icon, animation, ariaLive, showCloseButton, className) cannot be updated dynamically. Close and recreate instead.
Type Reference
NotifyOptions
interface NotifyOptions {
message: string;
variant?: Variant;
duration?: number;
actions?: Action[];
onClose?: () => void;
className?: string;
showCloseButton?: boolean;
animation?: boolean;
pauseOnHover?: boolean;
icon?: string | false;
ariaLive?: 'polite' | 'assertive';
}
Action
interface Action {
label: string;
onClick: (event: MouseEvent, handle: NotifyHandle) => void;
className?: string;
}
NotifyConfig
interface NotifyConfig {
placement?: Placement;
maxVisible?: number;
duration?: number;
animation?: boolean;
pauseOnHover?: boolean;
showCloseButton?: boolean;
className?: string;
variant?: Variant;
}
Note: message, actions, onClose, icon, and ariaLive are NOT in NotifyConfig — they are per-notification only.
Placement
type Placement = 'top-left' | 'top-center' | 'top-right'
| 'bottom-left' | 'bottom-center' | 'bottom-right';
Variant
type Variant = 'default' | 'success' | 'error' | 'warning' | 'info';
Configuration Precedence (CRITICAL — always apply this)
Options resolve in strict order: per-notification > global config > library defaults.
notify.configure({ showCloseButton: false });
notify({ message: 'Has X', showCloseButton: true });
When writing notify code, always resolve values in this order. Never assume a value is the library default if the user has called configure().
CSS Variables (theming — no JS needed)
Every visual property is controlled through CSS variables scoped to .notify-notification. Override them in any stylesheet:
.notify-notification {
--notify-bg: #ffffff;
--notify-color: #1a1a2e;
--notify-border-radius: 8px;
--notify-padding: 12px 16px;
--notify-font-family: system-ui, -apple-system, sans-serif;
--notify-font-size: 14px;
--notify-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
--notify-border: 1px solid rgba(0, 0, 0, 0.08);
--notify-icon-size: 20px;
--notify-icon-color: currentColor;
--notify-close-color: #666;
--notify-close-hover-color: #333;
--notify-action-color: #3b82f6;
--notify-action-hover-color: #2563eb;
}
Variant-specific overrides:
.notify-notification--success { --notify-bg: #ecfdf5; --notify-border: 1px solid #a7f3d0; --notify-icon-color: #10b981; }
.notify-notification--error { --notify-bg: #fef2f2; --notify-border: 1px solid #fecaca; --notify-icon-color: #ef4444; }
.notify-notification--warning { --notify-bg: #fffbeb; --notify-border: 1px solid #fde68a; --notify-icon-color: #f59e0b; }
.notify-notification--info { --notify-bg: #eff6ff; --notify-border: 1px solid #bfdbfe; --notify-icon-color: #3b82f6; }
Key principle: CSS variables give you full visual control without modifying JavaScript. Use them for theming, not JS configuration.
DOM Structure (generated HTML)
<div class="notify-container notify-container--bottom-right" data-notify-host>
<div class="notify-notification notify-notification--error" id="notify-1"
role="status" aria-live="polite" aria-atomic="true" tabindex="0">
<span class="notify-notification__icon"><svg>...</svg></span>
<div class="notify-notification__content">
<div class="notify-notification__message">Message here</div>
<div class="notify-notification__actions">
<button class="notify-notification__action" type="button">Undo</button>
</div>
</div>
<button class="notify-notification__close" type="button"
aria-label="Close notification">✕</button>
</div>
</div>
Key facts about the structure:
- Container:
position: fixed, pointer-events: none, z-index: 9999, max-width: 380px
- Notifications:
pointer-events: auto, width: 100%, flex layout (icon | content+actions | close)
- Icon is absent for
default variant or icon: false
- Actions div only rendered when
actions array is non-empty
- Close button absent when
showCloseButton: false
- Container created once, reused for all notifications
- Animations: CSS
@keyframes notify-enter (0.25s) and notify-exit (0.2s)
Accessibility (built-in)
Every notification automatically carries:
role="status" — semantic live region
aria-live="polite" (or "assertive" if configured)
aria-atomic="true" — full re-announcement on content change
tabindex="0" — keyboard focusable
- Close button:
aria-label="Close notification"
- All buttons:
type="button" (prevents accidental form submission in <form>)
- Message rendered as
textContent (not innerHTML) — safe from XSS by default
When writing code with notify, always include proper ariaLive for errors that demand immediate attention:
notify({ message: 'Session expired', variant: 'error', ariaLive: 'assertive', duration: 0 });
Behavior Rules
Stacking & Overflow
Notifications stack vertically in insertion order (oldest first). When maxVisible (default 5) is exceeded, the oldest visible notification is removed synchronously (no exit animation). The onClose callback still fires for overflowed notifications.
Auto-close
Each notification auto-closes after duration ms (default 4000). Hover pauses the timer; leaving restarts the timer from the FULL original duration (not remaining time). Set duration: 0 to disable auto-close.
Important: Timer restart on mouseleave resets to the full duration, not the remaining time. This gives users more reading time and is the intended behavior.
Close Safety
handle.close() is idempotent (safe to call multiple times). It uses a dismissed flag internally. Exit animation has a 400ms safety timeout in case animationend doesn't fire (common in background tabs).
dismissAll() Caveat
Does NOT call onClose and does NOT animate. It clears timers, marks instances dismissed, removes elements from DOM, and clears the internal map. Use for cleanup, not for sequential notification dismissal where callbacks matter.
Framework Integration Patterns
React
Global setup in root component:
import { notify } from '@ajosecortes/notify';
import { useEffect } from 'react';
function App() {
useEffect(() => {
notify.configure({ placement: 'top-right', maxVisible: 3 });
return () => notify.dismissAll();
}, []);
return <Router />;
}
Usage in event handlers (NEVER in render):
const handleSave = async () => {
try {
await api.save();
notify.success('Saved');
} catch {
notify.error({ message: 'Failed', duration: 8000 });
}
};
Vue
import { notify } from '@ajosecortes/notify';
export default {
mounted() {
notify.configure({ placement: 'bottom-left' });
},
methods: {
handleDelete() {
notify({
message: 'User deleted',
variant: 'success',
actions: [{ label: 'Undo', onClick: (e, h) => { restore(); h.close(); } }],
});
},
},
beforeDestroy() {
notify.dismissAll();
},
};
Svelte
<script lang="ts">
import { notify } from '@ajosecortes/notify';
import { onMount, onDestroy } from 'svelte';
onMount(() => notify.configure({ placement: 'top-right' }));
onDestroy(() => notify.dismissAll());
function save() { notify.success('Saved'); }
</script>
Angular
@Injectable({ providedIn: 'root' })
export class NotifyService implements OnDestroy {
constructor() {
notify.configure({ placement: 'top-right', maxVisible: 3 });
}
success(msg: string) { return notify.success(msg); }
error(msg: string) { return notify.error({ message: msg, duration: 8000 }); }
ngOnDestroy() { notify.dismissAll(); }
}
SSR Considerations
Notify uses document.body — it cannot run on the server. Always guard or dynamic-import:
if (typeof window !== 'undefined') {
const { notify } = await import('@ajosecortes/notify');
}
Common Patterns
Undo Pattern
function deleteItem(item: Item) {
removeFromUI(item);
notify({
message: `"${item.name}" deleted`,
variant: 'warning',
duration: 6000,
actions: [{
label: 'Undo',
onClick: (e, handle) => {
restoreItem(item);
handle.update({ message: `"${item.name}" restored`, variant: 'success' });
setTimeout(() => handle.close(), 2000);
},
}],
onClose: () => api.permanentlyDelete(item.id),
});
}
Loading → Success/Error Transition
async function saveData(data: unknown) {
const handle = notify({
message: 'Saving...',
variant: 'info',
duration: 0,
showCloseButton: false,
});
try {
await api.save(data);
handle.update({ message: 'Saved successfully', variant: 'success', duration: 3000 });
} catch {
handle.update({ message: 'Save failed', variant: 'error', duration: 8000, showCloseButton: true });
}
}
Confirmation via Notification
function confirmDelete(name: string): Promise<boolean> {
return new Promise((resolve) => {
notify({
message: `Delete "${name}"?`,
variant: 'warning',
duration: 0,
actions: [
{ label: 'Delete', onClick: (e, h) => { h.close(); resolve(true); } },
{ label: 'Cancel', onClick: (e, h) => { h.close(); resolve(false); } },
],
onClose: () => resolve(false),
});
});
}
Network Status
window.addEventListener('online', () => {
notify.success('Connection restored', { duration: 3000 });
});
window.addEventListener('offline', () => {
notify.warning('You are offline', { duration: 0 });
});
Persistent (non-dismissible) Alert
notify({
message: 'Session expires in 5 minutes',
variant: 'warning',
duration: 0,
showCloseButton: false,
actions: [{
label: 'Extend session',
onClick: async (e, h) => {
await refreshSession();
h.update({ message: 'Session extended', variant: 'success' });
setTimeout(() => h.close(), 2000);
},
}],
});
Testing Notifications
Setup (vitest + jsdom):
import { notify, configure, dismissAll } from '@ajosecortes/notify';
import { beforeEach, afterEach, vi } from 'vitest';
beforeEach(() => {
document.body.innerHTML = '';
configure(null);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
Key test patterns:
const el = document.querySelector('.notify-notification');
expect(el).not.toBeNull();
expect(el!.textContent).toContain('Hello');
vi.advanceTimersByTime(4000);
el!.dispatchEvent(new AnimationEvent('animationend', { bubbles: false }));
expect(document.querySelector('.notify-notification')).toBeNull();
configure({ maxVisible: 2 });
notify('A'); notify('B'); notify('C');
const notifications = document.querySelectorAll('.notify-notification');
expect(notifications).toHaveLength(2);
expect(notifications[0].textContent).toContain('B');
const onClick = vi.fn();
const handle = notify({ message: 'Test', actions: [{ label: 'Go', onClick }] });
(document.querySelector('.notify-notification__action') as HTMLElement).click();
expect(onClick).toHaveBeenCalledWith(expect.any(MouseEvent), expect.objectContaining({ close: expect.any(Function) }));
dismissAll();
expect(document.querySelectorAll('.notify-notification')).toHaveLength(0);
Common Mistakes to Avoid
NEVER call notify() during render (React)
function MyComponent() {
notify('Hello');
return <div />;
}
function MyComponent() {
useEffect(() => { notify('Mounted'); }, []);
const onClick = () => notify('Clicked');
return <button onClick={onClick}>Click</button>;
}
NEVER assume library defaults if configure() was called
notify.configure({ duration: 8000 });
notify('Uses 8000ms');
NEVER call notify() in SSR
Always guard with typeof window !== 'undefined' or dynamic import on the client side.
NEVER use innerHTML for messages
The library uses textContent for the message. If you need rich HTML, inject it yourself after getting the element — but this is discouraged for accessibility reasons.
NEVER rely on onClose in dismissAll()
dismissAll() does not fire onClose callbacks. If callbacks are important, close handles individually.
update() IS NOT a general setter
Only message, duration, and variant can be updated. For other changes, close and recreate.
ALWAYS clean up on unmount
useEffect(() => () => notify.dismissAll(), []);
Without this, notifications from unmounted components linger and their callbacks may reference unmounted state.