Skip to content

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 sileojs
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";
The stylesheet is required. The package does not inject it: without 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>
ES modules need a server that serves .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

ImportContents
sileojsVanilla core: sileo, createToaster, getToaster
sileojs/vueVue 3 adapter: SileoPlugin, SileoToaster, useSileo
sileojs/styles.cssThe 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>
FieldWhat it is
cfg.positionOne of the 6 positions
cfg.themelight | dark | system
cfg.offsetNumber/string or { top, right, bottom, left }
cfg.visibleToastsTabs visible in the row
cfg.optionsDefaults for every toast
cfg.stylesPer-part styles (see Per-part styles)
The toaster is a single one for the whole page. If the plugin (or another 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" });
  }
}
TypeScript: the package does not ship .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.*

MethodReturnsWhat it does
sileo.show(opts)idUses opts.type (or opts.state) as the state
sileo.success(opts)idShorthands 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 promiseloadingsuccess / 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 toasterReconfigures it live (mounts it if there is none)
sileo.setTheme(t)the toasterconfigure shorthands
sileo.setPosition(p)the toaster
sileo.setStyles(s)the toaster
sileo.getConfig()the configThe current config, or null with no toaster
Every call is a notification. With no 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

OptionTypeDefaultDescription
idstringa unique one per callIdentity. Same id = same toast (replaces it)
titlestringthe stateText on the capsule
descriptionstring | Node | {html}The panel's message. Without it the toast never unfolds
type / statesuccess | loading | error | warning | info | action"success"Colour and icon
positionone of the 6the toaster'sCan differ per toast
durationnumber | null6000ms until it closes. null = it stays
iconNode | {html} | stringthe state'sA 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
classNamestringClass for the toast root
fillstringper themeColour of the capsule and the panel
roundnessnumber16Radius; also scales the gooey blur
autopilotfalse | { expand, collapse }150 / 4000 msSee 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.

If you pass a node, it is yours: you can keep it, put controls in it and change its content live. Since the toast reuses the same node, touching it neither rebuilds the notification nor closes the panel — unlike 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
PositionWhere it shows up
top-left · top-center · top-rightTop; the panel opens downwards
bottom-left · bottom-center · bottom-rightBottom; the panel opens upwards

Each position is its own stack, with its own tab row and its own focus.

Exports

ExportWhat 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_PARTSThe names of the styleable parts, as an array
ToasterThe class, in case you want to instantiate it yourself
dismissToast(id)Same as sileo.dismiss
SILEO_POSITIONS · SILEO_STATESThe valid values, as arrays
STATE_ICONThe default icons (SVG strings) in case you want to reuse them

And from sileojs/vue:

ExportWhat it is
SileoPlugin (also default)Mounts the toaster, registers the component and $sileo
SileoToasterThe 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.

No call ever gets swallowed. With no 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-max or visibleToasts) 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 viewport

Autopilot

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 },
});
While the pointer is inside the stack the autopilot stops counting: it will not close the panel you are pointing at. When the pointer leaves, everything goes back to rest.

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);
}
The shadow is what separates a tab from the one behind it, so it depends on the fill: with light capsules a dark shadow is enough, but with 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).

PartNode
viewportThe container for that position
toastThe toast root
canvas · pill · bodyThe gooey layers
header · badge · titleThe header (the tab itself)
content · description · buttonThe open panel
countThe +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].

If your stylesheet has loose rules like 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 happensWith
Styles merge with what was thereconfigure({ styles })
One part is clearedconfigure({ styles: { title: null } })
Every default is clearedconfigure({ options: null })
They are swapped wholesaleconfigure({ styles, replace: true })
A toast that asked for its own 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>, with aria-label equal 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: reduce not 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 onWhat for
linear()The spring, as a native easing
@propertySo the JS reads the measurements already resolved
translate / scalePlacing the tabs without touching transform
oklch(), color-mix()The state colours and their backgrounds
SVG filtersThe gooey morphing
ResizeObserverMeasuring capsule and panel

How it works

The merging effect is two rectangles and an SVG filter:

  • The capsule and the panel are two divs with border-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-fill and 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().