Mochi
I built a toast library. The world didn't need one. But I had a specific vision for what a toast could feel like, and nothing matched it.
Mochi is a gooey toast library for React. Each toast is an SVG blob that morphs, breathes, and pulses. It's built on Base UI's Toast primitive, which handles accessibility, focus management, and swipe-to-dismiss. Everything on top of that — the gooey rendering, the animations, the deduplication — is Mochi. One toast at a time, no close button. Dismiss by swiping or waiting.
Gooey
The core idea: a toast should feel alive.
Each toast is an inline SVG with two <rect> elements: a pill (the header) and a body (the expandable description). An SVG gooey filter combines them into one organic shape. When the toast expands, the rects overlap and the filter morphs them into a continuous blob — something you can't pull off with CSS border-radius.
The filter works in three steps. A Gaussian blur softens both rects into a blobby mass (tied to corner radius, so rounder toasts get softer edges). Then a color matrix cranks the alpha contrast with values 0 0 0 20 -10, snapping soft edges back to hard and creating that metaball look. Finally a composite step layers the sharp original content on top so text and icons stay crisp through the filter.
The filter also acts as a kind of co-designer. It amplifies everything: edge movement, scale changes, even padding. I had to tune several values specifically because the filter exaggerates them. More on that later.
Hover or tap to expand
Both rects animate with scaleY instead of height. Animating height causes the filter to recalculate on every geometry change, producing visible jumps. Scale transforms happen after the filter, so animation stays smooth — and they get GPU compositing for free. SVG filters also respect CSS transitions natively, so there's no JavaScript animation loop: change a CSS variable, and the filter just re-renders.
Expanding
Toasts with a description expand on hover and collapse when you leave. No click needed. Loading toasts are the exception — they never expand, to avoid visual noise while something's still in progress.
Hover works here because expanding doesn't change any state. It just shows more info. Instant feedback, no commitment.
Under the hood, the entire toast is driven by CSS custom properties set as inline styles on the root element. React computes all values from the open/closed state, and CSS transitions with spring easing handle every animation. React owns the state, CSS owns the motion.
One tricky bit: the expanded height has to stay stable throughout the collapse animation. If it changed mid-transition, the scaleY target would shift and the toast would visually jump. So I freeze the expanded height into a ref while open and hold it during collapse.
Position
From a single position prop (like "bottom-right"), three behaviors cascade:
Pill alignment. The pill aligns to the screen edge: right-aligned for right-edge positions, left-aligned for left, centered for center. A bottom-right toast's pill sits close to the screen edge, feeling like a natural extension of the UI chrome.
Expand direction. Bottom toasts expand upward. Top toasts expand downward. The pill stays anchored to the screen edge. The SVG canvas flips with scaleY(-1) for bottom-edge toasts, so expansion always pushes away from the edge.
Text alignment. The description text follows the pill's alignment: right-aligned for right-edge positions, centered for center. Left-aligned description text under a centered pill looks off-balance, so the text follows the same spatial anchor.
One prop, three derived behaviors. The reader never notices because it just feels right. Try moving the dot grid below — the pill alignment, expand direction, and text alignment all shift together.
Spring
All animations use a single CSS spring easing curve: a linear() function with 33 stops that approximates a spring. All CSS, no JavaScript animation loops.
--mochi-spring: linear(
0, 0.002 0.6%, 0.007 1.2%, ...
1.028 46.3%, 1.026 51.9%,
1.01 66.1%, 1.003 74.9%, 1 85.2%, 1
);
Spring easing only works through CSS transition-timing-function — JS animation libraries that set inline transform values every frame bypass it entirely. CSS transitions are also naturally interruptible: hover and unhover quickly, and the animation just reverses from wherever it is. And the custom properties flow directly into SVG attributes, keeping everything in sync without coordination code.
For users who prefer reduced motion, all animation durations drop to near-zero via a prefers-reduced-motion: reduce media query.
Crossfade
When a promise toast goes from loading to success, the header crossfades. The old text blurs out while the new text blurs in.
Two layers: when the header key changes (type + title), the current content becomes the "previous" layer with a blur-out animation (150ms). The new content enters with a blur-in (600ms, spring easing).
I use blur instead of a plain opacity fade because with opacity you can read both labels during the overlap, which looks like a bug. Blur makes the old text unreadable immediately, so the overlap feels intentional — like one label morphing into the other. Watch the header in the demo below — it starts as a loading spinner, then crossfades into the success state.
Dedup
This one was important to get right.
Without dedup, every toast.success({ title: "Saved" }) creates a new toast. Since Mochi shows one toast at a time, this causes three problems: visual stutter from repeated enter/exit animations, ghost toasts piling up in state, and screen readers announcing the same thing over and over.
Mochi handles this automatically. It builds a fingerprint from type:title. If a matching toast is already on screen, it resets the dismiss timer and plays a pulsate animation instead of creating a new one. The user sees the toast "breathe" — try clicking the Ping demo above repeatedly to see it in action.
The fingerprint uses type and title because both are always strings. Description is a ReactNode which can contain JSX and can't be reliably compared, so it's excluded.
The hardest part was cleanup: with one-toast mode, old toasts are visually dead before their removal callbacks fire. During that window, dedup logic would try to update a ghost toast. The fix is to eagerly clear all dedup state before creating any new toast, with onRemove as a safety net for other dismiss paths like swipe and timeout.
Pulse
When a duplicate is detected, the existing toast scales to 1.03 and back over 400ms. Small, but noticeable.
I originally tried 1.05, but the gooey filter amplifies scale changes. The blur-and-threshold pipeline exaggerates any edge movement, so 1.03 is the sweet spot — a gentle "breathe" that doesn't feel aggressive. This is the filter acting as co-designer again: 1.05 works fine on a standard <div> toast, but the filter makes it feel like twice as much.
One problem: CSS animations don't restart when you set the same attribute again. The browser sees no change. The fix: void el.offsetWidth between removing and re-adding the animation attribute. Reading offsetWidth forces the browser to process all pending style changes, guaranteeing the removal is committed before the re-add.
el.removeAttribute("data-mochi-repeat");
void el.offsetWidth; // Force reflow
el.setAttribute("data-mochi-repeat", "");
Credit
I want to be clear about what I didn't build. Base UI's Toast primitive handles all the accessibility (ARIA live regions, screen reader announcements, F6 landmark navigation), swipe-to-dismiss, lifecycle management, and the base components. It's well-documented and battle-tested. I use it directly.
Everything else I described — the gooey SVG rendering, the two-rect architecture, the animation state machine, position-aware alignment, the crossfade, deduplication with eager cleanup, pulsate — that's what Mochi adds on top.
Why
The toast space is crowded. Sonner exists and it's great. Beautiful animations, millions of downloads. The right choice for most projects.
Mochi exists because I wanted to explore a different visual direction. The SVG gooey filter is the entire premise. Every decision — the two-rect architecture, scaleY over height, CSS variables as the animation bridge, the frozen ref pattern, the eager-clear dedup — flows from that one visual idea.
Sometimes the best reason to build something is that you can see it clearly in your head and it doesn't exist yet.