Mount it once, notify from anywhere. There is no stylesheet to import: this is the one component in the package that brings its own look, because a toast renders at the edge of the screen and a forgotten import gives you a pile of text in a corner rather than something you can see is unstyled.
import { Toaster, useNotifications } from "@enigmax/primitives/react/toast";
// once, near the root
<Toaster position="bottom-right" />
// anywhere
const { notify } = useNotifications();
notify({ title: "Saved", body: "Two files changed", tone: "success" });
The theme is injected once and prepended to <head>, so anything your app already has
outranks it by source order - no !important anywhere. <Toaster styles={false} /> turns it
off entirely, and @enigmax/primitives/toast.css is the same sheet if you would rather
import it yourself.
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. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.
enigma add toastInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.
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>
);
}/*
* The toast stylesheet.
*
* GENERATED from src/react/toast/styles.ts by scripts/sync-recipes.mjs - edit that file, not
* this one. It exists as a stylesheet as well as a string because the component injects it,
* and this is the same sheet for anyone who would rather import it, copy it with
* `enigma add toast --copy`, or fork it outright.
*/
:where(html[dir="ltr"]),
:where([data-toastcmp-toaster][dir="ltr"]) {
--toast-icon-margin-start: -3px;
--toast-icon-margin-end: 4px;
--toast-svg-margin-start: -1px;
--toast-svg-margin-end: 0px;
--toast-button-margin-start: auto;
--toast-button-margin-end: 0;
--toast-close-button-start: 0;
--toast-close-button-end: unset;
--toast-close-button-transform: translate(-35%, -35%);
}
:where(html[dir="rtl"]),
:where([data-toastcmp-toaster][dir="rtl"]) {
--toast-icon-margin-start: 4px;
--toast-icon-margin-end: -3px;
--toast-svg-margin-start: 0px;
--toast-svg-margin-end: -1px;
--toast-button-margin-start: 0;
--toast-button-margin-end: auto;
--toast-close-button-start: unset;
--toast-close-button-end: 0;
--toast-close-button-transform: translate(35%, -35%);
}
:where([data-toastcmp-toaster]) {
position: fixed;
width: var(--width);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial,
Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
--gray1: hsl(0, 0%, 99%);
--gray2: hsl(0, 0%, 97.3%);
--gray3: hsl(0, 0%, 95.1%);
--gray4: hsl(0, 0%, 93%);
--gray5: hsl(0, 0%, 90.9%);
--gray6: hsl(0, 0%, 88.7%);
--gray7: hsl(0, 0%, 85.8%);
--gray8: hsl(0, 0%, 78%);
--gray9: hsl(0, 0%, 56.1%);
--gray10: hsl(0, 0%, 52.3%);
--gray11: hsl(0, 0%, 43.5%);
--gray12: hsl(0, 0%, 9%);
--border-radius: 8px;
box-sizing: border-box;
padding: 0;
margin: 0;
list-style: none;
outline: none;
z-index: 999999999;
transition: transform 400ms ease;
}
:where([data-toastcmp-toaster][data-lifted="true"]) {
transform: translateY(-10px);
}
@media (hover: none) and (pointer: coarse) {
:where([data-toastcmp-toaster][data-lifted="true"]) {
transform: none;
}
}
:where([data-toastcmp-toaster][data-x-position="right"]) {
right: var(--offset-right);
}
:where([data-toastcmp-toaster][data-x-position="left"]) {
left: var(--offset-left);
}
:where([data-toastcmp-toaster][data-x-position="center"]) {
left: 50%;
transform: translateX(-50%);
}
:where([data-toastcmp-toaster][data-y-position="top"]) {
top: var(--offset-top);
}
:where([data-toastcmp-toaster][data-y-position="bottom"]) {
bottom: var(--offset-bottom);
}
:where([data-toastcmp-toast]) {
--y: translateY(100%);
--lift-amount: calc(var(--lift) * var(--gap));
z-index: var(--z-index);
position: absolute;
opacity: 0;
transform: var(--y);
filter: blur(0);
/* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */
touch-action: none;
transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms;
box-sizing: border-box;
outline: none;
overflow-wrap: anywhere;
}
:where([data-toastcmp-toast][data-styled="true"]) {
padding: 16px;
background: var(--normal-bg);
border: 1px solid var(--normal-border);
color: var(--normal-text);
border-radius: var(--border-radius);
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
width: var(--width);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
:where([data-toastcmp-toast]:focus-visible) {
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);
}
:where([data-toastcmp-toast][data-y-position="top"]) {
top: 0;
--y: translateY(-100%);
--lift: 1;
--lift-amount: calc(1 * var(--gap));
}
:where([data-toastcmp-toast][data-y-position="bottom"]) {
bottom: 0;
--y: translateY(100%);
--lift: -1;
--lift-amount: calc(var(--lift) * var(--gap));
}
:where([data-toastcmp-toast]) :where([data-description]) {
font-weight: 400;
line-height: 1.4;
color: inherit;
}
:where([data-toastcmp-toast]) :where([data-title]) {
font-weight: 500;
line-height: 1.5;
color: inherit;
}
:where([data-toastcmp-toast]) :where([data-icon]) {
display: flex;
height: 16px;
width: 16px;
position: relative;
justify-content: flex-start;
align-items: center;
flex-shrink: 0;
margin-left: var(--toast-icon-margin-start);
margin-right: var(--toast-icon-margin-end);
}
:where([data-toastcmp-toast][data-promise="true"]) :where([data-icon])>svg {
opacity: 0;
transform: scale(0.8);
transform-origin: center;
animation: toastcmp-fade-in 300ms ease forwards;
}
:where([data-toastcmp-toast]) :where([data-icon])>* {
flex-shrink: 0;
}
:where([data-toastcmp-toast]) :where([data-icon]) svg {
margin-left: var(--toast-svg-margin-start);
margin-right: var(--toast-svg-margin-end);
}
:where([data-toastcmp-toast]) :where([data-content]) {
display: flex;
flex-direction: column;
gap: 2px;
}
[data-toastcmp-toast][data-styled="true"] [data-button] {
border-radius: 4px;
padding-left: 8px;
padding-right: 8px;
height: 24px;
font-size: 12px;
color: var(--normal-bg);
background: var(--normal-text);
margin-left: var(--toast-button-margin-start);
margin-right: var(--toast-button-margin-end);
border: none;
cursor: pointer;
outline: none;
display: flex;
align-items: center;
flex-shrink: 0;
transition: opacity 400ms, box-shadow 200ms;
}
:where([data-toastcmp-toast]) :where([data-button]):focus-visible {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
}
:where([data-toastcmp-toast]) :where([data-button]):first-of-type {
margin-left: var(--toast-button-margin-start);
margin-right: var(--toast-button-margin-end);
}
:where([data-toastcmp-toast]) :where([data-cancel]) {
color: var(--normal-text);
background: rgba(0, 0, 0, 0.08);
}
:where([data-toastcmp-toast][data-theme="dark"]) :where([data-cancel]) {
background: rgba(255, 255, 255, 0.3);
}
:where([data-toastcmp-toast]) :where([data-close-button]) {
position: absolute;
left: var(--toast-close-button-start);
right: var(--toast-close-button-end);
top: 0;
height: 20px;
width: 20px;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
color: var(--gray12);
border: 1px solid var(--gray4);
transform: var(--toast-close-button-transform);
border-radius: 50%;
cursor: pointer;
z-index: 1;
transition: opacity 100ms, background 200ms, border-color 200ms;
}
[data-toastcmp-toast] [data-close-button] {
background: var(--gray1);
}
:where([data-toastcmp-toast]) :where([data-close-button]):focus-visible {
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);
}
:where([data-toastcmp-toast]) :where([data-disabled="true"]) {
cursor: not-allowed;
}
:where([data-toastcmp-toast]):hover :where([data-close-button]):hover {
background: var(--gray2);
border-color: var(--gray5);
}
/* Leave a ghost div to avoid setting hover to false when swiping out */
:where([data-toastcmp-toast][data-swiping="true"])::before {
content: "";
position: absolute;
left: -50%;
right: -50%;
height: 100%;
z-index: -1;
}
:where([data-toastcmp-toast][data-y-position="top"][data-swiping="true"])::before {
/* y 50% needed to distribute height additional height evenly */
bottom: 50%;
transform: scaleY(3) translateY(50%);
}
:where([data-toastcmp-toast][data-y-position="bottom"][data-swiping="true"])::before {
/* y -50% needed to distribute height additional height evenly */
top: 50%;
transform: scaleY(3) translateY(-50%);
}
/* Leave a ghost div to avoid setting hover to false when transitioning out */
:where([data-toastcmp-toast][data-swiping="false"][data-removed="true"])::before {
content: "";
position: absolute;
inset: 0;
transform: scaleY(2);
}
/* Needed to avoid setting hover to false when inbetween toasts */
:where([data-toastcmp-toast])::after {
content: "";
position: absolute;
left: 0;
height: calc(var(--gap) + 1px);
bottom: 100%;
width: 100%;
}
:where([data-toastcmp-toast][data-mounted="true"]) {
--y: translateY(0);
opacity: 1;
}
:where([data-toastcmp-toast][data-expanded="false"][data-front="false"]) {
--scale: var(--toasts-before) * 0.05 + 1;
--y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)));
height: var(--front-toast-height);
}
:where([data-toastcmp-toast])>* {
transition: opacity 400ms;
}
:where([data-toastcmp-toast][data-expanded="false"][data-front="false"][data-styled="true"])>* {
opacity: 0;
}
:where([data-toastcmp-toast][data-visible="false"]) {
opacity: 0;
pointer-events: none;
}
:where([data-toastcmp-toast][data-mounted="true"][data-expanded="true"]) {
--y: translateY(calc(var(--lift) * var(--offset)));
height: var(--initial-height);
}
:where([data-toastcmp-toast][data-removed="true"][data-front="true"][data-swipe-out="false"]) {
--y: translateY(calc(var(--lift) * -100%));
opacity: 0;
}
:where([data-toastcmp-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="true"]) {
--y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));
opacity: 0;
}
:where([data-toastcmp-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="false"]) {
--y: translateY(40%);
opacity: 0;
transition: transform 500ms, opacity 200ms;
}
/* Bump up the height to make sure hover state doesn"t get set to false */
:where([data-toastcmp-toast][data-removed="true"][data-front="false"])::before {
height: calc(var(--initial-height) + 20%);
}
[data-toastcmp-toast][data-swiping="true"] {
transform: var(--y) translateY(var(--swipe-amount-y, 0px)) translateX(var(--swipe-amount-x, 0px));
transition: none;
}
[data-toastcmp-toast][data-swiped="true"] {
user-select: none;
}
[data-toastcmp-toast][data-swipe-out="true"][data-y-position="bottom"],
[data-toastcmp-toast][data-swipe-out="true"][data-y-position="top"] {
animation-duration: 200ms;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
[data-toastcmp-toast][data-swipe-out="true"][data-swipe-direction="left"] {
animation-name: swipe-out-left;
}
[data-toastcmp-toast][data-swipe-out="true"][data-swipe-direction="right"] {
animation-name: swipe-out-right;
}
[data-toastcmp-toast][data-swipe-out="true"][data-swipe-direction="up"] {
animation-name: swipe-out-up;
}
[data-toastcmp-toast][data-swipe-out="true"][data-swipe-direction="down"] {
animation-name: swipe-out-down;
}
@keyframes swipe-out-left {
from {
transform: var(--y) translateX(var(--swipe-amount-x));
opacity: 1;
}
to {
transform: var(--y) translateX(calc(var(--swipe-amount-x) - 100%));
opacity: 0;
}
}
@keyframes swipe-out-right {
from {
transform: var(--y) translateX(var(--swipe-amount-x));
opacity: 1;
}
to {
transform: var(--y) translateX(calc(var(--swipe-amount-x) + 100%));
opacity: 0;
}
}
@keyframes swipe-out-up {
from {
transform: var(--y) translateY(var(--swipe-amount-y));
opacity: 1;
}
to {
transform: var(--y) translateY(calc(var(--swipe-amount-y) - 100%));
opacity: 0;
}
}
@keyframes swipe-out-down {
from {
transform: var(--y) translateY(var(--swipe-amount-y));
opacity: 1;
}
to {
transform: var(--y) translateY(calc(var(--swipe-amount-y) + 100%));
opacity: 0;
}
}
@media (max-width: 600px) {
[data-toastcmp-toaster] {
position: fixed;
right: var(--mobile-offset-right);
left: var(--mobile-offset-left);
width: 100%;
}
[data-toastcmp-toaster][dir="rtl"] {
left: calc(var(--mobile-offset-left) * -1);
}
[data-toastcmp-toaster] [data-toastcmp-toast] {
left: 0;
right: 0;
width: calc(100% - var(--mobile-offset-left) * 2);
}
[data-toastcmp-toaster][data-x-position="left"] {
left: var(--mobile-offset-left);
}
[data-toastcmp-toaster][data-y-position="bottom"] {
bottom: var(--mobile-offset-bottom);
}
[data-toastcmp-toaster][data-y-position="top"] {
top: var(--mobile-offset-top);
}
[data-toastcmp-toaster][data-x-position="center"] {
left: var(--mobile-offset-left);
right: var(--mobile-offset-right);
transform: none;
}
}
[data-toastcmp-toaster][data-theme="light"] {
--normal-bg: #fff;
--normal-border: var(--gray4);
--normal-text: var(--gray12);
--success-bg: hsl(143, 85%, 96%);
--success-border: hsl(145, 92%, 91%);
--success-text: hsl(140, 100%, 27%);
--info-bg: hsl(208, 100%, 97%);
--info-border: hsl(221, 91%, 91%);
--info-text: hsl(210, 92%, 45%);
--warning-bg: hsl(49, 100%, 97%);
--warning-border: hsl(49, 91%, 91%);
--warning-text: hsl(31, 92%, 45%);
--error-bg: hsl(359, 100%, 97%);
--error-border: hsl(359, 100%, 94%);
--error-text: hsl(360, 100%, 45%);
}
[data-toastcmp-toaster][data-theme="light"] [data-toastcmp-toast][data-invert="true"] {
--normal-bg: #000;
--normal-border: hsl(0, 0%, 20%);
--normal-text: var(--gray1);
}
[data-toastcmp-toaster][data-theme="dark"] [data-toastcmp-toast][data-invert="true"] {
--normal-bg: #fff;
--normal-border: var(--gray3);
--normal-text: var(--gray12);
}
[data-toastcmp-toaster][data-theme="dark"] {
--normal-bg: #000;
--normal-bg-hover: hsl(0, 0%, 12%);
--normal-border: hsl(0, 0%, 20%);
--normal-border-hover: hsl(0, 0%, 25%);
--normal-text: var(--gray1);
--success-bg: hsl(150, 100%, 6%);
--success-border: hsl(147, 100%, 12%);
--success-text: hsl(150, 86%, 65%);
--info-bg: hsl(215, 100%, 6%);
--info-border: hsl(223, 100%, 12%);
--info-text: hsl(216, 87%, 65%);
--warning-bg: hsl(64, 100%, 6%);
--warning-border: hsl(60, 100%, 12%);
--warning-text: hsl(46, 87%, 65%);
--error-bg: hsl(358, 76%, 10%);
--error-border: hsl(357, 89%, 16%);
--error-text: hsl(358, 100%, 81%);
}
[data-toastcmp-toaster][data-theme="dark"] [data-toastcmp-toast] [data-close-button] {
background: var(--normal-bg);
border-color: var(--normal-border);
color: var(--normal-text);
}
[data-toastcmp-toaster][data-theme="dark"] [data-toastcmp-toast] [data-close-button]:hover {
background: var(--normal-bg-hover);
border-color: var(--normal-border-hover);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="success"] {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="success"] [data-close-button] {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="info"] {
background: var(--info-bg);
border-color: var(--info-border);
color: var(--info-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="info"] [data-close-button] {
background: var(--info-bg);
border-color: var(--info-border);
color: var(--info-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="warning"] {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="warning"] [data-close-button] {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="error"] {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
[data-rich-colors="true"][data-toastcmp-toast][data-type="error"] [data-close-button] {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.toastcmp-loading-wrapper {
--size: 16px;
height: var(--size);
width: var(--size);
position: absolute;
inset: 0;
z-index: 10;
}
.toastcmp-loading-wrapper[data-visible="false"] {
transform-origin: center;
animation: toastcmp-fade-out 0.2s ease forwards;
}
.toastcmp-spinner {
position: relative;
top: 50%;
left: 50%;
height: var(--size);
width: var(--size);
}
.toastcmp-loading-bar {
animation: toastcmp-spin 1.2s linear infinite;
background: var(--gray11);
border-radius: 6px;
height: 8%;
left: -10%;
position: absolute;
top: -3.9%;
width: 24%;
}
.toastcmp-loading-bar:nth-child(1) {
animation-delay: -1.2s;
transform: rotate(0.0001deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(2) {
animation-delay: -1.1s;
transform: rotate(30deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(3) {
animation-delay: -1s;
transform: rotate(60deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(4) {
animation-delay: -0.9s;
transform: rotate(90deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(5) {
animation-delay: -0.8s;
transform: rotate(120deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(6) {
animation-delay: -0.7s;
transform: rotate(150deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(7) {
animation-delay: -0.6s;
transform: rotate(180deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(8) {
animation-delay: -0.5s;
transform: rotate(210deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(9) {
animation-delay: -0.4s;
transform: rotate(240deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(10) {
animation-delay: -0.3s;
transform: rotate(270deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(11) {
animation-delay: -0.2s;
transform: rotate(300deg) translate(146%);
}
.toastcmp-loading-bar:nth-child(12) {
animation-delay: -0.1s;
transform: rotate(330deg) translate(146%);
}
@keyframes toastcmp-fade-in {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes toastcmp-fade-out {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes toastcmp-spin {
0% {
opacity: 1;
}
100% {
opacity: 0.15;
}
}
@media (prefers-reduced-motion) {
[data-toastcmp-toast],
[data-toastcmp-toast]>*,
.toastcmp-loading-bar {
transition: none !important;
animation: none !important;
}
}
.toastcmp-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform-origin: center;
transition: opacity 200ms, transform 200ms;
}
.toastcmp-loader[data-visible="false"] {
opacity: 0;
transform: scale(0.8) translate(-50%, -50%);
}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.