@svelte-put/async-stack

GitHub Github

type-safe and headless builder for async component stack

@svelte-put/async-stack @svelte-put/async-stack @svelte-put/async-stack @svelte-put/async-stack

Still on Svelte 4? See the old docs site here.

Introduction

@svelte-put/async-stack is no more than a stack structure that sits between your component library and application, providing type-safe push&pop mechanism to mount/unmount components from anywhere to a centralized DOM parent element.

async-stack manages
      async flow between your push (component libraries) and the place it is rendered
    (application

Illustration 1: the minimal role of async-stack, managing just the async operations of forwarding components to where they should render.

What are the use cases?

This package was extracted from the core logic of now-deprecated @svelte-put/noti and @svelte-put/modal packages, which were also the original use cases. Throughout this documentation, examples will be in the form of rendering notifications. See Recipes for more examples.

Installation

npm install --save-dev @svelte-put/async-stack
pnpm add -D @svelte-put/async-stack
yarn add -D @svelte-put/async-stack

Comprehensive Example

This section presents a working example of the package. You will notice that @svelte-put/async-stack only handles the core logic 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 component bundle but rather designed to help build custom notification/dialog system, for example. We will unpack each part of the library in later sections of the document to see how customization can be achieved.

Example

Click to push toast notification

  • Stack Setup (notification-stack.ts): a Stack is created with (optionally) predefined notification variants.

  • Component Setup (Notification.svelte): define a component to be rendered as notification.

  • Portal Setup (NotificationPortal.svelte): a centralized place to insert rendered notifications, with the help of the render action from Stack, and some sensible animations powered by svelte/animate and svelte/transition.

  • Usage (usage.svelte): notification is pushed using the previously created Stack.

import { stack } from '@svelte-put/async-stack';

import Notification from './Notification.svelte';

export const notiStack = stack({ timeout: 3000 })
	// add a minimalistic variant config
	.addVariant('info', Notification)
	// add a verbose variant config
	.addVariant('special', {
		id: 'counter',
		component: Notification,
		props: {
			special: true,
			content: 'A very special notification',
		},
	})
	// build the actual stack
	.build();
<script lang="ts" module>
	import type { StackItemProps } from '@svelte-put/async-stack';

	export interface NotificationProps extends StackItemProps<{ reason: string }> {
		content?: string;
		special?: boolean;
	}
</script>

<script lang="ts">
	import { fly } from 'svelte/transition';

	let {
		content = 'Placeholder',
		special = false,
		item, // injected by @svelte-put/async-stack
	}: NotificationProps = $props();

	function dismiss() {
		item.resolve({ reason: 'popped from within component' });
	}
</script>

<!-- eslint-disable svelte/valid-compile -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
	class="not-prose pointer-events-auto relative flex items-start justify-between px-4 py-2 shadow-lg md:items-center"
	class:hl-error={special}
	class:hl-info={!special}
	in:fly|global={{ duration: 200, y: -20 }}
	onmouseenter={item.pause}
	onmouseleave={item.resume}
>
	<p>Notification (variant: {item.config.variant}): {content} (id = {item.config.id})</p>
	<button onclick={dismiss} type="button" class="c-btn c-btn--icon">
		<i class="i i-[x] h-6 w-6"></i>
		<span class="sr-only">Dismiss</span>
	</button>
	<div
		class="progress absolute inset-x-0 bottom-0 h-0.5 origin-left {special
			? 'bg-error-bg-200'
			: 'bg-info-bg-200'}"
		class:paused={item.state === 'paused'}
		style={`--progress-duration: ${item.config.timeout}ms;`}
		aria-disabled={true}
	></div>
</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>
<script>
	import { flip } from 'svelte/animate';
	import { fly, fade } from 'svelte/transition';

	import { notiStack } from './notification-stack';
</script>

<!-- notification portal, typically setup at somewhere global like root layout -->
<aside
	class="z-notification pointer-events-none fixed inset-y-0 right-0 flex flex-col-reverse justify-end gap-4 p-10 md:left-1/2 md:justify-start"
