@svelte-put/noti
Deprecation Notice
This package will be deprecated when Svelte 5 lands, in favor for @svelte-put/async-stack - a more generic and minimal package with very similar API. If you are using Svelte 5 today, head over to the Migration Guides to learn more.
Installation
npm install --save-dev @svelte-put/noti@^1.0.0
pnpm add -D @svelte-put/noti@^1.0.0
yarn add -D @svelte-put/noti@^1.0.0
Comprehensive Example
This section presents a working example of the package. You will notice that @svelte-put/noti
only handles the core logics and leave UI up to you to configure. For that reason, the setup is quite verbose. This, however, enables flexibility: the package is not a plug-and-play prebuilt notification bundle but rather designed to help build custom notification system. We will unpack each part of the library in later sections of the document to see how customization can be achieved.
Component Setup (
Notification.svelte
): define a component to be rendered as notification.Store Setup (
notification-store.ts
): aNotificationStore
is created with (optionally) predefined notification variants.Portal Setup (
NotificationPortal.svelte
): aNotificationPortal
component is registered withuse:portal
as a centralized place to insert rendered notifications.Usage (
usage.svelte
): notification ispushed
using the previously createdNotificationStore
.
<script lang="ts">
import type { NotificationInstance } from '@svelte-put/noti';
import { createEventDispatcher } from 'svelte';
import { fly } from 'svelte/transition';
// optional, injected automatically by @svelte-put/noti
export let notification: NotificationInstance;
export let content = 'Placeholder';
export let special = false;
const { progress } = notification;
const dispatch = createEventDispatcher<{ resolve: string }>();
const dismiss = () => dispatch('resolve', 'popped from within component');
</script>
<!-- eslint-disable svelte/valid-compile -->
<div
class="relative px-4 py-2 bg-blue-200 rounded-sm not-prose shadow-lg text-black pointer-events-auto flex items-start md:items-center justify-between"
class:bg-pink-300={special}
in:fly|global={{ duration: 200, y: -20 }}
on:mouseenter={progress.pause}
on:mouseleave={progress.resume}
>
<p>Notification (variant: {notification.variant}): {content} (id = {notification.id})</p>
<button on:click={dismiss} class="md-max:mt-0.5">
<svg inline-src="lucide/x" width="24" height="24" />
</button>
<div
class="progress absolute inset-x-0 bottom-0 h-0.5 bg-blue-500 origin-left"
class:bg-pink-500={special}
class:paused={$progress.state === 'paused'}
style={`--progress-duration: ${notification.timeout}ms;`}
/>
</div>
<style>
.progress {
animation: progress var(--progress-duration) linear;
}
.progress.paused {
animation-play-state: paused;
}
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
</style>
// notification-store.ts
import { store } from '@svelte-put/noti';
import Notification from './Notification.svelte';
// define somewhere global, reuse across app
export const notiStore = store()
// add a minimalistic variant config
.variant('info', Notification)
// add a verbose variant config
.variant('special', {
id: 'counter',
component: Notification,
props: {
special: true,
content: 'A very special notification',
},
})
// build the actual NotificationStore
.build();
<script>
import { portal } from '@svelte-put/noti';
import { notiStore } from './notification-store';
</script>
<!-- notification portal, typically setup at somewhere global like root layout -->
<aside
class="fixed z-notification inset-y-0 right-0 md:left-1/2 p-10 pointer-events-none flex flex-col-reverse gap-4 justify-end md:justify-start"
use:portal={notiStore}
/>
<script lang="ts">
import { notiStore } from './notification-store';
const pushInfo = () => notiStore.push('info', { props: { content: 'An info notification' } });
const pushSpecial = () => notiStore.push('special');
</script>
<!-- notification push triggers -->
<button class="c-btn c-btn--outlined" on:click={pushInfo}>Push an info notification</button>
<button class="c-btn" on:click={pushSpecial}>Push a special notification</button>
Notification Store
This is a key part of @svelte-put/noti
. It holds all internal logics and is used for the push
& pop
mechanism. As shown in Comprehensive Example, a NotificationStore
is created with a builder pattern that provides type-safety for push
invocations.
Initialization
import { store } from '@svelte-put/noti';
export const notiStore = store({ /** optional common config */ })
.variant(/* */)
.build(); // remember to call this to build the actual store
The store
function accepts an optional config object that will be merged to all notification instance config on push
.
type store = (config?: NotificationCommonConfig) => import('@svelte-put/noti').NotificationStoreBuilder;
type NotificationCommonConfig = {
/**
* milliseconds to wait and automatically pop the notification.
* Defaults to `3000`. Set to `false` to disable
*/
timeout?: number | false;
/**
* id generator for notifications. Defaults to 'uuid'.
*
*
* - counter: use an auto-incremented counter that is scoped to the store
* - uuid: use `crypto.randomUUID()`, fallback to `counter` if not available
* - callback: a custom function that accepts {@link NotificationInstanceConfig} and returns a string as the id
*/
id?:
| 'counter'
| 'uuid'
| ((config: {
/* NotificationInstanceConfig, omitted for conciseness */
}) => string);
};
Predefined Variants
Use the variant
method to add a variant config, allowing quick notification dispatch with minimal ad-hoc configuration. It accepts a mandatory variant
string, and either a Svelte component or a config object (as seen in Comprehensive Example).
type SvelteComponentClass = import('svelte').ComponentType<import('svelte').SvelteComponent>;
type variant = (
variant: string,
config: NotificationVariantConfig | SvelteComponentClass,
) => import('@svelte-put/noti').NotificationStoreBuilder;
type NotificationVariantConfig = {
/** extends NotificationCommonConfig, omitted for conciseness */
variant: string;
component: SvelteComponentClass;
props?: {
/** inferred props for component */
};
};
Notification Push
New notifications can be pushed with the NotificationStore.push
method. A push
call take either one of the predefined variant, as seen in Comprehensive Example, …
notiStore.push('<variant>', { /** optional config & component props */ });
…or the 'custom'
variant, helpful for one-off notification
notiStore.push('custom', {
component: NotificationComponent, // required
props: { /** props for NotificationComponent */ },
id: () => 'one-time-id',
timeout: false,
});
Custom push
must provide a component in its config.
If you find that the push
interface is too verbose (it is), you can further create your own proxy utils.
export const info = (content: string) => notiStore.push('info', { props: { content } });
// later
info('An info notification...');
The API is intentionally kept verbose to maintain a generic interface that can cover many use cases. But if you think it can be further simplified, feedback and proposal are much welcomed 🙇.
Notification Pop
An active notification can be popped either from within the component (typically via user interactions), by dispatching a resolve
CustomEvent (as seen in Comprehensive Example)…
<script lang="ts">
import type { NotificationInstance } from '@svelte-put/noti';
import { createEventDispatcher } from 'svelte';
// ...truncated...
const dispatch = createEventDispatcher<{ resolve: string }>();
const dismiss = () => dispatch('resolve', 'popped from within component');
</script>
<!-- ...truncated... -->
or via the pop
method from NotificationStore
…
import { notiStore } from './notification-store';
// pop the topmost notification
notiStore.pop();
// pop a specific notification
notiStore.pop('specific-id');
// pop a specific notification with custom resolution value
notiStore.pop('id', 'custom-resolve-detail');
// alternatively, provide arguments as object
notiStore.pop({
detail: 'custom-resolve-detail',
}); // pop the topmost notification with custom resolution value
Resolution Await
Notification resolution can be await. The awaited value is inferred from either the argument provided to NotificationStore.pop
, or CustomEvent.detail
of the resolve
CustomEvent. This is especially helpful for complex interactive notification (see Notification Component section for an example of interactive notification).
import { notiStore } from './notification-store';
const pushed = notiStore.push('info');
const resolved = await pushed.resolve();
In the following example, try pressing the “Push a persistent notification” button and observe the async nature of the push & pop mechanism.
<script lang="ts">
import { notiStore } from './comprehensive/notification-store';
let promise: Promise<unknown> | null = null;
async function pushNoti() {
const pushed = notiStore.push('info', {
timeout: false,
props: { content: 'A persistent notification' },
});
promise = pushed.resolve();
await promise;
setTimeout(() => (promise = null), 2000);
}
function popNoti() {
notiStore.pop();
}
</script>
<button
on:click={pushNoti}
disabled={!!promise}
class="c-btn"
class:bg-gray-500={!!promise}
>
Push a persistent notification
</button>
{#if promise}
<p class="mt-2 text-blue-500">
{#await promise}
Notification is pushed and waiting for resolution. Either click the x button on the
notification, or <button class="c-link" on:click={popNoti}>click here</button> to pop the notification.
{:then}
Resolved (resetting in 2 seconds)
{/await}
</p>
{/if}
Timeout and Progress
const pushed = notiStore.push('info', { timeout: 3000 });
If your notification has timeout
specified in its config, a setTimeout
is setup and the notification
will be automatically popped from the stack. This timeout can be paused and resumed.
notiStore.pause(pushed.id);
notiStore.resume(pushed.id);
The pause
and resume
methods on NotificationStore
are actually just proxy methods for the same ones on NotificationInstance
, which is accessible from within the notification component via the injected notification prop.
<script lang="ts">
import type { NotificationInstance } from '@svelte-put/noti';
export let notification: NotificationInstance;
const { progress } = notification;
$: console.log($progress.state); // 'idle' | 'running' | 'paused' | 'ended'
const pause = () => progress.pause();
const resume = () => progress.resume();
</script>
<!-- ...truncated... -->
NotificationInstance.progress
is a Svelte store, its value contain a state
property with value of 'idle'
, 'running'
, 'paused'
, or 'ended'
, helpful to control the play state of some animation, for example.
Notification Portal
use:portal
The complementary portal
Svelte action provides a quick and minimal solution to set any HTMLElement
as the rendering portal for a NotificationStore. When using the portal
action, only one portal can bind to a NotificationStore
, and vice versa.
<script>
import { portal } from '@svelte-put/noti';
import { notiStore } from './notification-store';
</script>
<aside use:portal={notiStore} />
Notification instances are rendered as direct children of the HTMLElement
to which use:portal
is attached. Newest instance is the last child.
Limitation
use:portal
is helpful for reducing boilerplate and keeping everything connected. However, there are some known UI limitations:
- Svelte transition for the notification component root node must be
global
(in:fly|global
, for example), - outro transition (upon unmount) will not run (but hopefully soon will be able to when this PR is merged),
animate
is not available because it requires a keyed each block,
The next section discusses how a custom portal can be built to overcome these limitations, should it be necessary.
Custom Portal
Instead of use:portal, rendering of notifications can be manually handled by subscribing to the notifications
array property of a NotificationStore. This is helpful when more granular control over rendering is necessary. For example, to coordinate and animate smoothly the positions of the notifications, as done in the following demo.
Notice the subtle difference compared to Comprehensive Example. Specifically, thanks to animate:flip
, the unmount transition is much smoother, especially when multiple notifications are active.
<script lang="ts">
import { store } from '@svelte-put/noti';
import NotificationWrapper from '@svelte-put/noti/Notification.svelte';
import { flip } from 'svelte/animate';
import { fly, fade } from 'svelte/transition';
import Notification from './comprehensive/Notification.svelte';
// define somewhere global, reuse across app
const notiStore = store()
.variant('info', Notification)
.variant('special', {
id: 'counter',
component: Notification,
props: {
special: true,
content: 'A very special notification',
},
})
.build();
const pushInfo = () => notiStore.push('info', { props: { content: 'An info notification' } });
const pushSpecial = () => notiStore.push('special');
</script>
<!-- notification portal, typically setup at somewhere global like root layout -->
<aside
class="fixed z-notification inset-y-0 right-0 md:left-1/2 p-10 pointer-events-none flex flex-col-reverse gap-4 justify-end md:justify-start"
>
{#each $notiStore.notifications as notification (notification.id)}
<div animate:flip={{ duration: 200 }} in:fly={{ duration: 200 }} out:fade={{ duration: 120 }}>
<NotificationWrapper {notification} />
</div>
{/each}
</aside>
<!-- notification push triggers -->
<button class="c-btn c-btn--outlined" on:click={pushInfo}>Push an info notification</button>
<button class="c-btn" on:click={pushSpecial}>Push a special notification</button>
Notice the usage of @svelte-put/noti/Notification.svelte
in above code snippet. It is just a small abstraction on top of svelte:component to conveniently provide the same functionality that use:portal
does. You can even go more granular and omit it; just make sure to provide the necessary props.
Notification Component
Any Svelte component can be used with @svelte-put/noti
. This section lists some optional prop & event interfaces that help build feature-rich notifications.
notification
Prop
Injected This is an optional prop that provides access to the corresponding NotificationInstance
interface (element of notification stack managed by NotificationStore).
type NotificationInstanceConfig = NotificationVariantConfig & {
/** extends NotificationVariantConfig, omitted for conciseness */
id: string;
};
type NotificationInstance = NotificationInstanceConfig & {
/** reference to the rendered notification component */
instance?: SvelteComponent;
/** internal api for resolving a notification, effectively popping it from the stack */
$resolve: (e: ComponentEvents['resolve']) => Promise<ComponentEvents['resolve']['detail']>;
/** svelte store with .pause & .resume methods for controlling automatic timeout */
progress: NotificationProgressStore;
}
This is helpful, for example, if you want access to the id
or variant
of the pushed notification.
<script lang="ts">
import type { NotificationInstance } from '@svelte-put/noti';
export let notification: NotificationInstance;
</script>
<div data-id={notification.id} class="notification notification--{notification.variant}" />
The notification
prop also allows access to the progress
store for controlling timeout. Check Timeout and Progress for more information. Also refer to the Notification
component used in Comprehensive Example which made use of the progress
store to pause notification timeout on pointer hover.
resolve
CustomEvent
If set up correctly, either automatically via use:portal or manually in your custom portal, a resolve
CustomEvent dispatched from the pushed instance component will prompt NotificationStore to remove it from the current notification stack.
The detail of this resolve
CustomEvent can be awaited, allowing us to receive user actions from complex interactive notifications such as in the example below.
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { fly } from 'svelte/transition';
export let message: string;
const dispatch = createEventDispatcher<{
resolve: boolean;
}>();
const join = () => dispatch('resolve', true);
const del = () => dispatch('resolve', false);
</script>
<div
class="px-4 py-2 bg-bg-100 rounded-sm shadow-lg pointer-events-auto"
in:fly|global={{ duration: 200, y: -20 }}
>
<p class="text-xl font-bold">Invitation</p>
<p class="text-lg">{message}</p>
<div class="flex gap-6">
<button class="c-btn w-40" on:click={join}> Join </button>
<button class="c-btn c-btn--outlined w-40" on:click={del}> Delete </button>
</div>
</div>
<script>
import { notiStore } from '../comprehensive/notification-store';
import InteractiveNotification from './InteractiveNotification.svelte';
let state = 'idle';
async function pushNoti() {
const pushed = notiStore.push('custom', {
timeout: false,
component: InteractiveNotification,
props: {
message: 'You are invited to join the Svelte community!',
},
});
state = 'pending';
const agreed = await pushed.resolve();
state = agreed ? 'accepted' : 'denied';
}
</script>
<p>
{#if state === 'idle'}
Waiting for notification to be pushed
{:else if state === 'pending'}
Waiting for user action to resolve notification
{:else}
Invitation was <span
class="px-2"
class:text-error-text={state == 'denied'}
class:bg-error-surface={state == 'denied'}
class:text-success-text={state === 'accepted'}
class:bg-success-surface={state == 'accepted'}
>
{state}
</span>
{/if}
</p>
<button
class="c-btn"
on:click={pushNoti}
disabled={state === 'pending'}
>
Trigger Interactive Notification
</button>
Happy pushing and popping notifications! 👨💻