COMPONENTS/FEEDBACK
Toast.
A small update, without interruption.
In practice
Wrap your application once in ToastProvider. Call useToast().add({title, description}). Includes dismissal, timeouts and announcement management.
Installation
Install Coal once. Components and TypeScript declarations are included. React 19 and React DOM 19 are peer dependencies.
Terminal · local package
npm install http://localhost:3100/downloads/chlohal-coal-ui-0.5.0.tgzThis installs the local package. It is not yet published on npm. See installation for stylesheet setup and integration.
Usage
example.tsx
// Wrap your application with <ToastProvider> from "@chlohal/coal-ui".
"use client";
import * as React from "react";
import "@chlohal/coal-ui/styles.css";
import { Button, useToast } from "@chlohal/coal-ui";
export default function Example() {
const toast = useToast();
return <Button variant="outline" onClick={() => toast.add({ title: "Changes saved.", description: "Everything is right where you left it." })}>Show notification</Button>;
}
Source
The original Coal implementation. Use the package export to integrate it.
packages/react/src/toast.tsx
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { type Intent, intentSymbols } from "./intent.js";
import { useRequired } from "./internal.js";
export type ToastOptions = {
title: React.ReactNode;
description?: React.ReactNode;
timeout?: number;
intent?: Intent;
};
type Entry = ToastOptions & { id: string };
type Manager = {
add: (options: ToastOptions) => string;
close: (id?: string) => void;
};
const Context = React.createContext<Manager | undefined>(undefined);
export function useToast() {
return useRequired(Context, "useToast");
}
function ToastItem({
toast,
close,
}: {
toast: Entry;
close: (id: string) => void;
}) {
const [paused, setPaused] = React.useState(false);
React.useEffect(() => {
if (
paused ||
toast.timeout === 0 ||
(toast.timeout === undefined &&
(toast.intent === "danger" || toast.intent === "warning"))
)
return;
const timer = setTimeout(() => close(toast.id), toast.timeout ?? 5000);
return () => clearTimeout(timer);
}, [toast, close, paused]);
return (
<div
className={`coal-toast coal-intent-${toast.intent ?? "neutral"}`}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setPaused(false);
}}
>
<div
role={toast.intent === "danger" ? "alert" : "status"}
aria-atomic="true"
>
<p className="coal-toast-title">
<span className="coal-intent-symbol" aria-hidden="true">
{intentSymbols[toast.intent ?? "neutral"]}
</span>
<span>{toast.title}</span>
</p>
{toast.description && (
<p className="coal-toast-description">{toast.description}</p>
)}
</div>
<button
type="button"
aria-label="Dismiss notification"
className="coal-toast-close"
onClick={() => close(toast.id)}
>
×
</button>
</div>
);
}
export function ToastProvider({
children,
limit = 3,
timeout = 5000,
}: {
children: React.ReactNode;
limit?: number;
timeout?: number;
}) {
const [toasts, setToasts] = React.useState<Entry[]>([]);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const id = React.useId();
const counter = React.useRef(0);
const close = React.useCallback(
(id?: string) =>
setToasts((list) => (id ? list.filter((t) => t.id !== id) : [])),
[],
);
const add = React.useCallback(
(options: ToastOptions) => {
const next = `${id}-${++counter.current}`;
setToasts((list) => {
const entry = {
...options,
timeout:
options.timeout ??
(options.intent === "danger" || options.intent === "warning"
? 0
: timeout),
id: next,
};
const all = [...list, entry];
const transient = new Set(
all
.filter((t) => t.timeout !== 0)
.slice(-Math.max(1, limit))
.map((t) => t.id),
);
return all.filter((t) => t.timeout === 0 || transient.has(t.id));
});
return next;
},
[id, limit, timeout],
);
return (
<Context.Provider value={{ add, close }}>
{children}
{mounted &&
createPortal(
<section aria-label="Notifications" className="coal-toaster">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} close={close} />
))}
</section>,
document.body,
)}
</Context.Provider>
);
}
Accessibility checklist
- Provide an accessible name for controls, including icon-only buttons.
- Check keyboard navigation, visible focus and disabled states in your application.
- Do not rely on color alone to communicate status.