Sileo
Toast notifications with gooey morphing and spring physics. Vanilla core, zero dependencies, and all the animation in CSS. Works with any framework; ships a Vue 3 adapter.
See the interactive demo → · Source on GitHub ↗ · Package on npm ↗
What it is
Sileo shows a capsule with the state and the title. Hover it and the message unfolds: capsule and panel merge into a single shape, with no seam, through an SVG metaball filter.
When several notifications are up at once they form an overlapping row of tabs at the title's height. Only the focused one shows its text; the rest stay as circles with their icon. See The tab row.
The JavaScript only keeps the state, builds the DOM, measures two things (capsule width and panel height) and places the tabs. All the movement —the spring, the morphing, the entrance and the exit— is CSS.
Install
With a bundler (npm, pnpm, yarn)
npm i sileojsimport { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";sileojs/styles.css there is no geometry and no
animation, and the toasts come out unstyled.
No build, straight from the browser
It is an ES module with no dependencies, so you can import it as is:
<link rel="stylesheet" href="/path/to/sileojs/src/sileo.css">
<script type="module">
import { sileo, createToaster } from "/path/to/sileojs/src/sileo.js";
createToaster({ position: "top-right" });
sileo.success({ title: "Done" });
</script>.js with
Content-Type: text/javascript. Careful with
python -m http.server: on Windows it serves them as
text/plain and the browser rejects them.
npx serve . works.
What the package ships
| Import | Contents |
|---|---|
sileojs | Vanilla core: sileo, createToaster, getToaster… |
sileojs/vue | Vue 3 adapter: SileoPlugin, SileoToaster, useSileo |
sileojs/styles.css | The stylesheet |
Quick start
Two steps: mount the toaster once, then call sileo.*() from anywhere.
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";
// once, when the app boots
createToaster({ position: "top-right", theme: "system" });
// from anywhere
sileo.success({
title: "Saved",
description: "Your changes were synced with the server.",
});
createToaster() is optional: if you never call it, the first
sileo.*() mounts one with the defaults. Call it when you
want to set the position, the theme or the offsets.
JavaScript / vanilla
The core knows nothing about frameworks. The pattern is always the same: mount the toaster on boot, destroy it if your view goes away.
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";
const toaster = createToaster({
position: "bottom-center",
theme: "system",
offset: 24,
});
document.querySelector("#save").addEventListener("click", async () => {
await sileo.promise(save(), {
loading: { id: "save", title: "Saving", description: "One moment…" },
success: () => ({ title: "Saved", description: "All done." }),
error: (err) => ({ title: "Error", description: String(err.message) }),
});
});
// when leaving the page / unmounting the widget
toaster.destroy();Vue 3
Two ways. The global one is the handiest if you use it across the app.
No component: plugin and global $sileo
The plugin mounts the toaster on boot and makes $sileo
available in every template. No component to place, nothing to import in
each file.
// main.js
import { createApp } from "vue";
import { SileoPlugin } from "sileojs/vue";
import "sileojs/styles.css";
import App from "./App.vue";
createApp(App)
.use(SileoPlugin, {
position: "top-right",
theme: "light",
options: { roundness: 14, styles: { toast: "top-90" } },
})
.mount("#app");<!-- any component, no imports -->
<template>
<button @click="$sileo.success({ title: 'Saved' })">
Save
</button>
</template>$sileo is a globalProperty: it only exists inside
the template. In <script setup> use
import { sileo } from "sileojs" or
inject("sileo") — the plugin provides it too.
The plugin takes the same options as createToaster(). With
{ mount: false } it registers $sileo and the
component but mounts nothing: the toaster is yours to mount.
With the component
Useful when you want the toaster to live and die with one view, or to change its props reactively.
<script setup>
import { SileoToaster, useSileo } from "sileojs/vue";
import "sileojs/styles.css";
const sileo = useSileo();
</script>
<template>
<SileoToaster position="top-right" theme="system" />
<button @click="sileo.success({ title: 'Saved' })">
Save
</button>
</template>
Props: position, theme, offset,
visibleToasts, options and styles
(shorthand for options.styles). Changing them reconfigures
the toaster live.
<SileoToaster position="top-right" theme="light" :styles="{ toast: 'top-90' }" />Reactive config: useSileoConfig()
Returns a reactive object shared by the whole app. Mutating it
reconfigures the toaster on the fly — theme, position and styles
included, and it reaches the toasts already on screen. In templates it
is also $sileoConfig.
<script setup>
import { useSileoConfig } from "sileojs/vue";
const cfg = useSileoConfig();
const dark = () => (cfg.theme = "dark");
const below = () => (cfg.position = "bottom-center");
const big = () => (cfg.styles.title = "text-lg font-bold");
</script>
<template>
<select v-model="cfg.theme">
<option>light</option><option>dark</option><option>system</option>
</select>
</template>| Field | What it is |
|---|---|
cfg.position | One of the 6 positions |
cfg.theme | light | dark | system |
cfg.offset | Number/string or { top, right, bottom, left } |
cfg.visibleToasts | Tabs visible in the row |
cfg.options | Defaults for every toast |
cfg.styles | Per-part styles (see Per-part styles) |
createToaster()) already mounted it, the component reuses it
and does not destroy it on unmount: it only destroys the
one it created.
React
import { useEffect } from "react";
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";
export default function App() {
useEffect(() => {
const toaster = createToaster({ position: "top-right", theme: "system" });
return () => toaster.destroy();
}, []);
return (
<button onClick={() => sileo.success({ title: "Saved" })}>
Save
</button>
);
}destroy uses this: call it on the toaster
(() => toaster.destroy()). Passing
createToaster(…).destroy bare as the cleanup function throws
on unmount.
In React 18 with StrictMode the effect mounts, cleans up and
mounts again in development. That is fine: the second
createToaster() creates a new one after the first
destroy().
Svelte
<script>
import { onMount } from "svelte";
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";
onMount(() => {
const toaster = createToaster({ position: "top-right" });
return () => toaster.destroy();
});
</script>
<button on:click={() => sileo.success({ title: "Saved" })}>
Save
</button>Angular
The stylesheet goes in angular.json:
"styles": ["node_modules/sileojs/src/sileo.css"]import { Component, OnInit, OnDestroy } from "@angular/core";
import { sileo, createToaster } from "sileojs";
@Component({
selector: "app-root",
template: `<button (click)="save()">Save</button>`,
})
export class AppComponent implements OnInit, OnDestroy {
private toaster?: ReturnType<typeof createToaster>;
ngOnInit() {
this.toaster = createToaster({ position: "top-right" });
}
ngOnDestroy() {
this.toaster?.destroy();
}
save() {
sileo.success({ title: "Saved" });
}
}.d.ts files yet. Turn on allowJs or declare the
module: declare module "sileojs";
SSR and no build
The core touches document as soon as you mount the toaster,
so in Next, Nuxt, SvelteKit or Astro mount it on the client only: inside
useEffect, onMounted, onMount or a
client <script>. Importing the module by itself runs
nothing.
Without a bundler you can resolve the package name with an import map:
<script type="importmap">
{ "imports": { "sileojs": "/vendor/sileojs/src/sileo.js" } }
</script>
<script type="module">
import { sileo, createToaster } from "sileojs";
createToaster();
window.sileo = sileo; // if you want it truly global
</script>API · sileo.*
| Method | Returns | What it does |
|---|---|---|
sileo.show(opts) | id | Uses opts.type (or opts.state) as the state |
sileo.success(opts) | id | Shorthands per state |
sileo.error(opts) | id | |
sileo.warning(opts) | id | |
sileo.info(opts) | id | |
sileo.action(opts) | id | |
sileo.loading(opts) | id | |
sileo.promise(p, opts) | the promise | loading → success / error / action |
sileo.update(id, opts) | — | Changes a live toast: collapses it, swaps the content and carries on |
sileo.dismiss(id) | — | Animated exit |
sileo.clear(position?) | — | Clears everything, or just one position |
sileo.configure(opts) | the toaster | Reconfigures it live (mounts it if there is none) |
sileo.setTheme(t) | the toaster | configure shorthands |
sileo.setPosition(p) | the toaster | |
sileo.setStyles(s) | the toaster | |
sileo.getConfig() | the config | The current config, or null with no toaster |
id
one is generated for it, so two calls in a row both show and coexist in
the row. Pass your own id when you want the next call to
replace that same notification — that is how you follow
a task (progress, retries, sileo.promise(),
sileo.update()).
sileo.promise()
Takes a promise or a function returning one. It shows the
loading state (no auto-close, never unfolds) and swaps it
when it settles. It returns the same promise, so you can keep chaining
—and catching— as usual.
sileo.promise(() => uploadFile(file), {
loading: { id: "upload", title: "Uploading", description: "Sending file…" },
success: (data) => ({ title: "Uploaded", description: data.name }),
error: (err) => ({ title: "Failed", description: String(err.message) }),
// optional: instead of success, an "action" state with a button
action: (data) => ({
title: "Report ready",
description: "You can download it now.",
button: { title: "Download", onClick: () => download(data.url) },
}),
});Toast options
| Option | Type | Default | Description |
|---|---|---|---|
id | string | a unique one per call | Identity. Same id = same toast (replaces it) |
title | string | the state | Text on the capsule |
description | string | Node | {html} | — | The panel's message. Without it the toast never unfolds |
type / state | success | loading | error | warning | info | action | "success" | Colour and icon |
position | one of the 6 | the toaster's | Can differ per toast |
duration | number | null | 6000 | ms until it closes. null = it stays |
icon | Node | {html} | string | the state's | A string is inserted as text; for an SVG use { html } |
button | { title, onClick } | — | Button inside the panel |
styles | { [part]: string | object } | — | Classes and/or CSS per part, see Per-part styles |
className | string | — | Class for the toast root |
fill | string | per theme | Colour of the capsule and the panel |
roundness | number | 16 | Radius; also scales the gooey blur |
autopilot | false | { expand, collapse } | 150 / 4000 ms | See Autopilot |
Rich content
description and icon take a DOM node or
{ html }. A string is always inserted as plain
text, so there is no injection risk when you render user data.
sileo.update(), which does rebuild it. The
demo uses this for a player with previous/next buttons that change the
text of that very notification.
sileo.info({
id: "thanks",
title: "Thanks",
icon: { html: '<svg viewBox="0 0 24 24" …></svg>' },
description: domNode,
styles: { title: "my-title", description: "my-text" },
});Toaster options
const toaster = createToaster({
position: "top-right", // the 6 positions
theme: "system", // "light" | "dark" | "system"
offset: 24, // number, string, or { top, right, bottom, left }
visibleToasts: 3, // tabs visible in the row
options: { // defaults for every toast
duration: 4000,
roundness: 20,
styles: { toast: "top-90" },
},
styles: { title: "font-bold" }, // shorthand for options.styles
container: document.body, // where to mount the viewports
});
toaster.set({ position: "bottom-center" }); // reconfigure live
toaster.set({ theme: "dark" });
toaster.set({ styles: { badge: "ring-1" } }); // merged with what was there
toaster.config; // the current config
toaster.destroy(); // unmount and clean up| Position | Where it shows up |
|---|---|
top-left · top-center · top-right | Top; the panel opens downwards |
bottom-left · bottom-center · bottom-right | Bottom; the panel opens upwards |
Each position is its own stack, with its own tab row and its own focus.
Exports
| Export | What it is |
|---|---|
sileo (also default) | The API to fire toasts |
createToaster(opts) | Mounts the toaster (or reconfigures the existing one) and returns it |
getToaster() | The mounted toaster, or null |
configure(opts) | Reconfigures the toaster live, from anywhere |
getConfig() | The current config, or null |
STYLE_PARTS | The names of the styleable parts, as an array |
Toaster | The class, in case you want to instantiate it yourself |
dismissToast(id) | Same as sileo.dismiss |
SILEO_POSITIONS · SILEO_STATES | The valid values, as arrays |
STATE_ICON | The default icons (SVG strings) in case you want to reuse them |
And from sileojs/vue:
| Export | What it is |
|---|---|
SileoPlugin (also default) | Mounts the toaster, registers the component and $sileo |
SileoToaster | The component |
useSileo() | Returns sileo, for composables |
useSileoConfig() | The reactive config (theme, position, styles) |
Interaction
- Pointer over → the panel opens and the auto-close of every toast is paused.
- Pointer already sitting there → counts just the same. A toast born under the cursor gets no
pointerenter, so Sileo checks where the pointer is on mount and on every relayout: the panel stays open until it leaves. - Pointer to another tab → focus jumps to it: it widens, shows its title and opens its message.
- Drag vertically more than 30 px → it is dismissed.
- Keyboard → every toast is focusable; on focus it behaves as if the pointer were over it.
prefers-reduced-motion→ all movement is turned off.
A toast in the loading state never unfolds and never
auto-closes: it is waiting for you to update it.
id,
each one is its own notification and they coexist in the row. With the
same id the new one replaces the previous, but still shows:
it comes back to the front of the row — even if it was buried under the
stack cut, or halfway out — the header replays its entrance even with
identical text, and its timer starts from zero.
The tab row
Several notifications in the same position do not stack as a list: they form an overlapping row of tabs at the title's height. Only the focused one drops its panel.
- The tabs behind show only their icon, and the icon hugs the edge the tab peeks from —in the right-hand positions the row grows inwards and the left side peeks; in the left and centre ones, the right side. It holds both at rest and under the pointer.
- Focus goes to the tab at the screen edge, the newest toast. A new toast takes focus.
- When the pointer comes in the deck opens: every notification shows up —the cut at three is only for the resting state— and the front one opens its panel.
- Only the focused tab widens; the rest stay as their icon. But it always widens to the same width, that of the widest in the stack: what it grows offsets what it shifts, so the tab you are pointing at does not slip away from the pointer. If it took its own width, focusing a short-titled one would bounce focus to its neighbour.
- The focused tab keeps its natural
z-index: below the ones in front of it and above the ones behind, like a real tab. Put on top of everything, focusing a middle one would swallow the ones between it and the screen edge. - If the row does not fit, the focused tab's width is trimmed first and then how much each icon peeks: a tab outside the viewport would fall outside the hover area.
- At rest 3 tabs are visible (
--sileo-stack-maxorvisibleToasts) and, if there are more, a+shows up at the end of the deck: it only tells you there are others; the exact number shows when the row opens. The focused tab is never hidden, even as new toasts arrive.
The positions come out of a single recurrence, from the edge inwards:
x[0] = 0
x[i+1] = x[i] + width(i) - tab-overlap
// at rest
width(i) = (i focused ? its capsule's width : the toast's height)
// the focused one, always the same width
width(i) = (i focused ? max(width of every capsule) : the toast's height)
// ^ bounded so the row fits in the viewportAutopilot
A new toast opens by itself for a moment so the message can be read, and
closes a few seconds later. By default it opens at 150 ms and closes
at 4 s (always within its duration).
sileo.info({ title: "No autopilot", autopilot: false });
sileo.info({
title: "My own pace",
description: "Opens right away and stays for 8 seconds.",
duration: 10000,
autopilot: { expand: 0, collapse: 8000 },
});CSS variables
Everything is tuned with custom properties; override them wherever you like.
:root {
/* shape */
--sileo-width: 350px;
--sileo-height: 40px;
--sileo-roundness: 16px;
--sileo-gap: 12px; /* distance from the screen edge */
/* movement */
--sileo-duration: 600ms;
--sileo-spring-easing: linear(…); /* the spring, already linearised */
--sileo-ease-flat: cubic-bezier(0.32, 0.72, 0, 1);
/* tab row */
--sileo-tab-overlap: 20px; /* overlap at rest */
--sileo-tab-overlap-hot: 12px; /* overlap with the pointer inside */
--sileo-stack-max: 3; /* visible tabs */
--sileo-shadow: drop-shadow(0 2px 8px rgb(0 0 0 / 0.18));
/* colour */
--sileo-fill: #ffffff;
--sileo-state-success: oklch(0.723 0.219 142.136);
--sileo-state-error: oklch(0.637 0.237 25.331);
--sileo-state-warning: oklch(0.795 0.184 86.047);
--sileo-state-info: oklch(0.685 0.169 237.323);
--sileo-state-action: oklch(0.623 0.214 259.815);
--sileo-state-loading: oklch(0.556 0 0);
}theme: "light" the capsule is dark and a black shadow over
another dark capsule separates nothing — there it also gets a light rim.
If you change --sileo-fill by hand, adjust
--sileo-shadow too.
--sileo-height, --sileo-tab-overlap and
--sileo-stack-max are declared with @property.
The JS reads them already resolved to px, so you can write them in any
unit and the CSS stays the single source of the measurements.
Themes
With theme the toaster picks the fill: on a light theme the
capsule is dark, and the other way round. "system" follows
prefers-color-scheme and reacts to changes.
createToaster({ theme: "system" }); // "light" | "dark" | "system"
If you pass no theme, the CSS rules: the fill is
--sileo-fill. And you can always pin it per toast with
fill.
Per-part styles
styles is a part → style object. There
is nothing framework-specific in it: what you hand over are classes, CSS
properties, or both. Per toast (styles) or for every one of
them (the toaster's options.styles).
| Part | Node |
|---|---|
viewport | The container for that position |
toast | The toast root |
canvas · pill · body | The gooey layers |
header · badge · title | The header (the tab itself) |
content · description · button | The open panel |
count | The +N chip |
Three shapes, mixable across parts:
createToaster({
position: "top-right",
options: {
roundness: 14,
styles: { toast: "top-90" }, // for every toast
},
});
sileo.success({
title: "Saved",
description: "All done.",
styles: {
toast: "rounded-2xl shadow-lg", // classes
description: { color: "#64748b", fontSize: "13px" }, // CSS properties
badge: { class: "ring-1", style: { "--sileo-roundness": "20px" } },
},
});camelCase and kebab-case work the same, and so
do custom properties (--sileo-*): any CSS variable can be
reached from styles without writing a separate stylesheet.
Styles merge per part and the toast wins: if the toaster
sets styles.toast and the call does too, the call's value is
used. Setting a part to null clears it.
If you would rather target the DOM, every part has its attribute:
[data-sileo-toast], [data-sileo-header],
[data-sileo-badge], [data-sileo-title],
[data-sileo-description], [data-sileo-button].
button:hover { … },
bear in mind the toast root is a <button>.
Sileo defends itself by raising the specificity of its own styles, but
get into the habit of scoping yours.
Live changes
Theme, position and styles can change at any time, and whatever is
already on screen readjusts: toasts swap their fill, move to the new
viewport and repaint with the new styles. configure() needs
neither the toaster instance nor an adapter, so it works the same in
vanilla, React, Svelte or Angular.
import { configure, sileo } from "sileojs";
configure({ theme: "dark" }); // or sileo.setTheme("dark")
configure({ position: "bottom-center" }); // or sileo.setPosition(...)
configure({ styles: { toast: "top-90" } }); // or sileo.setStyles(...)
sileo.getConfig(); // { position, theme, resolvedTheme, offset, options, styles }| What happens | With |
|---|---|
| Styles merge with what was there | configure({ styles }) |
| One part is cleared | configure({ styles: { title: null } }) |
| Every default is cleared | configure({ options: null }) |
| They are swapped wholesale | configure({ styles, replace: true }) |
position stays where it is;
the rest follow the toaster's. And if the theme goes back to
"system", the toaster listens to
prefers-color-scheme again.
Accessibility
- Each position is a
<section role="status" aria-live="polite">: screen readers announce the toast as it appears, without interrupting. - The root of each toast is a focusable
<button>, witharia-labelequal to the title. - Receiving keyboard focus opens the panel, just like the pointer does.
- Tabs beyond the visible maximum are hidden with
visibility: hidden, so they leave the tab order. - With
prefers-reduced-motion: reducenot a single animation or transition is left.
Browser support
Chrome/Edge 113+, Safari 16.4+ and Firefox 128+. No build step and no dependencies.
| Relies on | What for |
|---|---|
linear() | The spring, as a native easing |
@property | So the JS reads the measurements already resolved |
translate / scale | Placing the tabs without touching transform |
oklch(), color-mix() | The state colours and their backgrounds |
| SVG filters | The gooey morphing |
ResizeObserver | Measuring capsule and panel |
How it works
The merging effect is two rectangles and an SVG filter:
- The capsule and the panel are two
divs withborder-radius, inside a filtered layer. - The filter blurs and then raises the alpha contrast (
×20 −10): that turns the blur into a hard edge, and that is where two nearby shapes merge. - The bridge's colour does not come from the blur —in unpremultiplied sRGB, transparent is black and it would come out grey—: it is flooded with
--sileo-filland clipped by the thresholded alpha. - On opening, the capsule grows towards the panel to force the overlap.
- The bottom positions reuse the same geometry with
scaleY(-1). - The shadow goes after the filter in the chain, over the already merged silhouette.
The JS only publishes measurements —--_pw (capsule width),
--_ch (content height), --_tx (place in the
row)— and the CSS derives all the geometry from them with
calc() and max().