Notifications
Four components, one rule — and the rule is the point. Pick by what the user has to do, not by how it looks.
Which one
| Component | Use for | Behavior | API |
|---|---|---|---|
| Modal | Needs acknowledgement — errors, confirms | Blocks. Requires a click. | _78.modal |
| Toast | Informational — “Saved”, “Copied” | Auto-dismisses. Never blocks. | _78.notify |
| Inline alert | Tied to a region — form errors, empty states | Sits in the layout. Persists. | _78-alert (CSS) |
| Quiet notes | Worth knowing, nothing to do — “close to target”, why a button isn't there | Sits in a card. Never louder than the content. | _78-notes (CSS) |
Modal
Built on the native <dialog> and opened with showModal(), which is
what gives the top layer, the ::backdrop, a focus trap and Escape for free.
Every opener returns a Promise. Dismissing — Escape, the backdrop, or the
× — resolves null from open() and false from
confirm() and alert().
Resolved value appears here.
<button class="_78-btn _78-btn-primary" type="button" id="rename">Rename…</button>
open(options)
| Option | Type | What it does |
|---|---|---|
title | string | The heading. Also becomes the dialog's accessible name. Omit it and the head bar is dropped unless the modal is dismissible. |
body | string · Node | The content. A DOM node is appended as-is. |
html | boolean | Treat body as markup instead of text. Off by default — text goes in with textContent, so untrusted content is safe. |
actions | array | The footer buttons. Defaults to a single primary OK. |
size | 'sm' · 'lg' | 400px or 760px instead of the default. Adds _78-modal-sm / _78-modal-lg. |
tone | any tone | Colors the title: 'danger', 'warn', 'success', 'info', 'accent' ('dim' is the plain modal). Adds _78-modal-{tone}; aliases like 'red' map to the canonical class. |
dismissible | boolean | Default true. Set false to remove the × and make Escape and the backdrop do nothing, so only a button closes it. Also accepted by confirm() and alert(). |
className | string | Extra classes on the <dialog>, for a one-off. |
Each entry in actions is { label, value, variant, autofocus }:
_78.modal.open({
title: 'Rename this view',
body: 'Everyone with access to the workspace will see the new name.',
size: 'sm',
actions: [
{ label: 'Cancel', value: null, variant: 'ghost' },
{ label: 'Rename', value: 'renamed', variant: 'primary', autofocus: true }
]
}).then(function (value) {
// 'renamed', or null if dismissed
});
variant is the button variant without the _78-btn- prefix —
primary, ghost, danger. The Promise resolves to the chosen
action's value.
confirm() and alert()
Two shorthands over open(). Both take the same option names where they apply.
// → Promise<boolean>
_78.modal.confirm('Archive this project? You can restore it later.');
_78.modal.confirm('Delete these 12 records? This cannot be undone.', {
title: 'Delete records', // default: 'Are you sure?'
tone: 'danger', // also makes the confirm button danger
confirmLabel: 'Delete', // default: 'Confirm'
cancelLabel: 'Keep them', // default: 'Cancel'
size: 'sm', // default for confirm(): 'sm'
html: false
});
// → Promise<boolean>: true when OK was clicked, false when dismissed
_78.modal.alert('Your export is ready in the downloads panel.', {
title: 'Export ready', // default: 'Heads up'
okLabel: 'Got it', // default: 'OK'
tone: 'warn',
size: 'sm',
html: false,
dismissible: true // false: no ×, Escape and backdrop do nothing
});
Both resolve a boolean. confirm() is true only for its Confirm button;
alert() is true only for OK. Cancel, ×, Escape and the
backdrop are all false.
_78.modal.alert(msg).then(reload) reloads the page when someone presses
Escape just to get the alert out of the way. Test the value, or better, tie the side effect
to the action that succeeded and use the alert only to report it.
// Wrong: runs on OK and on every dismissal.
_78.modal.alert('Import finished.').then(reload);
// Right: only when OK was clicked.
if (await _78.modal.alert('Import finished. Reload to see the new rows?', { okLabel: 'Reload' })) {
reload();
}
alert() used to resolve undefined however it closed; it now resolves
true or false. Code that ignores the value keeps working. And
dismissible: false now also holds off Escape, and is passed through by
confirm() and alert(), which used to ignore it.
A dialog you wrote yourself
You do not have to build modals in JavaScript. Write the <dialog> in your markup
and open it with show(), which returns the same Promise. mount() wires an
existing dialog's backdrop-click and dismissal behavior without opening it; put
data-_78-dismissible="false" on the <dialog> to hold off the backdrop and Escape instead.
<dialog class="_78-modal" id="terms" aria-labelledby="terms-title">
<div class="_78-modal-head">
<h2 class="_78-modal-title" id="terms-title">Written in HTML</h2>
<form method="dialog" class="_78-modal-close">
<button class="_78-icon-btn" type="submit" aria-label="Close">×</button>
</form>
</div>
<div class="_78-modal-body">
<p>No JavaScript built this one.</p>
</div>
<div class="_78-modal-foot _78-modal-foot-split">
<form method="dialog"><button class="_78-btn _78-btn-ghost" value="">Decline</button></form>
<form method="dialog"><button class="_78-btn _78-btn-primary" value="ok">Accept</button></form>
</div>
</dialog>
_78.modal.show(document.getElementById('terms'))
.then(function (returnValue) { /* the chosen button's value, '' if dismissed */ });
_78.modal.mount(el); // wire dismissal behavior without opening
The close button is a <form method="dialog">, so it would still close with
JavaScript disabled. _78-modal-foot-split pushes the footer buttons to opposite ends
— the usual shape when one of them is a “back” or destructive action.
| Class | What it is |
|---|---|
_78-modal | On the <dialog>. |
_78-modal-sm / -lg | 400px / 760px width. |
_78-modal-danger / -warn | Colors the title. |
_78-modal-head · -title · -close | The header row, its heading, and the close-button slot. |
_78-modal-body | The content area. Consecutive <p>s are spaced. |
_78-modal-foot | The action row, right-aligned. |
_78-modal-foot-split | With _78-modal-foot — pushes the buttons apart. |
Toast
Stacks in the corner, auto-dismisses, never blocks. Default lifetime is 4 seconds; errors get 6. These buttons are real:
<button class="_78-btn" type="button" id="save">Save</button>
_78.notify('Plain informational toast');
_78.notify('Workspace saved', { type: 'success' });
// Shorthands for the four types
_78.notify.success('Workspace saved');
_78.notify.error('Could not reach the server'); // .danger() is the same, by its tone name
_78.notify.warn('You are near your seat limit');
_78.notify.info('A new version is available');
| Option | Type | What it does |
|---|---|---|
type | any tone, or 'error' | Sets the accent stripe and the title color. 'danger' and the toast's own 'error' are the same thing: longer on screen and announced at once (role="alert"). Omit for the neutral toast. |
title | string | A bold line above the message. |
duration | number (ms) | Default 4000, or 6000 for errors. 0 makes it sticky — it then only leaves by its close button or its handle. |
html | boolean | Treat the message as markup. Off by default. |
The message may also be a DOM node, which is appended as-is — no html: true
needed, and no escaping question.
The return handle
notify() returns { el, close }. That is how you dismiss a sticky toast
when the thing it was reporting on finishes.
var t = _78.notify('Uploading…', { duration: 0 });
// t.el → the toast element, if you need to update it
// t.close() → dismiss it now, clearing any pending timer
upload().then(function () {
t.close();
_78.notify.success('Upload complete');
});
Where the stack sits
Bottom-right by default. Two classes on the _78-toasts container move it — set
them once, on the container the kit creates.
// The stack is created on first use; grab it and position it.
document.querySelector('._78-toasts').classList.add('_78-toasts-top'); // top instead of bottom
document.querySelector('._78-toasts').classList.add('_78-toasts-left'); // left instead of right
Inline alert
Pure CSS apart from dismissal. The icon and title are optional; the content belongs in
_78-alert-body — the alert is a flex row, so bare text and an inline link dropped
straight into it become separate flex items and the sentence breaks apart.
Use role="alert" for problems, which is announced immediately, and
role="status" for everything else, which waits for a pause.
<div class="site-demo-block">
<div class="_78-alert _78-alert-info" role="status">
<svg class="_78-alert-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg>
<div class="_78-alert-body">
<div class="_78-alert-title">Usage is reported daily</div>
Numbers on this page can lag by up to 24 hours.
</div>
</div>
<div class="_78-alert _78-alert-success" role="status">
<div class="_78-alert-body"><div class="_78-alert-title">Import complete</div>142 records added.</div>
<button class="_78-alert-close" type="button" aria-label="Dismiss">×</button>
</div>
<div class="_78-alert _78-alert-warn" role="alert">
<div class="_78-alert-body"><div class="_78-alert-title">Approaching your seat limit</div>210 of 220 seats are in use.</div>
</div>
<div class="_78-alert _78-alert-danger" role="alert">
<div class="_78-alert-body"><div class="_78-alert-title">Payment failed</div>Update the card on file to avoid interruption.</div>
</div>
<div class="_78-alert _78-alert-accent">
<div class="_78-alert-body">Accent variant — a neutral highlight tied to a region.</div>
</div>
<div class="_78-alert">
<div class="_78-alert-body">Plain variant — no semantic color.</div>
</div>
</div>
_78-alert-close is the one piece of behavior: the kit removes the alert when it is
clicked. No wiring needed.
Lists in an alert
A plain <ul> or <ol> inside _78-alert-body keeps its
indent and markers, in every tone. The same holds inside a card, a modal body and a disclosure. For a
list that should not show bullets, use
_78-list-plain.
- Row 7: unknown plan name
- Row 12: seat count is zero
- Row 31: duplicate account ID
- Export the sheet as CSV
- Keep the header row
<div class="site-demo-block">
<div class="_78-alert _78-alert-danger" role="alert">
<div class="_78-alert-body">
<div class="_78-alert-title">Three rows were rejected</div>
<ul>
<li>Row 7: unknown plan name</li>
<li>Row 12: seat count is zero</li>
<li>Row 31: duplicate account ID</li>
</ul>
</div>
</div>
<div class="_78-alert _78-alert-info" role="status">
<div class="_78-alert-body">
<div class="_78-alert-title">Before you import</div>
<ol>
<li>Export the sheet as CSV</li>
<li>Keep the header row</li>
</ol>
</div>
</div>
</div>
Paragraphs in an alert
Two <p>s in a row inside _78-alert-body are spaced --space-2
apart. Inside a card or a disclosure body the gap is --space-3, the same as a modal body.
The rule carries no weight of its own, so a margin utility on the paragraph — or any rule of
yours — still wins.
Earnings are due on Thursday, before the open.
This account has been idle for 18 days, past its review date.
Nothing has been changed. These are things to know, not instructions.
<div class="_78-alert _78-alert-warn" role="status">
<div class="_78-alert-body">
<div class="_78-alert-title">Worth a look</div>
<p>Earnings are due on Thursday, before the open.</p>
<p>This account has been idle for 18 days, past its review date.</p>
<p class="_78-text-dim _78-text-sm">Nothing has been changed. These are things to know, not instructions.</p>
</div>
</div>
Consecutive paragraphs used to run together, because the reset zeroes their margins. If you spaced them with your own class, it still works — and you can now delete it.
Cards and disclosure bodies with several paragraphs pick up a --space-3 gap they did
not have before.
Empty state
The other half of “tied to a region” — a region with nothing in it yet.
<div class="_78-empty">
<div class="_78-empty-title">No projects yet</div>
<div class="_78-text-sm">Anything you create will show up here.</div>
<button class="_78-btn _78-btn-primary _78-btn-sm _78-mt-8" type="button">New project</button>
</div>
Quiet notes
Not everything worth saying is worth an alert. "Close to target, nothing to do yet", the renewal date
under an account, the reason an action isn't offered — put those in an alert and the card shouts
about nothing; put them in _78-empty and they read as missing content.
_78-notes is the quiet one: dim, small, and after other content in a card it sits where
a card foot would.
All twelve accounts are on track this quarter.
- Northwind renews in 12 days, on autopay
- Contoso renews in 19 days, on autopay
<div class="_78-card">
<div class="_78-card-title">Renewals</div>
<p>All twelve accounts are on track this quarter.</p>
<div class="_78-notes _78-notes-ruled">
<div class="_78-notes-title">Due soon, nothing to do yet:</div>
<ul>
<li>Northwind renews in 12 days, on autopay</li>
<li>Contoso renews in 19 days, on autopay</li>
</ul>
</div>
</div>
A line's tone
Each line gets a small dot marker in --dim. Any
_78-tone-* on a line colors its
marker, and the text stays quiet — so one block can hold an earnings date, a time limit and a
market change without every line sharing one alarm color. A tone on the block colors every marker;
a tone on a line overrides it.
_78-notes-box is the compact alert: the same notes in a quiet inset with a 3px edge in the
block's tone. It's what to reach for when two stacked alerts in one card read heavy.
Three need a look before Friday.
- Fabrikam's card expires at the end of the month
- Contoso is over its seat limit by two
- Northwind asked about the annual plan
Nothing has been changed. These are things to know, not instructions.
<div class="_78-card">
<div class="_78-card-title">Accounts</div>
<p>Three need a look before Friday.</p>
<div class="_78-notes _78-notes-box _78-tone-warn">
<div class="_78-notes-title">Worth a look</div>
<ul>
<li>Fabrikam's card expires at the end of the month</li>
<li>Contoso is over its seat limit by two</li>
<li class="_78-tone-info">Northwind asked about the annual plan</li>
</ul>
<p class="_78-notes-xs">Nothing has been changed. These are things to know, not instructions.</p>
</div>
</div>
Nothing to do, and why
One sentence is a note too. When the action a reader expects isn't there, say why in the space it would have taken:
Team plan, 14 seats.
No upgrade offered: this workspace is already on the largest plan its billing region supports.
<div class="_78-card">
<div class="_78-card-title">Upgrade</div>
<p>Team plan, 14 seats.</p>
<div class="_78-card-foot">
<p class="_78-notes">No upgrade offered: this workspace is already on the largest plan its billing region supports.</p>
</div>
</div>
| Class | What it does |
|---|---|
_78-notes | The block, or a single <p>: --dim, --fs-sm. After other content in a card, --space-4 above it. |
_78-notes-title | An optional small heading: --text, semibold, sentence case. |
<ul> / <ol> | Each line gets a 6px dot marker; lines sit --space-1 apart. |
_78-tone-* on a line | Colors that line's marker — any tone or category. |
_78-tone-* on the block | Colors every marker and the box edge. |
_78-notes-box | The compact alert: a --bg3 inset with a 3px toned left edge. |
_78-notes-ruled | A hairline above — a footnote under the card's content. |
_78-notes-xs | --fs-xs, on the block or on one paragraph in it. |
The marker is reinforcement, never the message: write each line so it reads without its color.