A toast that stacks
In 2023 Emil Kowalski built a toast library and told the story in Building a Toast Component. Sonner is downloaded over 40,000,000 times a week from npm and used by Cursor, X, and OpenAI. The market was crowded; his answer to why people chose it is a list of small decisions. The first was the name: react-toast and its kind felt cheap, so he chose the French sonner, “to ring,” trading discoverability for elegance.
Transitions, not keyframes#
The second was the animation: “I believe Sonner took off immediately because of the stacking animation which was done by some companies before, but never open sourced.” The launch video he chose focused on that motion.
His first version used CSS keyframes, and they broke when toasts arrived quickly: older toasts jumped into position because keyframes are not interruptible. Transitions retarget mid-flight, so he switched to one rule, transition: transform 400ms ease. A transition needs a start state, so a useEffect sets a mounted flag after first render and writes it to a data attribute; he notes @starting-style would now make this much simpler.
.sonner-toast { transition: transform 400ms ease;}[data-mounted="true"] { transform: translateY(0);}[data-mounted="false"] { transform: translateY(100%);}The stacking arithmetic#
Every toast is position: absolute, offset by the gap times its index, and scaled down by 0.05 times its index for depth. The post’s diagram reads Y(0) scale(1), Y(-14px) scale(0.95), Y(-28px) scale(0.9), matching the Toaster’s defaults of gap 14 and visibleToasts 3. Two custom properties drive it.
[data-sonner-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(--toasts-before) * 0.05) + 1));}Toasts of different heights stick out unevenly, so every toast takes the height of the front one while stacked. The figure builds that stack: add toasts quickly and toggle Transitions/Keyframes to feel the jump Emil describes. Its swipe readouts matter next.
Swipe with momentum#
Swipe to dismiss is a pointer listener writing the drag distance into a --swipe-amount variable used by translateY. Removal is momentum-based: distance over elapsed time gives a velocity, and the toast goes if the swipe passes SWIPE_THRESHOLD or the velocity passes 0.11, “just a number that I ended up on through trial and error.” A short fast flick clears it where a long slow drag would not.
const timeTaken = new Date().getTime() - dragStartTime.current.getTime();const velocity = Math.abs(swipeAmount) / timeTaken;if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { removeToast(toast);}Hover expands the stack. The expanded offset is index times gap plus the heights of the toasts before it, a reduce over a heights array written to an --offset variable; the expand prop makes that the resting state.
Details and developer experience#
State avoids React Context through the Observer pattern: the Toaster subscribes, toast() notifies, the list renders with a map. No hooks, no providers. The toast.promise API, one promise plus copy for loading, success, and error, drew Theo’s “like React Query for toasts.” The API follows Timo’s react-hot-toast “because it’s simply very good,” and developer experience included a custom docs site with interactive examples.
import { toast } from "sonner";toast("My toast");Then the big little details. A toast lasts 4 seconds unless hovered, and a useIsDocumentHidden hook watching document.hidden pauses the timer in a hidden tab. An :after pseudo-element fills the gaps between toasts so hover does not flicker.
A drag captures all future pointer events, and dragging upward is slowed with friction rather than blocked, “nicer than just stopping the toast immediately.” He borrows Paul Graham: “a thousand barely audible voices all singing in tune.” His verdict is developer experience plus looks, the real differentiator: “Beauty is generally underutilized in software so you can use it as leverage to stand out.”