Toast
Portal a transient notification into a fixed-position corner stack. The toast animates in on mount (opacity + slide) and out before removal — Domphy holds the DOM until the exit transition finishes via _onBeforeRemove. No host-tag restriction; apply to any <div>.
The toast surface uses a medium elevation() box-shadow so it visibly floats above page content.
Customization
Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.
There are four levels of customization, in increasing order of effort:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas
Unit - U = fontSize / 4 - convert final values with themeSpacing(n).
Size - n = intrinsic text lines, w = wrapping level, d = density factor:
height = (n * 6 + 2 * d * w) * U
paddingBlock = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius = d * w * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import type { DomphyElement, ElementNode, PartialElement } from "@domphy/core";
import { toState } from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
type ToastPosition =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
/**
* Renders a transient notification surface as a fixed-position overlay (portaled
* into a corner stack), animating in on mount and out before removal. No host
* tag check; typically applied to a `<div>`.
*
* @param props.position - Corner of the screen for the toast stack. Optional, one of `"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"`. Defaults to `"top-center"`.
* @param props.color - Theme color for the toast surface. Optional. Defaults to `"neutral"`.
* @example { div: "Saved!", $: [toast({ position: "top-right" })] }
*/
function toast(
props: { position?: ToastPosition; color?: ThemeColor } = {},
): PartialElement {
const { position = "top-center", color = "neutral" } = props;
const state = toState(false);
const isTop = position.startsWith("top");
const isCenter = position.endsWith("center");
const isRight = position.endsWith("right");
const overlayEle: DomphyElement<"div"> = {
div: [],
id: `domphy-toast-${position}`,
style: {
position: "fixed",
display: "flex",
flexDirection: isTop ? "column" : "column-reverse",
alignItems: isCenter ? "center" : isRight ? "end" : "start",
inset: 0,
gap: themeSpacing(4),
zIndex: 30,
padding: themeSpacing(6),
pointerEvents: "none",
},
};
return {
_portal: (rootNode) => {
let overlay = rootNode.domElement!.querySelector(
`#domphy-toast-${position}`,
);
if (!overlay) {
const overlayNode = rootNode.children!.insert(
overlayEle,
) as ElementNode;
overlay = overlayNode.domElement!;
}
return overlay;
},
role: "status",
ariaAtomic: "true",
// Toast is rendered as an overlay surface, so it uses the inverted branch.
dataTone: "shift-17",
style: {
minWidth: themeSpacing(32),
pointerEvents: "auto",
paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2),
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 4),
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2),
fontSize: (listener) => themeSize(listener, "inherit"),
color: (listener) => themeColor(listener, "text", color),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
boxShadow: elevation("medium"),
opacity: (listener) => Number(state.get(listener)),
transform: (listener) =>
state.get(listener)
? "translateY(0)"
: isTop
? "translateY(-100%)"
: "translateY(100%)",
transition: "opacity 300ms ease, transform 300ms ease",
},
_onMount: () => requestAnimationFrame(() => state.set(true)),
_onBeforeRemove: (node, done) => {
let finished = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let overlayRemoved = false;
const rootNode = node.getRoot();
const overlayEl = node.domElement?.parentElement ?? null;
// The #domphy-toast-{position} overlay is inserted into the root on
// first use and shared by every toast at that position — remove it once
// its LAST toast is gone instead of leaking one empty fixed container
// per used position for the app's whole lifetime.
const removeOverlayIfEmpty = () => {
if (overlayRemoved) return;
if (
!overlayEl ||
overlayEl.id !== `domphy-toast-${position}` ||
overlayEl.childElementCount > 0
) {
return;
}
overlayRemoved = true;
const item = rootNode.children?.items.find(
(it) => (it as ElementNode).domElement === overlayEl,
);
if (item) rootNode.children!.remove(item);
else overlayEl.remove();
};
const finish = () => {
if (finished) return;
finished = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
node.domElement!.removeEventListener("transitionend", onEnd);
done();
removeOverlayIfEmpty();
};
const onEnd = (e: Event) => {
if ((e as TransitionEvent).propertyName === "transform") finish();
};
node.domElement!.addEventListener("transitionend", onEnd);
// Fallback: if transitionend never fires (reduced-motion, display:none,
// early detach), unblock removal after the transition duration + buffer.
timer = setTimeout(finish, 350);
// If the node is detached before the exit animation settles, clear the
// pending fallback timer and the transitionend listener so they cannot
// fire on a removed node.
node.addHook("Remove", () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
node.domElement?.removeEventListener("transitionend", onEnd);
removeOverlayIfEmpty();
});
state.set(false);
},
};
}
export { toast };Props
toast({
position?: ToastPosition, // default "top-center"
color?: ThemeColor, // default "neutral"
})ToastPosition is one of:
"top-left" | "top-center" | "top-right"
"bottom-left" | "bottom-center" | "bottom-right"Usage
Insert the toast element as a child of the root node, then remove it after a delay:
import { button, toast } from "@domphy/ui"
const App = {
div: [{
button: "Show Toast",
$: [button()],
onClick: (_e, node) => {
const toastEle = {
div: "Saved successfully",
$: [toast({ position: "bottom-right" })],
}
const toastNode = node.getRoot().children.insert(toastEle)
setTimeout(() => toastNode.remove(), 3000)
},
}],
}Position
Each distinct position value gets one shared overlay container (id="domphy-toast-<position>"). Multiple toasts with the same position stack in the same overlay; different positions render in independent overlays.
top-*positions stack top-to-bottom (newest at bottom of stack).bottom-*positions stack bottom-to-top (newest at top of stack).*-centeraligns items tocenter;*-lefttostart;*-righttoend.
Color
Uses the ThemeColor system. The toast surface uses dataTone: "shift-17" (the inverted branch), so "neutral" renders as a near-black surface in light mode. Pass any theme color to tint the surface:
{ div: "Error!", $: [toast({ color: "danger", position: "top-right" })] }Animation
The enter/exit uses CSS transitions on opacity and translateY. On mount a requestAnimationFrame flips the visible state from false to true, triggering the transition. On _onBeforeRemove the state resets to false and removal is deferred until the transform transition ends.