Mochi
Toast notifications are one of those UI patterns everyone uses but nobody thinks about. A colored bar slides in, says "Saved", slides out. Done.
I wanted to make one that felt alive. Something that morphs and breathes instead of sliding and fading.
Click the buttons. Hover a toast to expand it. Click the same button rapidly to see the dedup pulsate. Try different positions.
The gooey trick
The core visual effect is an SVG filter. Instead of a <div> with border-radius, each toast is an inline <svg> with two <rect> elements combined through a gooey metaball filter:
- Blur both rects into soft blobs (
feGaussianBlur) - Crunch alpha back to hard edges (
feColorMatrixwith values0 0 0 20 -10) - Composite the original sharp content on top (
feComposite)
When the pill and body overlap during expand/collapse, they merge into a single organic blob. CSS border-radius can't do this.
<filter id="gooey">
<feGaussianBlur stdDeviation={blur} result="blur" />
<feColorMatrix in="blur" mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10"
result="goo" />
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
The blur amount is tied to the corner radius (roundness * 0.5). Tighter corners = less blur = crisper edges. It scales naturally.
Two rects, one blob
The SVG has exactly two shapes:
- Pill: the always-visible header bar. Width is measured from content via ResizeObserver.
- Body: the expandable description area. Starts at
scaleY(0)and transitions toscaleY(1)on hover.
Both use transform-box: fill-box and transform-origin: 50% 0% so transforms originate from the top of each rect, not the SVG viewBox origin.
Why scaleY instead of animating height directly? The gooey filter recalculates on geometry changes, causing visible jumps. scaleY is a transform that composites after the filter, giving smooth animations and GPU compositing for free.
Spring easing in CSS
All animations use a spring curve via CSS linear() with 37 stops:
--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
);
This approximates a spring that slightly overshoots and settles. CSS transitions with this easing are interruptible by nature. Hover and unhover rapidly and the transition reverses smoothly from wherever it is. No JavaScript animation library needed.
CSS custom properties as animation bridge
The toast's state is expressed as CSS custom properties set inline on the root element:
--_htotal height--_pwpill width--_sypill scaleY--_bybody scaleY and opacity--_bwbody width
CSS transitions on these properties drive everything. This is better than class-based animation because the spring easing only works via transition-timing-function, and it's better than JS-driven animation because CSS transitions are interruptible without explicit cancellation logic.
Expand direction
Bottom-positioned toasts expand upward. Top-positioned toasts expand downward. The pill stays anchored to the screen edge.
The trick: the SVG canvas itself flips with scaleY(-1) for upward expansion. The coordinate system inverts, so the body rect appears to grow upward. Content is repositioned accordingly.
Header crossfade
When a toast type changes (loading to success during a promise), the header crossfades with a blur effect. The old content blurs out in 150ms, the new content blurs in over 600ms with the spring curve.
Why blur instead of a simple opacity fade? Plain crossfades show both labels overlapping and readable, which looks like a bug. Blur makes the exiting text illegible immediately, so the overlap feels intentional.
Deduplication
Click the same toast rapidly. Instead of creating a new toast each time, the existing one pulsates.
The manager fingerprints each toast by type:title. If a match already exists, it resets the auto-dismiss timer and increments a repeatCount. The component detects the change and triggers a CSS pulsate animation (scale: 1 > 1.03 > 1).
Retriggering the animation requires a forced reflow. The pattern:
el.removeAttribute("data-mochi-repeat");
void el.offsetWidth; // Force reflow
el.setAttribute("data-mochi-repeat", "");
requestAnimationFrame doesn't work here because useEffect runs post-paint. The rAF callback can fire in the same compositing frame as the removal, so the browser never sees the attribute disappear. void el.offsetWidth forces a synchronous reflow that guarantees the removal is committed before the re-add.
Pulsate
The pulsate uses scale, not transform: scale(). Subtle but critical. The toast root already uses transform for positioning and enter/exit animations. If the pulsate set transform: scale(1.03), it would override those transforms and the toast would jump. The individual CSS scale property composes with transform without overriding it.
Built on Base UI
Base UI's Toast primitive handles the hard parts: ARIA live regions, swipe-to-dismiss, focus management, lifecycle management, enter/exit attribute transitions. Everything visual is built on top.
The library defaults to limit={1}. One toast at a time. New arrivals crossfade with the current toast. This is deliberate. The gooey animation is meant to be focused, not stacked five high.
Theme detection
The hook checks .dark class on <html> first (Tailwind convention), then data-theme="dark", then prefers-color-scheme. A MutationObserver watches for class changes, and a matchMedia listener watches for system preference changes. Both trigger a re-evaluation.
State colors in oklch
Each toast type has a color in oklch. Why oklch? It's perceptually uniform. The same lightness across different hues produces colors that actually look equally bright. The badge backgrounds use color-mix(in oklch, var(--_c) 20%, transparent). With HSL, a 20% mix of green and a 20% mix of blue would look wildly different. With oklch, they're balanced.
No close button
Deliberate omission. Toasts auto-dismiss after timeout. A close button implies permanence and creates false urgency. Swipe-to-dismiss is available for users who want to dismiss early.
Stack
- React 19 + Base UI Toast for the primitive
- SVG gooey filter for the morph effect
- CSS custom properties + spring
linear()for animations - Zero animation libraries - no framer-motion, no GSAP
- Vanilla CSS with
data-*attribute selectors
About 600 lines of TypeScript and 200 lines of CSS.