Most toast libraries stack notifications. Fire three errors and you get three toasts piled on top of each other, and the one you needed to read is already pushed off the screen.

I built a small toast library called Ding around the opposite idea: only one toast is visible at a time, and a new call morphs the one on screen into the new message. This post walks through the parts that were interesting to build: the spring animation, the update rules, and the tradeoffs.

Why springs instead of CSS transitions

A CSS transition has a fixed duration and an easing curve. If you interrupt it halfway, the browser restarts from the current value and the velocity is lost. For a toast that changes size every time its text changes, that looks jerky.

A spring does not have a duration. It has a target, a position and a velocity. When the target moves, the velocity stays, so the motion bends smoothly toward the new value instead of restarting.

A spring in under 30 lines

The solver is a mass-spring-damper on a single number. Here is the core of it:

export class Spring { v = 0; to: number; cfg: Required<SpringConfig>; constructor(public x: number, cfg: SpringConfig) { this.to = x; this.cfg = { stiffness: 170, damping: 18, mass: 1, ...cfg }; } /** Advance dt seconds. Returns true while still moving. */ step(dt: number): boolean { const { stiffness: k, damping: c, mass: m } = this.cfg; const h = 1 / 240; for (let t = 0; t < dt; t += h) { this.v += ((-k * (this.x - this.to) - c * this.v) / m) * h; this.x += this.v * h; } if (Math.abs(this.v) < 0.05 && Math.abs(this.x - this.to) < 0.05) { this.x = this.to; this.v = 0; return false; } return true; } } 

Each step applies Hooke's law, force = -k * (x - target) - c * v, then integrates velocity and position. It runs at a fixed 1/240 s sub-step, so the result does not depend on the frame rate. When the toast changes size you set spring.to = newWidth, and the velocity it already has carries over.

One shared requestAnimationFrame loop drives every spring and stops itself when nothing is moving:

const tasks = new Set<(dt: number) => boolean>(); function tick(now: number) { const dt = Math.min(0.1, (now - last) / 1000); last = now; for (const t of tasks) if (!t(dt)) tasks.delete(t); raf = tasks.size ? requestAnimationFrame(tick) : 0; } 

The Math.min(0.1, ...) keeps a backgrounded tab from handing the solver a huge time step when it wakes up.

Springs in plain CSS with linear()

Not everything needs a per-frame JavaScript loop. For simple enter and exit motion I bake a spring into a CSS linear() easing. The closed-form solution of a damped spring gives the position at any time, so you can sample it and hand the points to the browser:

const pts: string[] = []; for (let i = 0; i <= steps; i++) pts.push(String(+f((i / steps) * settle).toFixed(4))); return { easing: `linear(${pts.join(',')})`, duration: Math.round(settle * 1000) }; 

The browser then animates it with no JavaScript. The result is a normal transition-timing-function that overshoots and settles like a spring.

One toast, many callers

Replacing a toast sounds simple until two async calls race. A slow request finishes late and overwrites a newer message.

Ding gives every call a caller id and remembers which caller owns the shared toast. Only the newest caller can update it:

const owner = new Map<string, string>(); // toast id -> newest caller id // ... if (owner.get(id) === callerId) apply(id, p); 

So a toast.promise() from an old request cannot overwrite a message that came after it.

The tradeoff: you lose history

Morphing has a real cost. A queue keeps every event. Morphing keeps only the latest, so a "saved" message followed immediately by a network error replaces the first one.

I handled this in two ways:

  • Errors, loading toasts and toasts with a button do not auto-close. They stay until dismissed, so nothing you need to act on disappears on a timer.
  • Repeating the same toast adds a counter (×2) instead of re-animating.

It is still a tradeoff. If you need every message to stay visible, a queue is the better design. Ding is for apps where one clear message at a time is the goal.

Accessibility

Animated UI is easy to get wrong for screen readers, so these were decided early:

  • Messages go through persistent live regions that exist before the text changes. Errors use role="alert" and everything else uses role="status".
  • Esc dismisses the toast, and Alt + T moves focus into it.
  • Hover and keyboard focus pause the timer.
  • Under prefers-reduced-motion, motion is replaced with a fade.

No stylesheet

Styles are injected from JavaScript the first time a toaster mounts, so there is nothing to import. Setup is one line, and it works the same in React, Svelte and plain DOM, because the core owns all the behavior and the framework packages only render.

<script> import { Toaster, toast } from '@yusufceng1z/ding-svelte'; </script> <Toaster position="top-center" /> <button onclick={() => toast.success('Saved')}>Save</button> 

Try it

The code is MIT licensed: https://github.com/YusufCeng1z/ding

It is my first published library, so I would like to hear where this design falls short.