Mount it once, notify from anywhere.
import { Toaster, useNotifications } from "@enigmax/primitives/react";
import "@enigmax/primitives/toast.css";
// once, near the root
<Toaster position="bottom-right" />
// anywhere
const { notify } = useNotifications();
notify({ title: "Saved", body: "Two files changed", tone: "success" });
Customize
Raise one. Hover it and the timer stops; drag it towards the edge it is pinned to and it
goes. The <Toaster> below is the real one, mounted on this page.
Installation
Adds the package and prints the import to use. Append --copy to write the source into your project instead.
enigma add toastInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react.
Ember is the default theme, and it is entirely this stylesheet. The custom properties at
the top are the whole API: override them on :root and you have a different theme without
touching a selector.
"use client";
import { Toaster, type ToastPosition } from "@enigmax/primitives/react";
/**
* Ember, in Tailwind. Yours to edit.
*
* The stack, the gestures and the exit hold come from `<Toaster />`; everything below is a
* class. The one thing Tailwind cannot express is the swipe offset - it is a live number
* from the pointer, published as `--enigma-toast-swipe` - so the arbitrary translate below
* reads that variable rather than trying to enumerate positions.
*/
const TONE = {
info: "before:bg-blue-400",
success: "before:bg-green-400",
warning: "before:bg-amber-400",
error: "before:bg-red-400",
loading: "before:bg-neutral-500"
} as const;
export function AppToaster({ position = "bottom-right" }: { position?: ToastPosition; }) {
return (
<Toaster
position={position}
className="fixed z-[9999] flex w-[min(22rem,calc(100vw-2.5rem))] flex-col gap-2.5 data-[position^=top]:top-5 data-[position^=top]:flex-col-reverse data-[position^=bottom]:bottom-5 data-[position$=-left]:left-5 data-[position$=-right]:right-5 data-[position$=-center]:left-1/2 data-[position$=-center]:-translate-x-1/2 pointer-events-none"
>
{(notification, controls) => (
<div
className={[
"pointer-events-auto relative grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-1 rounded-xl border p-4",
"border-neutral-800 bg-neutral-950 text-neutral-100 shadow-[0_8px_30px_rgb(0_0_0/45%)]",
"before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:rounded-l-xl",
// Tracks the finger exactly while a swipe is in progress.
"translate-x-[var(--enigma-toast-swipe,0)] transition-transform duration-200 ease-out data-[swiping]:transition-none",
TONE[notification.tone]
].join(" ")}
>
<p className="col-start-1 m-0 text-sm font-semibold leading-snug">{notification.title}</p>
{notification.body && <p className="col-start-1 m-0 text-[13px] leading-relaxed text-neutral-400">{notification.body}</p>}
{notification.action && (
<button
type="button"
className="col-start-2 row-span-full rounded-md bg-neutral-100 px-2.5 py-1.5 text-xs font-semibold text-neutral-950"
onClick={() => {
notification.action?.onSelect();
if (notification.action?.dismiss !== false) controls.dismiss();
}}
>{notification.action.label}</button>
)}
{/* Opacity rather than hidden: it must stay reachable by keyboard. */}
<button
type="button"
aria-label="Dismiss"
className="absolute right-2 top-1.5 px-1 text-sm leading-none text-neutral-400 opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
onClick={controls.dismiss}
>×</button>
</div>
)}
</Toaster>
);
}/*
* Ember - the default toast theme.
*
* Every colour, every distance and every curve is in this file. `<Toaster />` renders the
* structure and the state; nothing below is something the package decided for you, so
* editing this file is the supported way to make it yours.
*
* The custom properties at the top are the whole API. Override them on `:root` and you have
* a different theme without touching a selector.
*/
:root {
--enigma-toast-width: 22rem;
--enigma-toast-gap: 0.625rem;
--enigma-toast-edge: 1.25rem;
--enigma-toast-radius: 0.75rem;
--enigma-toast-exit: 200ms;
--enigma-toast-bg: #101010;
--enigma-toast-border: #2a2a2a;
--enigma-toast-text: #f5f5f5;
--enigma-toast-muted: #a3a3a3;
--enigma-toast-shadow: 0 8px 30px rgb(0 0 0 / 45%);
--enigma-toast-info: #60a5fa;
--enigma-toast-success: #4ade80;
--enigma-toast-warning: #fbbf24;
--enigma-toast-error: #f87171;
--enigma-toast-loading: var(--enigma-toast-muted);
}
@media (prefers-color-scheme: light) {
:root {
--enigma-toast-bg: #ffffff;
--enigma-toast-border: #e5e5e5;
--enigma-toast-text: #171717;
--enigma-toast-muted: #737373;
--enigma-toast-shadow: 0 8px 30px rgb(0 0 0 / 12%);
}
}
[data-enigma-toaster] {
position: fixed;
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--enigma-toast-gap);
width: min(var(--enigma-toast-width), calc(100vw - var(--enigma-toast-edge) * 2));
/* The region is a layout box, not a wall: clicks pass through the gaps between toasts. */
pointer-events: none;
margin: 0;
padding: 0;
}
[data-enigma-toaster][data-position^="top"] { top: var(--enigma-toast-edge); flex-direction: column-reverse; }
[data-enigma-toaster][data-position^="bottom"] { bottom: var(--enigma-toast-edge); }
[data-enigma-toaster][data-position$="-left"] { left: var(--enigma-toast-edge); }
[data-enigma-toaster][data-position$="-right"] { right: var(--enigma-toast-edge); }
[data-enigma-toaster][data-position$="-center"] { left: 50%; transform: translateX(-50%); }
[data-enigma-toast] {
pointer-events: auto;
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.25rem 0.75rem;
padding: 0.875rem 1rem;
color: var(--enigma-toast-text);
background: var(--enigma-toast-bg);
border: 1px solid var(--enigma-toast-border);
border-radius: var(--enigma-toast-radius);
box-shadow: var(--enigma-toast-shadow);
/* The swipe offset the component publishes. Zero unless a finger is on it. */
translate: var(--enigma-toast-swipe, 0) 0;
transition: translate 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease-out;
touch-action: pan-y;
outline: none;
}
[data-enigma-toaster][data-position$="-center"] [data-enigma-toast] { translate: 0 var(--enigma-toast-swipe, 0); }
/* While a finger is down the toast tracks it exactly: a transition here would lag behind
the pointer, which reads as a broken gesture rather than a smooth one. */
[data-enigma-toast][data-swiping] { transition: none; cursor: grabbing; user-select: none; }
[data-enigma-toast]:focus-visible { box-shadow: var(--enigma-toast-shadow), 0 0 0 2px var(--enigma-toast-info); }
/* A left border is the tone. It reads at a glance and survives any background. */
[data-enigma-toast]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
border-radius: var(--enigma-toast-radius) 0 0 var(--enigma-toast-radius);
background: var(--enigma-toast-tone, transparent);
}
[data-enigma-toast][data-tone="info"] { --enigma-toast-tone: var(--enigma-toast-info); }
[data-enigma-toast][data-tone="success"] { --enigma-toast-tone: var(--enigma-toast-success); }
[data-enigma-toast][data-tone="warning"] { --enigma-toast-tone: var(--enigma-toast-warning); }
[data-enigma-toast][data-tone="error"] { --enigma-toast-tone: var(--enigma-toast-error); }
[data-enigma-toast][data-tone="loading"] { --enigma-toast-tone: var(--enigma-toast-loading); }
[data-enigma-toast-title] { grid-column: 1; margin: 0; font-size: 0.875rem; font-weight: 600; line-height: 1.35; }
[data-enigma-toast-body] { grid-column: 1; margin: 0; font-size: 0.8125rem; color: var(--enigma-toast-muted); line-height: 1.45; }
[data-enigma-toast-action] {
grid-column: 2;
grid-row: 1 / -1;
padding: 0.3125rem 0.625rem;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
color: var(--enigma-toast-bg);
background: var(--enigma-toast-text);
border: 0;
border-radius: 0.375rem;
cursor: pointer;
}
[data-enigma-toast-close] {
position: absolute;
top: 0.375rem;
right: 0.5rem;
padding: 0 0.25rem;
font-size: 0.875rem;
line-height: 1;
color: var(--enigma-toast-muted);
background: none;
border: 0;
cursor: pointer;
/* Shown on hover or focus, so it never covers the message while it is being read - but
always reachable by keyboard, which `display: none` would take away. */
opacity: 0;
transition: opacity 120ms ease-out;
}
[data-enigma-toast]:hover [data-enigma-toast-close],
[data-enigma-toast-close]:focus-visible { opacity: 1; }
/* Enter from the edge it is pinned to, leave the same way. */
@keyframes enigma-toast-in-right { from { opacity: 0; translate: 100% 0; } to { opacity: 1; translate: 0 0; } }
@keyframes enigma-toast-in-left { from { opacity: 0; translate: -100% 0; } to { opacity: 1; translate: 0 0; } }
@keyframes enigma-toast-in-top { from { opacity: 0; translate: 0 -100%; } to { opacity: 1; translate: 0 0; } }
@keyframes enigma-toast-in-bottom { from { opacity: 0; translate: 0 100%; } to { opacity: 1; translate: 0 0; } }
@keyframes enigma-toast-out { to { opacity: 0; scale: 0.94; } }
[data-position$="-right"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-right 260ms cubic-bezier(0.22, 1, 0.36, 1); }
[data-position$="-left"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-left 260ms cubic-bezier(0.22, 1, 0.36, 1); }
[data-position="top-center"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-top 260ms cubic-bezier(0.22, 1, 0.36, 1); }
[data-position="bottom-center"] [data-enigma-toast][data-state="open"] { animation: enigma-toast-in-bottom 260ms cubic-bezier(0.22, 1, 0.36, 1); }
/* Matches the component's exitDuration: the node is held for exactly this long, so a
longer animation would be cut off mid-way. */
[data-enigma-toast][data-state="leaving"] {
animation: enigma-toast-out var(--enigma-toast-exit) ease-in forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
/* The movement goes, the state change stays: a toast still appears and still leaves. */
[data-enigma-toast] { animation: none !important; transition: opacity 120ms ease-out; }
[data-enigma-toast][data-state="leaving"] { opacity: 0; }
}One action, one toast
promise follows an async operation from loading to its outcome in the same slot. The
alternative is three toasts stacking up for one thing the visitor did once.
const { promise } = useNotifications();
await promise(saveDocument(draft), {
loading: "Saving...",
success: (saved) => `Saved as ${saved.name}`,
error: (error) => `Could not save: ${error.message}`
});
It rethrows. Swallowing the rejection here would turn a failed call into a silent
success for everything after the await, which is a worse bug than a missing toast.
loading is sticky by definition - it ends when the work ends, not on a timer - and stops
being sticky the moment it becomes a success.
An action on the toast
notify({
title: "Message deleted",
action: { label: "Undo", onSelect: () => restore(message) }
});
Pressing it dismisses the toast unless you pass dismiss: false. A press on the button is
never read as the start of a swipe.
What the renderer adds
The queue underneath already owns ordering, dedupe by key, sticky errors and timers that hold while the tab is hidden rather than running down. Four things it cannot express live in the component:
A toast that is leaving is still on screen. The queue drops an item the moment it is
dismissed, so the component keeps its own list and holds the node for exitDuration before
removing it. Without that there is nothing left to animate.
A pointer resting on the stack stops every clock, and so does focus, so a message cannot expire while it is being read.
A swipe follows the finger before it decides anything - only towards the edge the stack
is pinned to, springing back under the threshold. The offset is published as
--enigma-toast-swipe, so the transform stays your stylesheet’s decision, and the
transition is dropped mid-drag because a toast lagging behind the pointer reads as a broken
gesture rather than a smooth one.
An error interrupts. role="alert" and aria-live="assertive" for errors,
role="status" and polite for everything else, so a success never talks over whatever a
screen reader is already reading.
Rendering it yourself
Pass a function and the component keeps the stack, the gestures and the exit hold while you own every element inside.
<Toaster position="top-center">
{(notification, { dismiss }) => (
<div className="my-toast">
<MyIcon tone={notification.tone} />
<p>{notification.title}</p>
<button onClick={dismiss}>Close</button>
</div>
)}
</Toaster>
Props and hooks
| Prop | Default | |
|---|---|---|
position |
"bottom-right" |
Six positions; the enter, exit and swipe all follow it |
queue |
the shared one | Pass your own from createNotifications |
exitDuration |
200 |
Must match the exit animation, or it is cut off |
swipeThreshold |
60 |
Px before a release dismisses |
| Attribute | On |
|---|---|
[data-enigma-toaster] |
The region - carries data-position |
[data-enigma-toast] |
One toast - data-tone, data-state, data-index, data-swiping |
[data-state="leaving"] |
On its way out. This is your exit animation’s cue |
[data-front] |
The newest one, for a stack that scales the ones behind |
[data-enigma-toast-action] |
The action button |
Deliberately not built on sonner or a fork of it. The queue already decides when a message disappears; adding a second library’s store would mean two things deciding that, and the one you did not configure wins.