>
	{#each notiStack.items as notification (notification.config.id)}
		<div
			animate:flip={{ duration: 200 }}
			in:fly={{ duration: 200, x: 20 }}
			out:fade={{ duration: 120 }}
			class="relative w-full"
			use:notiStack.actions.render={notification}
		></div>
	{/each}
</aside>
<script lang="ts">
	import { notiStack } from './notification-stack';

	const pushInfo = () => notiStack.push('info', { props: { content: 'An info notification' } });
	const pushSpecial = () => notiStack.push('special');
</script>

<!-- notification push triggers -->
<button class="c-btn c-btn--outlined" onclick={pushInfo}>Push an info notification</button>
<button class="c-btn" onclick={pushSpecial}>Push a special notification</button>

Stack

This is the core part of @svelte-put/async-stack. It holds all logic for the async push & pop mechanism. Shown in Comprehensive Example, a Stack instance is created using a builder pattern that provides type-safety for push invocations.

Initialization

import { stack } from '@svelte-put/async-stack';

export const notiStack = stack({ /** optional common config, shared by all notifications */ })
  .addVariant('name', /* specific config for notification of this variant */)
  .build(); // remember to call this to build the actual stack;

The stack function accepts an optional config object that will be merged with all StackItem instance config on push.

type stack = (config?: StackItemCommonConfig) => import('@svelte-put/async-stack').StackBuilder;

type StackItemCommonConfig = {
	/**
	 * milliseconds to wait and automatically pop the stack item.
	 * Defaults to `0` (disabled)
	 */
	timeout?: number;
	/**
	 * id generator for stack items. Defaults to 'uuid'
	 *   - counter: use an auto-incremented counter that is scoped to the stack
	 *   - uuid: use `crypto.randomUUID()`, fallback to `counter` if not available
	 *   - callback: a custom function that accepts {@link StackItemInstanceConfig} and returns a string as the id
	 */
	id?:
		| 'counter'
		| 'uuid'
    | ((config: /* StackItemInstanceConfig, omitted for conciseness */) => string);
};

A Stack instance may be wrapped in a Svelte context for better encapsulation. See Recipes for an example.

Predefined Variants

Use the addVariant method to add a predefined config, allowing quick push with minimal ad-hoc configuration. The first parameter is a unique variant name, while the second accepts either a Svelte component or a config object (as seen in Comprehensive Example).

type addVariant = (
  variant: string,
  config: StackItemVariantConfig | import('svelte').Component,
) => import('@svelte-put/async-stack').StackBuilder;

type StackItemVariantConfig = {
  /** extends StackItemCommonConfig, omitted for conciseness */
  variant: string;
  component: import('svelte').Component;
  props?: {
    /** inferred props for component */
  };
};

Push

A StackItem can be pushed onto Stack by invoking the push method on Stack instance. A push call take either one of the predefined variant, as seen in Comprehensive Example...

notiStack.push('<variant>', /** optional StackItem instance config & component props */);

...or the 'custom' variant, helpful for one-off, ad-hoc push

notiStack.push('custom', {
  component: MyNotification, // required
  props: { /** props for MyNotification */ },
  id: () => 'one-time-id',
  timeout: 4000,
});

Custom push must provide a component in its config.

If you find that the push interface is too verbose (it is, by design), you can further create your own proxy util(s).

export const info = (content: string) => notiStack.push('info', { props: { content } });

// later
info('An actual info notification');

Pop

An active StackItem instance can be popped either from within the component (typically following user interactions), by calling resolve from the injected item prop...

<script lang="ts">
  import type { StackItemProps } from '@svelte-put/async-stack';

  let { item }: StackItemProps<{ confirmed: boolean }> = $props();

  const confirm = () => item.resolve({ confirmed: true });
  const cancel = () => item.resolve({ confirmed: false });
</script>
<!-- ...truncated... -->

...or via the pop method on Stack instance:

import { notiStack } from './notification-stack';

// pop the topmost notification
notiStack.pop();

// pop a specific notification
notiStack.pop('specific-id');

// pop a specific notification with custom resolution value
notiStack.pop('id', 'custom-resolve-detail');

// alternatively, provide arguments as object
notiStack.pop({
  detail: 'custom-resolve-detail',
}); // pop the topmost notification with custom resolution value

Stack.pop returns the popped StackItem instance, or null if no notification is found with the provided id. You can set id in the push config, and get it back from the returned object.

import { notiStack } from './notification-stack';

const pushed = notiStack.push('info', { id: 'my-id' });

// ... later ...

const popped = notiStack.pop(pushed.config.id); // or just 'my-id'

// pushed === popped -> true

Typescript Support

To provide typing for StackItem.pop, pass type of your component as the generic argument. This helps infer the resolution type. See Awaiting for Resolution for more information.

import ConfirmNoti from './ConfirmNoti.svelte';

const popped = notiStack.pop<typeof ConfirmNoti>('id');
if (popped) {
  const resolution = await notiStack.resolution;
  if (resolution) { // can be undefined, for example when timeouted
    console.log(resolution.confirmed);
  }
}
<script lang="ts">
  import type { StackItemProps } from '@svelte-put/async-stack';

  let { item }: StackItemProps<{ confirmed: boolean }> = $props();
</script>

<!-- ...truncated... -->

Awaiting for Resolution

A StackItem instance is resolved when it is popped from a Stack. This resolution is a Promise that can be awaited, and its value is from argument passed to either Stack.pop or StackItem.resolve (injected item prop). This is especially useful for complex interactive notification (see Component for StackItem section for an exmaple of such).

import { notiStack } from './notification-stack';

const pushed = notiStack.push('info');
const resolved = await pushed.resolution;

To provide typing for resolution, simply extend the StackItemProps. The generic argument passed to StackItemProps is the type of resolution's Promise value:

<script lang="ts">
  import type { StackItemProps } from '@svelte-put/async-stack';

  let { item }: StackItemProps<{ confirmed: boolean }> = $props();
  // typeof item.resolution === Promise<{ confirmed: boolean }>
</script>

In the following example, try pressing the "Push a persistent notification" button and observe the async nature of the push & pop mechanism.

Example
<script lang="ts">
	import { notiStack } from './comprehensive/notification-stack';

	let promise: Promise<unknown> | null = null;
	async function pushNoti() {
		const pushed = notiStack.push('info', {
			timeout: 0,
			props: { content: 'persistent' },
		});

		promise = pushed.resolution;
		await promise;
		setTimeout(() => (promise = null), 2000);
	}

	function popNoti() {
		notiStack.pop();
	}
</script>

<button onclick={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" onclick={popNoti}>click here</button> to pop the notification.
		{:then}
			Resolved (resetting in 2 seconds)
		{/await}
	</p>
{/if}

Timeout and Progress

const pushed = notiStack.push('info', { timeout: 3000 });

If a StackItem has timeout specified in its config, Stack sets up a setTimeout to automatically pop it in due time.

A StackItem upon being timeouted will resolve with no value. Make sure you are aware of this if you await for its resolution somewhere else in the application.

This timeout can be paused and resumed by invoking corresponding methods on the Stack instance.

notiStack.pause(pushed.id);
notiStack.resume(pushed.id);

The pause and resume methods on Stack instance are actually just proxies for the same ones on StackItem instance, which is accessible from within component via the injected item prop...

<script lang="ts">
  import type { StackItemProps } from '@svelte-put/async-stack';

  let { item }: StackItemProps<{ confirmed: boolean }> = $props();

  $inspect(item.state);
  const pause = () => item.pause();
  const resume = () => item.resume();
</script>

<!-- ...truncated... -->

...accept when no id is specified, in which case the method will be applied to all active StackItem instances.

notiStack.pause(); // pausing all items
notiStack.resume(); // resuming all items

item.state is a reactive Svelte rune with value of 'idle' | 'elapsing' | 'paused' | 'timeout' | 'resolved', helpful to control the play state of some animation, for example as seen in Comprehensive Example.

Portal for Rendering Stack

As seen in Comprehensive Example, @svelte-put/async-stack does not control how and where you render your stack items, as it cannot assume the styling and animation required for your project. To make it easier to set up one, however, you can utilize the render action on a Stack.

The helper render action

The render action makes sure your StackItem components receive all the correct props upon push. In the example above, the associated components will be rendered as direct children of <div>.

<script>
	import { notiStack } from './notification-stack';
</script>

<!-- notification portal, typically setup at somewhere global like root layout -->
<aside>
  <ul>
    {#each notiStack.items as notification (notification.config.id)}
      <li use:notiStack.actions.render={notification}></li>
    {/each}
  </ul>
</aside>

Events upon Mounting & Unmounting StackItem

The render action also exposes two helpful events, stackitemmount and stackitemunmount, helpful for post processing and cleanup.

<li
  use:notiStack.actions.render={notification}
  onstackitemmount
  onstackitemunmount
></li>

For a more concrete example on how these events are helpful, see Recipes - Svelte Sonner Inspired Toast System.

Component for StackItem

Any Svelte component can be used with @svelte-put/async-stack. In any case, however, your component should accept an item prop that is the actual StackItem instance created by Stack on push. If you follow the setup pattern introduced in Portal For Rendering Stack, the render action should automatically pass the item prop to your component.

<script lang="ts">
  import type { StackItemProps } from '@svelte-put/async-stack';

  let { item }: StackItemProps<{ confirmed: boolean }> = $props();
</script>
<!-- ...truncated... -->

The item prop has the following interface:

interface StackItem<Resolved> {
  // the config applied on push
  config: StackItemInstanceConfig;

  // managing timeout
  state: StackItemState;
  resume: () => void;
  pause: () => void;

  // handling resolution
  resolution: Promise<Resolved | undefined>;
  resolve: (r: Resolved) => void;
}

type StackItemState = 'idle' | 'elapsing' | 'paused' | 'timeout' | 'resolved';

type StackItemInstanceConfig = {
  id: string;
  timeout: number;
  variant: string;
  component: import('svelte').Component;
  props?: Record<string, unknown>;
}

Timeout Management

When aStackItem is pushed with a timeout, calling pause or resume can help better control its state, as described in Timeout and Progress. The Comprehensive Example demonstrates this by pausing timeout upon pointer hover over the notification. Try it out again below:

Example

Click to push toast notification

Resolution Handling

Calling resolve on the item prop will pop the notification from Stack and resolve the resolution promise, as described in Awaiting for Resolution. This is especially useful for interactive notifications, such as in the example below.

Example

Waiting for notification to be pushed

<script lang="ts">
	import type { StackItemProps } from '@svelte-put/async-stack';
	import { fly } from 'svelte/transition';

	let { item, message }: StackItemProps<boolean> & { message: string } = $props();

	const join = () => item.resolve(true);
	const del = () => item.resolve(false);
</script>

<div
	class="bg-bg-100 pointer-events-auto rounded-sm px-4 py-2 shadow-lg"
	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" onclick={join}> Join </button>
		<button class="c-btn c-btn--outlined w-40" onclick={del}> Delete </button>
	</div>
</div>
<script>
	import { notiStack } from '../comprehensive/notification-stack';

	import InteractiveNotification from './InteractiveNotification.svelte';

	let state = $state('idle');
	async function pushNoti() {
		const pushed = notiStack.push('custom', {
			timeout: 0,
			component: InteractiveNotification,
			props: {
				message: 'You are invited to join the Svelte community!',
			},
		});

		state = 'pending';
		const agreed = await pushed.resolution;
		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:hl-error={state == 'denied'}
			class:hl-success={state === 'accepted'}
		>
			{state}
		</span>
	{/if}
</p>
<button class="c-btn" onclick={pushNoti} disabled={state === 'pending'}>
	Trigger Interactive Notification
</button>

Recipes

This section presents some useful patterns to better integrate @svelte-put/async-stack into your application.

Wrapping Stack in Svelte Context

To share a Stack instance across the application, it is useful to wrap it in an idiomatic Svelte context. The example below shows a minimal version of such pattern.

<script>
  import { setContext } from 'svelte';
  import { notiStack } from '$lib/notification/notification-stack';

  let { children } = $props();
  setContext('app:noti', notiStack);
</script>

{@render children()}
export const notiStack = stack({ /* common config */ })
  .addVariant('name', /* specific config */)
  .build();
export type NotiStack = typeof notiStack;
<script lang="ts">
  import type { NotiStack } from '$lib/notification/notification-stack';

  const notiStack = getContext<NotiStack>('app:noti');
  function pushNoti = () => notiStack.push(/* push config */);
</script>

<buttton onclick={pushNoti}></buttton>

A modal or dialog system shares much similarity with a notification system, although components tend to be much more complex. The following example shows one possible setup for such system.

Example
import { stack } from '@svelte-put/async-stack';

import ConfirmationModal from './ConfirmationModal.svelte';

export const modalStack = stack()
	.addVariant('confirm', {
		component: ConfirmationModal,
		props: {
			title: 'Confirmation',
			description: 'Do we have an accord?',
		},
	})
	.build();
<script lang="ts">
	import type { StackItemProps } from '@svelte-put/async-stack';

	import { enhancedialog } from './enhance-dialog';

	let {
		item,
		title,
		description,
	}: StackItemProps<{ confirmed: boolean }> & {
		title: string;
		description: string;
	} = $props();

	let dialog: HTMLDialogElement;

	function checkAndResolve() {
		// when dialog has closed and animation has finished, resolve
		if (!dialog.open) {
			item.resolve({ confirmed: dialog.returnValue === 'yes' });
		}
	}

	$effect(() => {
		dialog?.showModal();
	});
</script>

<dialog
	class="bg-bg-200 text-fg backdrop:bg-bg space-y-4 p-8 shadow-lg backdrop:opacity-50"
	bind:this={dialog}
	use:enhancedialog
	onclickbackdrop={() => dialog.close()}
	onanimationend={checkAndResolve}
>
	<p class="text-xl font-medium">{title}</p>
	<p>{description}</p>
	<form method="dialog" class="flex justify-end gap-4">
		<button type="submit" class="c-btn c-btn--outlined" value="no">No</button>
		<button type="submit" class="c-btn" value="yes">Yes</button>
	</form>
</dialog>

<style>
	dialog {
		inset: 0;

		display: block; /* required for animation to work */

		margin: auto;

		animation-name: fly-out-down;
		animation-duration: 150ms;
		animation-timing-function: var(--default-transition-timing-function);
		animation-fill-mode: forwards;
	}

	dialog[open] {
		animation-name: fly-in-up;
	}

	@keyframes fly-in-up {
		from {
			transform: translateY(50px);
			opacity: 0;
		}

		to {
			transform: translateY(0);
			opacity: 1;
		}
	}

	@keyframes fly-out-down {
		from {
			transform: translateY(0);
			opacity: 1;
		}

		to {
			transform: translateY(50px);
			opacity: 0;
		}
	}
</style>
<script>
	import { modalStack } from './modal-stack';
</script>

<!-- notification portal, typically setup at somewhere global like root layout -->
<aside class="z-modal pointer-events-none fixed inset-0">
	{#each modalStack.items as modal (modal.config.id)}
		<div class="pointer-events-auto h-full w-full" use:modalStack.actions.render={modal}></div>
	{/each}
</aside>
<script lang="ts">
	import { modalStack } from './modal-stack';

	let confirmed: boolean | undefined = undefined;
	async function confirm() {
		const pushed = modalStack.push('confirm');
		({ confirmed } = (await pushed.resolution) ?? {});
	}
</script>

<div class="not-prose flex items-center gap-2">
	<button class="c-btn" onclick={confirm}>Trigger Modal</button>
	{#if confirmed === true}
		<p class="text-sm font-bold text-green-500">We have an accord.</p>
	{:else if confirmed === false}
		<p class="text-sm font-bold text-red-500">We don't have an accord.</p>
	{/if}
</div>
// TODO: candidate for a separate package?

/**
 * @typedef EnhanceDialogAttributes
 * @property {(e: CustomEvent<void>) => void} [onclickbackdrop] - fired when clicked on backdrop
 */

/**
 * @typedef {import('svelte/action').ActionReturn<undefined, EnhanceDialogAttributes>} EnhanceDialogActionReturn
 */

/**
 * @param {HTMLDialogElement} dialog
 * @returns {EnhanceDialogActionReturn}
 */
export function enhancedialog(dialog) {
	/**
	 * @param {MouseEvent} event
	 */
	function onClick(event) {
		let rect = /** @type {HTMLDialogElement} */ (event.target).getBoundingClientRect();
		if (!event.clientX || !event.clientY) return; // not a mouse event (probably triggered by keyboard)
		if (
			rect.left > event.clientX ||
			rect.right < event.clientX ||
			rect.top > event.clientY ||
			rect.bottom < event.clientY
		) {
			dialog.dispatchEvent(new CustomEvent('clickbackdrop'));
		}
	}

	dialog.addEventListener('click', onClick);

	return {
		destroy() {
			dialog.removeEventListener('click', onClick);
		},
	};
}

HTML dialog for the win!

The ConfirmationModal component uses a native <dialog> element, which is a great way to create accessible dialogs without reinventing the wheel. A couple of things worth noting from the example above:

  • dialog.showModal() is used to create dialog-as-a-modal, providing some additional feature like focus-trapping and escape key. Depending on your use cases, you might just need to use dialog.show() instead.
  • The ConfirmationModal component uses animationend event to check for when to resolve the StackItem (note the fly-in-and-out animation). This works in conjunction with a correct form setup and the use of dialog.returnValue. If you do not care about such things, simply call item.resolve when needed.
  • Currently there is no way yet to natively capture click event on the <dialog>backdrop, the enhancedialog action is there for that.

Svelte Sonner Inspired Toast System

As demonstrated throughout this documentation, svelte-put/async-stack can be used to build a push notification system. See Comprehensive Example for a complete example. The example below shows a more fancy version that is inspired by Svelte Sonner.

Example
import { stack } from '@svelte-put/async-stack';

import Toast from './Toast.svelte';

export const toastStack = stack({ timeout: 3_000 }).addVariant('toast', Toast).build();

// create push proxies
const STATUSES = ['info', 'success', 'warning', 'error'] as const;
type Status = (typeof STATUSES)[number];
type ToastPusher = (message: string) => void;
export const toast: Record<Status, ToastPusher> = STATUSES.reduce(
	(acc, status) => {
		acc[status] = (message) =>
			toastStack.push('toast', {
				props: {
					status,
					title: status.charAt(0).toUpperCase() + status.slice(1),
					message,
				},
			});
		return acc;
	},
	{} as Record<Status, ToastPusher>,
);
<script lang="ts" module>
	import type { StackItemProps } from '@svelte-put/async-stack';
	import type { HTMLAttributes } from 'svelte/elements';

	export interface ToastProps extends HTMLAttributes<HTMLElement>, StackItemProps {
		title?: string;
		message: string;
		status?: 'info' | 'success' | 'warning' | 'error';
	}

	const iconClassMap: Record<NonNullable<ToastProps['status']>, string> = {
		info: 'i-[info]',
		success: 'i-[check-circle]',
		warning: 'i-[warning]',
		error: 'i-[warning-circle]',
	};
</script>

<script lang="ts">
	let { item, title, message, status = 'info', class: cls, ...rest }: ToastProps = $props();

	const iconClass = $derived(status ? iconClassMap[status] : 'i-[info]');

	function dismiss() {
		item.resolve();
	}
</script>

<article
	class="relative shadow {cls}"
	role="status"
	aria-live="polite"
	aria-atomic="true"
	data-status={status}
	{...rest}
>
	<!-- x button to dismiss -->
	<button
		onclick={dismiss}
		class="absolute right-0 top-0 flex -translate-y-1/2 translate-x-1/2 cursor-pointer rounded-full border border-current bg-inherit p-1.5"
	>
		<i class="i i-[x] h-3.5 w-3.5"></i>
		<span class="sr-only">Dismiss</span>
	</button>
	<div class="relative flex items-start gap-3 overflow-hidden p-3">
		<div class="i {iconClass} h-6 w-6 shrink-0"></div>

		<div class="leading-normal">
			<p class="mb-2 border-b border-current pb-1 font-medium">
				{title}
			</p>
			<p>{message}</p>
		</div>

		<!-- progress, (time until auto-dismiss) -->
		<div
			class="progress absolute inset-x-0 bottom-0 h-0.5 origin-left overflow-hidden"
			style:--progress-duration={item.config.timeout + 'ms'}
			style:--progress-play-state={item.state === 'paused' ? 'paused' : 'running'}
			aria-disabled="true"
		></div>
	</div>
</article>

<style>
	article {
		color: var(--toast-color-fg);
		background-color: var(--toast-color-bg);

		&[data-status='info'] {
			--toast-color-icon: var(--color-info-fg);
			--toast-color-progress: var(--color-info-bg-200);
			--toast-color-bg: var(--color-info-bg);
			--toast-color-fg: var(--color-info-fg);
		}

		&[data-status='success'] {
			--toast-color-icon: var(--color-success-fg);
			--toast-color-progress: var(--color-success-bg-200);
			--toast-color-bg: var(--color-success-bg);
			--toast-color-fg: var(--color-success-fg);
		}

		&[data-status='warning'] {
			--toast-color-icon: var(--color-warning-fg);
			--toast-color-progress: var(--color-warning-bg-200);
			--toast-color-bg: var(--color-warning-bg);
			--toast-color-fg: var(--color-warning-fg);
		}

		&[data-status='error'] {
			--toast-color-icon: var(--color-error-fg);
			--toast-color-progress: var(--color-error-bg-200);
			--toast-color-bg: var(--color-error-bg);
			--toast-color-fg: var(--color-error-fg);
		}
	}

	.progress {
		background-color: var(--toast-color-progress);
		animation: progress var(--progress-duration) linear;
		animation-play-state: var(--progress-play-state);
	}

	.i {
		color: var(--toast-color-icon);
	}

	@keyframes progress {
		from {
			transform: scaleX(0);
		}

		to {
			transform: scaleX(1);
		}
	}
</style>
<script lang="ts">
	import { flip } from 'svelte/animate';
	import { fly } from 'svelte/transition';

	import { toastStack } from './toast';

	const MAX_ITEMS = 3;
	const SCALE_STEP = 0.05;
	const EXPAND_REM_GAP = 1;
	const TRANSLATE_REM_STEP = 1;
	/** if render top down, 1, if bottom up, -1 */
	const Y_SIGN = -1;

	let expanded = $state(false);
	let visibleItems = $derived(toastStack.items.slice(-3));

	let liMap: Record<string, HTMLLIElement> = $state({});
	let liHeightMap = $derived(
		Object.entries(liMap).reduce(
			(acc, [id, li]) => {
				acc[id] = li.clientHeight;
				return acc;
			},
			{} as Record<string, number>,
		),
	);
	let liOpacityMap = $derived(
		Object.entries(liMap).reduce(
			(acc, [id, li]) => {
				const index = parseInt(li.dataset.index ?? '0') || 0;
				acc[id] = index >= toastStack.items.length - MAX_ITEMS ? 1 : 0;
				return acc;
			},
			{} as Record<string, number>,
		),
	);
	let liTransformMap = $derived(
		Object.entries(liMap).reduce(
			(acc, [id, li]) => {
				const index = parseInt(li.dataset.index ?? '0') || 0;
				if (!expanded) {
					const delta = Math.min(toastStack.items.length - index, MAX_ITEMS + 1) - 1;
					const scaleX = 1 - delta * SCALE_STEP;

					const liHeight = liHeightMap[id] ?? 0;
					let topLiHeight = 0;
					const topItem = toastStack.items.at(-1);
					if (topItem) topLiHeight = liHeightMap[topItem.config.id] ?? 0;

					let scaleY = scaleX;
					let yPx = 0;
					if (liHeight >= topLiHeight) {
						scaleY = topLiHeight / liHeight || 1;
					} else {
						yPx = Y_SIGN * (topLiHeight - liHeight * scaleY);
					}
					const yRem = (Y_SIGN * delta * TRANSLATE_REM_STEP) / scaleY;

					acc[id] = `scaleX(${scaleX}) scaleY(${scaleY}) translateY(calc(${yPx}px + ${yRem}rem))`;
					return acc;
				}

				let accumulatedHeight = 0;
				let rem = 0;
				for (let i = toastStack.items.length - 1; i > index; i--) {
					accumulatedHeight += liHeightMap[toastStack.items[i].config.id] ?? 0;
					rem += EXPAND_REM_GAP;
				}
				let scale = index < toastStack.items.length - MAX_ITEMS ? 1 - MAX_ITEMS * SCALE_STEP : 1;

				acc[id] =
					`scale(${scale}) translateY(calc(${Y_SIGN * accumulatedHeight}px + ${Y_SIGN * rem}rem))`;
				return acc;
			},
			{} as Record<string, string>,
		),
	);
	const olHeight = $derived.by(() => {
		if (!expanded) return 'auto';
		const contentPx = visibleItems.reduce((acc, item) => {
			acc += (liHeightMap[item.config.id] ?? 0) + EXPAND_REM_GAP;
			return acc;
		}, 0);
		const remGap = EXPAND_REM_GAP * (visibleItems.length - 1);
		return `calc(${contentPx}px + ${remGap}rem)`;
	});

	function onMouseEnter() {
		expanded = true;
		toastStack.pause(); // pause all items;
	}
	function onMouseLeave() {
		expanded = false;
		toastStack.resume(); // resume all items;
	}
</script>

<!-- toast portal, typically setup at somewhere global like root layout -->
<ol
	class="z-notification tablet:bottom-10 tablet:right-10 fixed bottom-2 right-4 grid content-end items-end"
	style:height={olHeight}
	onmouseenter={onMouseEnter}
	onmouseleave={onMouseLeave}
	data-expanded={expanded}
>
	{#each toastStack.items as notification, index (notification.config.id)}
		{@const id = notification.config.id}
		<li
			data-index={index}
			class="w-full origin-center"
			animate:flip={{ duration: 200 }}
			transition:fly={{ duration: 200, y: '2rem' }}
			style:opacity={liOpacityMap[id]}
			style:transform={liTransformMap[id]}
			use:toastStack.actions.render={notification}
			onstackitemmount={(e) => {
				liMap[id] = e.target as HTMLLIElement;
			}}
			onstackitemunmount={() => {
				delete liMap[id];
			}}
		></li>
	{/each}
</ol>

<style lang="postcss">
	ol {
		width: min(calc(100% - 2rem), 40rem);
		transition-duration: 500ms;

		&:hover {
			transition-duration: 250ms;
		}
	}

	li {
		grid-column: 1;
		grid-row: 1;

		transition-timing-function: var(--default-transition-timing-function);
		transition-duration: inherit;
		transition-property: transform;
	}
</style>
<script lang="ts">
	import { toast } from './toast';

	const message = `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s`;
</script>

<div class="not-prose flex flex-wrap items-center gap-2">
	<button class="c-btn c-btn--outlined" onclick={() => toast.info(message)}>info</button>
	<button class="c-btn c-btn--outlined" onclick={() => toast.success(message)}>success</button>
	<button class="c-btn c-btn--outlined" onclick={() => toast.warning(message)}>warning</button>
	<button class="c-btn c-btn--outlined" onclick={() => toast.error(message)}>error</button>
</div>

Edit this page on Github