Appearance
Toaster — live demo
This is the real @rozie-ui/toast-vue package running on this page (VitePress is itself a Vue app). Click a button to enqueue a toast — it appears in the corner, auto-dismisses after its duration (hover the stack to pause), and you can close it with its × button or clear them all. Everything below is driven by the same Toaster.rozie source that compiles to all six frameworks, built on native DOM with no engine and no required CSS — the queue/timer behaviour and a tokenised skin all ship inside the component.
The host is mounted once and driven entirely through Vue's ref — there is no global toast() singleton; "call from anywhere" is your app's wiring concern.
show({ message, type, duration }) enqueues a toast and returns its id; dismiss(id) removes one and clear() removes them all. Pass duration: 0 for a sticky toast. Set position to any of the six corners (top-left, top-right, top-center, bottom-left, bottom-right, bottom-center), cap the stack with max, and opt out of hover-pause with disablePauseOnHover. See the full API for every prop, the handle, the #toast scoped slot, theming, and accessibility.
Custom chrome with the #toast slot
By default each toast renders its message plus a close button. The #toast scoped slot ({ toast, dismiss }) hands you the toast record and the dismiss function so you can render whatever chrome you like:
vue
<script setup>
import { ref } from 'vue';
import Toaster from '@rozie-ui/toast-vue';
const toaster = ref();
</script>
<template>
<button @click="toaster.show({ message: 'Undo?', type: 'info' })">Delete</button>
<Toaster ref="toaster" position="top-center">
<template #toast="{ toast, dismiss }">
<strong style="text-transform: capitalize">{{ toast.type }}</strong>
<span>{{ toast.message }}</span>
<button @click="dismiss(toast.id)">Undo</button>
</template>
</Toaster>
</template>One source, six outputs
You author the component once as a .rozie file:
html
<!--
Toaster.rozie — a headless, accessible toast / notification host.
A pure-Rozie family (NO third-party engine). Cross-framework toast systems are
re-implemented everywhere; Rozie ships ONE self-contained host. Deliberately
NOT a global singleton + context system (the heavyweight shape): the <Toaster>
owns the queue + auto-dismiss timers as internal state and exposes an imperative
`show / dismiss / clear` handle the consumer drives via `ref`. "Call from
anywhere" is then the consumer's app-wiring concern (stash the ref) — Rozie owns
the component, not the app's global plumbing. This keeps it captcha-simple and
side-steps the "$provide/$inject doesn't cross a portal" limitation.
Timers: each non-sticky toast schedules a window.setTimeout to auto-dismiss;
hovering the stack pauses them (full restart on leave — remaining-time tracking
is intentionally out of v1 scope), and $onUnmount clears them all. `timers` and
`nextId` are top-level `let`s (mutable cross-render scratch → React hoists them
to useRef; the engine-wrapper persistence guarantee).
Authoring notes (collision classes — see the authoring playbook):
- Imperative verbs `show` / `dismiss` / `clear` are NOT inherited HTMLElement
members (no ROZ137) and there are no emits (so no expose==event ROZ121). A
per-toast close button calls the internal `dismiss`; consumers wanting an
action button / custom chrome use the #toast scoped slot.
- Handler params left UNTYPED (neutralize to `any`). All window.* calls are
typeof-guarded for SSR.
- $data.toasts is written via fresh arrays (concat/filter) — never in-place
mutation (dropped on React/Solid/Lit/Angular change detection).
Consumer example:
<Toaster ref="toaster" position="top-right" :duration="4000" />
// elsewhere: $refs.toaster.show({ message: 'Saved', type: 'success' })
-->
<rozie name="Toaster">
<props>
{
// Stack corner: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' |
// 'bottom-right' | 'bottom-center'.
position: {
type: String,
default: 'bottom-right',
docs: {
description:
"Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.",
},
},
// Default auto-dismiss in ms. 0 (or a per-toast duration of 0) = sticky.
duration: {
type: Number,
default: 4000,
docs: {
description:
'Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.',
},
},
// Max visible toasts (0 = unlimited); when exceeded, the oldest drop.
max: {
type: Number,
default: 0,
docs: {
description:
'Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.',
},
},
// Opt OUT of pausing auto-dismiss timers while the pointer is over the stack
// (default: hovering pauses).
disablePauseOnHover: {
type: Boolean,
default: false,
docs: {
description:
'Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.',
},
},
// Accessible name for the live region (default 'Notifications').
ariaLabel: {
type: String,
default: null,
docs: {
description:
"Accessible name for the live region (`role=\"region\"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.",
},
},
}
</props>
<data>
{
// The live toast queue: [{ id, message, type, duration }].
toasts: [],
// Monotonic id counter. Kept in reactive $data (NOT a module-let) so it
// persists across renders on React: a top-level `let` referenced ONLY inside an
// $expose verb (useImperativeHandle) is not hoisted to useRef by the emitter, so
// it would reset to 0 every render → duplicate toast ids. $data IS real state.
seq: 0,
}
</data>
<script lang="ts">
// Mutable cross-render scratch (NOT reactive): the per-id timeout handles. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
let timers = {}
// ---- timers ------------------------------------------------------------
const startTimer = (toast) => {
if (!toast || !toast.duration || toast.duration <= 0) return
if (typeof window === 'undefined') return
timers[toast.id] = window.setTimeout(() => dismiss(toast.id), toast.duration)
}
const clearTimer = (id) => {
if (timers[id] && typeof window !== 'undefined') window.clearTimeout(timers[id])
delete timers[id]
}
const pauseTimers = () => {
if (typeof window === 'undefined') return
for (const k in timers) window.clearTimeout(timers[k])
timers = {}
}
// ---- queue (imperative handle implementations) -------------------------
const show = (input) => {
const t = input || {}
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id
if (t.id != null) {
id = t.id
} else {
const s = $data.seq
id = 't' + s
$data.seq = s + 1
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : $props.duration,
}
const next = $data.toasts.concat([toast])
const max = $props.max
$data.toasts = max > 0 && next.length > max ? next.slice(next.length - max) : next
startTimer(toast)
return id
}
const dismiss = (id) => {
clearTimer(id)
$data.toasts = $data.toasts.filter((t) => t.id !== id)
}
const clear = () => {
pauseTimers()
$data.toasts = []
}
// ---- hover pause -------------------------------------------------------
const onMouseEnter = () => {
if ($props.disablePauseOnHover) return
pauseTimers()
}
const onMouseLeave = () => {
if ($props.disablePauseOnHover) return
for (const t of $data.toasts) startTimer(t)
}
// ---- helpers -----------------------------------------------------------
const regionLabel = () => ($props.ariaLabel != null ? $props.ariaLabel : 'Notifications')
const liveFor = (type) => (type === 'error' || type === 'warning' ? 'assertive' : 'polite')
// ---- lifecycle + handle ------------------------------------------------
$onUnmount(() => {
pauseTimers()
})
$expose({ show, dismiss, clear })
</script>
<template>
<div
class="rozie-toaster"
:class="'rozie-toaster--' + $props.position"
role="region"
:aria-label="regionLabel()"
@mouseenter="onMouseEnter()"
@mouseleave="onMouseLeave()"
>
<!-- Loop var is `t`, NOT `toast`: a loop var named `toast` shadows the `#toast`
slot snippet on Svelte (`{#each … as toast}` collides with the `toast`
snippet → `{@render toast()}` renders a non-function → Svelte-only throw).
Same collision class as slider's mark→tick / embla's slide→item. The slot
PROP stays `:toast` (consumers still get `{ toast, dismiss }`). -->
<div
r-for="t in $data.toasts"
:key="t.id"
class="rozie-toast"
:class="'rozie-toast--' + t.type"
role="status"
:aria-live="liveFor(t.type)"
>
<slot name="toast" :toast="t" :dismiss="dismiss">
<span class="rozie-toast-message">{{ t.message }}</span>
<button type="button" class="rozie-toast-close" aria-label="Dismiss" @click="dismiss(t.id)">×</button>
</slot>
</div>
</div>
</template>
<style>
/*
Token-driven (mirrors slider/otp themes): every visual value is a
`var(--rozie-toast-*, <fallback>)`. The shipped themes/*.css presets map these
onto shadcn/Radix, Material 3, Bootstrap 5.
*/
.rozie-toaster {
position: fixed;
z-index: var(--rozie-toast-z, 9999);
display: flex;
flex-direction: column;
gap: var(--rozie-toast-gap, 0.5rem);
padding: var(--rozie-toast-region-padding, 1rem);
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
pointer-events: none;
font: var(--rozie-toast-font, inherit);
}
.rozie-toaster > * {
pointer-events: auto;
}
/* corners */
.rozie-toaster--top-left { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toast {
display: flex;
align-items: center;
gap: var(--rozie-toast-content-gap, 0.75rem);
min-width: var(--rozie-toast-min-width, 16rem);
max-width: var(--rozie-toast-toast-max-width, 24rem);
padding: var(--rozie-toast-padding, 0.75rem 1rem);
color: var(--rozie-toast-color, #fff);
background: var(--rozie-toast-bg, #333);
border-radius: var(--rozie-toast-radius, 0.5rem);
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
}
.rozie-toast--success { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
.rozie-toast-message {
flex: 1 1 auto;
font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-toast-close-size, 1.25rem);
height: var(--rozie-toast-close-size, 1.25rem);
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: inherit;
background: transparent;
border: none;
border-radius: 0.25rem;
opacity: var(--rozie-toast-close-opacity, 0.75);
cursor: pointer;
}
.rozie-toast-close:hover {
opacity: 1;
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/toast-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, rozieDisplay } from '@rozie/runtime-react';
import './Toaster.css';
interface ToastCtx { toast: any; dismiss: any; }
interface ToasterProps {
/**
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
*/
position?: string;
/**
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
*/
duration?: number;
/**
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
*/
max?: number;
/**
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
*/
disablePauseOnHover?: boolean;
/**
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
*/
ariaLabel?: (string) | null;
renderToast?: (ctx: ToastCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface ToasterHandle {
show: (...args: any[]) => any;
dismiss: (...args: any[]) => any;
clear: (...args: any[]) => any;
}
const Toaster = forwardRef<ToasterHandle, ToasterProps>(function Toaster(_props: ToasterProps, ref): JSX.Element {
const props: Omit<ToasterProps, 'position' | 'duration' | 'max' | 'disablePauseOnHover' | 'ariaLabel'> & { position: string; duration: number; max: number; disablePauseOnHover: boolean; ariaLabel: (string) | null } = {
..._props,
position: _props.position ?? 'bottom-right',
duration: _props.duration ?? 4000,
max: _props.max ?? 0,
disablePauseOnHover: _props.disablePauseOnHover ?? false,
ariaLabel: _props.ariaLabel ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { position, duration, max, disablePauseOnHover, ariaLabel, ...rest } = _props as ToasterProps & Record<string, unknown>;
void position; void duration; void max; void disablePauseOnHover; void ariaLabel;
return rest;
})();
const timers = useRef({});
const [toasts, setToasts] = useState<any[]>([]);
const [seq, setSeq] = useState(0);
function startTimer(toast: any) {
if (!toast || !toast.duration || toast.duration <= 0) return;
if (typeof window === 'undefined') return;
timers.current[toast.id] = window.setTimeout(() => dismiss(toast.id), toast.duration);
}
function clearTimer(id: any) {
if (timers.current[id] && typeof window !== 'undefined') window.clearTimeout(timers.current[id]);
delete timers.current[id];
}
const pauseTimers = useCallback(() => {
if (typeof window === 'undefined') return;
for (const k in timers.current) window.clearTimeout(timers.current[k]);
timers.current = {};
}, []);
function show(input: any) {
const t = input || {};
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id;
if (t.id != null) {
id = t.id;
} else {
const s = seq;
id = 't' + s;
setSeq(s + 1);
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : props.duration
};
const next = toasts.concat([toast]);
const max = props.max;
setToasts(max > 0 && next.length > max ? next.slice(next.length - max) : next);
startTimer(toast);
return id;
}
const dismiss = useCallback((id: any) => {
clearTimer(id);
setToasts(prev => prev.filter((t: any) => t.id !== id));
}, [clearTimer]);
function clear() {
pauseTimers();
setToasts([]);
}
const onMouseEnter = useCallback(() => {
if (props.disablePauseOnHover) return;
pauseTimers();
}, [pauseTimers, props.disablePauseOnHover]);
const onMouseLeave = useCallback(() => {
if (props.disablePauseOnHover) return;
for (const t of toasts as any) startTimer(t);
}, [props.disablePauseOnHover, startTimer, toasts]);
function regionLabel() {
return props.ariaLabel != null ? props.ariaLabel : 'Notifications';
}
function liveFor(type: any) {
return type === 'error' || type === 'warning' ? 'assertive' : 'polite';
}
useEffect(() => {
return () => {
pauseTimers();
};
}, []);
const _rozieExposeRef = useRef({ show, dismiss, clear });
_rozieExposeRef.current = { show, dismiss, clear };
useImperativeHandle(ref, () => ({ show: (...args: Parameters<typeof show>): ReturnType<typeof show> => _rozieExposeRef.current.show(...args), dismiss: (...args: Parameters<typeof dismiss>): ReturnType<typeof dismiss> => _rozieExposeRef.current.dismiss(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args) }), []);
return (
<>
<div role="region" aria-label={rozieAttr(regionLabel())} {...attrs} className={clsx(clsx("rozie-toaster", 'rozie-toaster--' + props.position), (attrs.className as string | undefined))} onMouseEnter={($event) => { onMouseEnter(); }} onMouseLeave={($event) => { onMouseLeave(); }} data-rozie-s-12d4265c="">
{toasts.map((t) => <div key={t.id} className={clsx("rozie-toast", 'rozie-toast--' + t.type)} role="status" aria-live={rozieAttr(liveFor(t.type))} data-rozie-s-12d4265c="">
{(props.renderToast ?? props.slots?.['toast']) ? ((props.renderToast ?? props.slots?.['toast']) as Function)({ toast: t, dismiss }) : <><span className={"rozie-toast-message"} data-rozie-s-12d4265c="">{rozieDisplay(t.message)}</span><button type="button" className={"rozie-toast-close"} aria-label="Dismiss" onClick={($event) => { dismiss(t.id); }} data-rozie-s-12d4265c="">×</button></>}
</div>)}
</div>
</>
);
});
export default Toaster;vue
<template>
<div :class="['rozie-toaster', 'rozie-toaster--' + props.position]" role="region" :aria-label="regionLabel()" v-bind="$attrs" @mouseenter="onMouseEnter()" @mouseleave="onMouseLeave()">
<div v-for="t in toasts" :key="t.id" :class="['rozie-toast', 'rozie-toast--' + t.type]" role="status" :aria-live="liveFor(t.type)">
<slot name="toast" :toast="t" :dismiss="dismiss">
<span class="rozie-toast-message">{{ t.message }}</span>
<button type="button" class="rozie-toast-close" aria-label="Dismiss" @click="dismiss(t.id)">×</button>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
const props = withDefaults(
defineProps<{
/**
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
*/
position?: string;
/**
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
*/
duration?: number;
/**
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
*/
max?: number;
/**
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
*/
disablePauseOnHover?: boolean;
/**
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
*/
ariaLabel?: string | null;
}>(),
{ position: 'bottom-right', duration: 4000, max: 0, disablePauseOnHover: false, ariaLabel: null }
);
defineSlots<{
toast(props: { toast: any; dismiss: any }): any;
}>();
const toasts = ref<any[]>([]);
const seq = ref(0);
// Mutable cross-render scratch (NOT reactive): the per-id timeout handles. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
let timers = {};
// ---- timers ------------------------------------------------------------
// ---- timers ------------------------------------------------------------
const startTimer = (toast: any) => {
if (!toast || !toast.duration || toast.duration <= 0) return;
if (typeof window === 'undefined') return;
timers[toast.id] = window.setTimeout(() => dismiss(toast.id), toast.duration);
};
const clearTimer = (id: any) => {
if (timers[id] && typeof window !== 'undefined') window.clearTimeout(timers[id]);
delete timers[id];
};
const pauseTimers = () => {
if (typeof window === 'undefined') return;
for (const k in timers) window.clearTimeout(timers[k]);
timers = {};
};
// ---- queue (imperative handle implementations) -------------------------
// ---- queue (imperative handle implementations) -------------------------
const show = (input: any) => {
const t = input || {};
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id;
if (t.id != null) {
id = t.id;
} else {
const s = seq.value;
id = 't' + s;
seq.value = s + 1;
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : props.duration
};
const next = toasts.value.concat([toast]);
const max = props.max;
toasts.value = max > 0 && next.length > max ? next.slice(next.length - max) : next;
startTimer(toast);
return id;
};
const dismiss = (id: any) => {
clearTimer(id);
toasts.value = toasts.value.filter((t: any) => t.id !== id);
};
const clear = () => {
pauseTimers();
toasts.value = [];
};
// ---- hover pause -------------------------------------------------------
// ---- hover pause -------------------------------------------------------
const onMouseEnter = () => {
if (props.disablePauseOnHover) return;
pauseTimers();
};
const onMouseLeave = () => {
if (props.disablePauseOnHover) return;
for (const t of toasts.value as any) startTimer(t);
};
// ---- helpers -----------------------------------------------------------
// ---- helpers -----------------------------------------------------------
const regionLabel = () => props.ariaLabel != null ? props.ariaLabel : 'Notifications';
const liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';
// ---- lifecycle + handle ------------------------------------------------
onBeforeUnmount(() => {
pauseTimers();
});
defineExpose({ show, dismiss, clear });
</script>
<style scoped>
.rozie-toaster {
position: fixed;
z-index: var(--rozie-toast-z, 9999);
display: flex;
flex-direction: column;
gap: var(--rozie-toast-gap, 0.5rem);
padding: var(--rozie-toast-region-padding, 1rem);
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
pointer-events: none;
font: var(--rozie-toast-font, inherit);
}
.rozie-toaster > * {
pointer-events: auto;
}
.rozie-toaster--top-left { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toast {
display: flex;
align-items: center;
gap: var(--rozie-toast-content-gap, 0.75rem);
min-width: var(--rozie-toast-min-width, 16rem);
max-width: var(--rozie-toast-toast-max-width, 24rem);
padding: var(--rozie-toast-padding, 0.75rem 1rem);
color: var(--rozie-toast-color, #fff);
background: var(--rozie-toast-bg, #333);
border-radius: var(--rozie-toast-radius, 0.5rem);
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
}
.rozie-toast--success { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
.rozie-toast-message {
flex: 1 1 auto;
font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-toast-close-size, 1.25rem);
height: var(--rozie-toast-close-size, 1.25rem);
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: inherit;
background: transparent;
border: none;
border-radius: 0.25rem;
opacity: var(--rozie-toast-close-opacity, 0.75);
cursor: pointer;
}
.rozie-toast-close:hover {
opacity: 1;
}
</style>svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onDestroy } from 'svelte';
interface Props {
/**
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
*/
position?: string;
/**
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
*/
duration?: number;
/**
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
*/
max?: number;
/**
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
*/
disablePauseOnHover?: boolean;
/**
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
*/
ariaLabel?: (string) | null;
toast?: Snippet<[{ toast: any; dismiss: any }]>;
snippets?: Record<string, any>;
[key: string]: unknown;
}
let {
position = 'bottom-right',
duration = 4000,
max = 0,
disablePauseOnHover = false,
ariaLabel = null,
toast: __toastProp,
snippets,
...__rozieAttrs
}: Props = $props();
const toast = $derived(__toastProp ?? snippets?.toast);
let toasts: any[] = $state([]);
let seq = $state(0);
// Mutable cross-render scratch (NOT reactive): the per-id timeout handles. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
let timers = {};
// ---- timers ------------------------------------------------------------
// ---- timers ------------------------------------------------------------
const startTimer = (toast: any) => {
if (!toast || !toast.duration || toast.duration <= 0) return;
if (typeof window === 'undefined') return;
timers[toast.id] = window.setTimeout(() => dismiss(toast.id), toast.duration);
};
const clearTimer = (id: any) => {
if (timers[id] && typeof window !== 'undefined') window.clearTimeout(timers[id]);
delete timers[id];
};
const pauseTimers = () => {
if (typeof window === 'undefined') return;
for (const k in timers) window.clearTimeout(timers[k]);
timers = {};
};
// ---- queue (imperative handle implementations) -------------------------
// ---- queue (imperative handle implementations) -------------------------
export const show = (input: any) => {
const t = input || {};
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id;
if (t.id != null) {
id = t.id;
} else {
const s = seq;
id = 't' + s;
seq = s + 1;
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : duration
};
const next = toasts.concat([toast]);
const max$local = max;
toasts = max$local > 0 && next.length > max$local ? next.slice(next.length - max$local) : next;
startTimer(toast);
return id;
};
export const dismiss = (id: any) => {
clearTimer(id);
toasts = toasts.filter((t: any) => t.id !== id);
};
export const clear = () => {
pauseTimers();
toasts = [];
};
// ---- hover pause -------------------------------------------------------
// ---- hover pause -------------------------------------------------------
const onMouseEnter = () => {
if (disablePauseOnHover) return;
pauseTimers();
};
const onMouseLeave = () => {
if (disablePauseOnHover) return;
for (const t of toasts as any) startTimer(t);
};
// ---- helpers -----------------------------------------------------------
// ---- helpers -----------------------------------------------------------
const regionLabel = () => ariaLabel != null ? ariaLabel : 'Notifications';
const liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';
// ---- lifecycle + handle ------------------------------------------------
onDestroy(() => (() => {
pauseTimers();
})());
</script>
<div role="region" aria-label={rozieAttr(regionLabel())} {...__rozieAttrs} class={["rozie-toaster", rozieClass('rozie-toaster--' + position), (__rozieAttrs)?.class]} onmouseenter={($event) => { onMouseEnter(); }} onmouseleave={($event) => { onMouseLeave(); }} use:applyListeners={__rozieAttrs} data-rozie-s-12d4265c>{#each toasts as t (t.id)}<div class={["rozie-toast", rozieClass('rozie-toast--' + t.type)]} role="status" aria-live={rozieAttr(liveFor(t.type))} data-rozie-s-12d4265c>{#if toast}{@render toast({ toast: t, dismiss })}{:else}<span class="rozie-toast-message" data-rozie-s-12d4265c>{rozieDisplay(t.message)}</span><button type="button" class="rozie-toast-close" aria-label="Dismiss" onclick={($event) => { dismiss(t.id); }} data-rozie-s-12d4265c>×</button>{/if}</div>{/each}</div>
<style>
:global {
.rozie-toaster[data-rozie-s-12d4265c] {
position: fixed;
z-index: var(--rozie-toast-z, 9999);
display: flex;
flex-direction: column;
gap: var(--rozie-toast-gap, 0.5rem);
padding: var(--rozie-toast-region-padding, 1rem);
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
pointer-events: none;
font: var(--rozie-toast-font, inherit);
}
.rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
pointer-events: auto;
}
.rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toast[data-rozie-s-12d4265c] {
display: flex;
align-items: center;
gap: var(--rozie-toast-content-gap, 0.75rem);
min-width: var(--rozie-toast-min-width, 16rem);
max-width: var(--rozie-toast-toast-max-width, 24rem);
padding: var(--rozie-toast-padding, 0.75rem 1rem);
color: var(--rozie-toast-color, #fff);
background: var(--rozie-toast-bg, #333);
border-radius: var(--rozie-toast-radius, 0.5rem);
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
}
.rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
.rozie-toast-message[data-rozie-s-12d4265c] {
flex: 1 1 auto;
font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close[data-rozie-s-12d4265c] {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-toast-close-size, 1.25rem);
height: var(--rozie-toast-close-size, 1.25rem);
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: inherit;
background: transparent;
border: none;
border-radius: 0.25rem;
opacity: var(--rozie-toast-close-opacity, 0.75);
cursor: pointer;
}
.rozie-toast-close[data-rozie-s-12d4265c]:hover {
opacity: 1;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, inject, input, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
interface ToastCtx {
$implicit: { toast: any; dismiss: any };
toast: any;
dismiss: any;
}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-toaster',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-toaster" [ngClass]="'rozie-toaster--' + position()" role="region" [attr.aria-label]="rozieAttr(regionLabel())" #rozieSpread_0 (mouseenter)="onMouseEnter()" (mouseleave)="onMouseLeave()" #rozieListenersTarget_1>
@for (t of toasts(); track t.id) {
<div class="rozie-toast" [ngClass]="'rozie-toast--' + t.type" role="status" [attr.aria-live]="rozieAttr(liveFor(t.type))">
@if ((toastTpl ?? templates()?.['toast'])) {
<ng-container *ngTemplateOutlet="(toastTpl ?? templates()?.['toast']); context: { $implicit: { toast: t, dismiss: dismiss }, toast: t, dismiss: dismiss }" />
} @else {
<span class="rozie-toast-message">{{ rozieDisplay(t.message) }}</span>
<button type="button" class="rozie-toast-close" aria-label="Dismiss" (click)="dismiss(t.id)">×</button>
}
</div>
}
</div>
`,
styles: [`
.rozie-toaster {
position: fixed;
z-index: var(--rozie-toast-z, 9999);
display: flex;
flex-direction: column;
gap: var(--rozie-toast-gap, 0.5rem);
padding: var(--rozie-toast-region-padding, 1rem);
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
pointer-events: none;
font: var(--rozie-toast-font, inherit);
}
.rozie-toaster > * {
pointer-events: auto;
}
.rozie-toaster--top-left { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toast {
display: flex;
align-items: center;
gap: var(--rozie-toast-content-gap, 0.75rem);
min-width: var(--rozie-toast-min-width, 16rem);
max-width: var(--rozie-toast-toast-max-width, 24rem);
padding: var(--rozie-toast-padding, 0.75rem 1rem);
color: var(--rozie-toast-color, #fff);
background: var(--rozie-toast-bg, #333);
border-radius: var(--rozie-toast-radius, 0.5rem);
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
}
.rozie-toast--success { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
.rozie-toast-message {
flex: 1 1 auto;
font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-toast-close-size, 1.25rem);
height: var(--rozie-toast-close-size, 1.25rem);
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: inherit;
background: transparent;
border: none;
border-radius: 0.25rem;
opacity: var(--rozie-toast-close-opacity, 0.75);
cursor: pointer;
}
.rozie-toast-close:hover {
opacity: 1;
}
`],
})
export class Toaster {
/**
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
*/
position = input<string>('bottom-right');
/**
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
*/
duration = input<number>(4000);
/**
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
*/
max = input<number>(0);
/**
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
*/
disablePauseOnHover = input<boolean>(false);
/**
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
*/
ariaLabel = input<(string) | null>(null);
toasts = signal<any[]>([]);
seq = signal(0);
@ContentChild('toast', { read: TemplateRef }) toastTpl?: TemplateRef<ToastCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
constructor() {
inject(DestroyRef).onDestroy(() => {
this.pauseTimers();
});
}
timers = {};
startTimer = (toast: any) => {
if (!toast || !toast.duration || toast.duration <= 0) return;
if (typeof window === 'undefined') return;
this.timers[toast.id] = window.setTimeout(() => this.dismiss(toast.id), toast.duration);
};
clearTimer = (id: any) => {
if (this.timers[id] && typeof window !== 'undefined') window.clearTimeout(this.timers[id]);
delete this.timers[id];
};
pauseTimers = () => {
if (typeof window === 'undefined') return;
for (const k in this.timers) window.clearTimeout(this.timers[k]);
this.timers = {};
};
show = (input: any) => {
const t = input || {};
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id;
if (t.id != null) {
id = t.id;
} else {
const s = this.seq();
id = 't' + s;
this.seq.set(s + 1);
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : this.duration()
};
const next = this.toasts().concat([toast]);
const max = this.max();
this.toasts.set(max > 0 && next.length > max ? next.slice(next.length - max) : next);
this.startTimer(toast);
return id;
};
dismiss = (id: any) => {
this.clearTimer(id);
this.toasts.set(this.toasts().filter((t: any) => t.id !== id));
};
clear = () => {
this.pauseTimers();
this.toasts.set([]);
};
onMouseEnter = () => {
if (this.disablePauseOnHover()) return;
this.pauseTimers();
};
onMouseLeave = () => {
if (this.disablePauseOnHover()) return;
for (const t of this.toasts() as any) this.startTimer(t);
};
regionLabel = () => this.ariaLabel() != null ? this.ariaLabel() : 'Notifications';
liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';
static ngTemplateContextGuard(
_dir: Toaster,
_ctx: unknown,
): _ctx is ToastCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Toaster;tsx
import type { JSX } from 'solid-js';
import { For, createSignal, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, mergeListeners, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
__rozieInjectStyle('Toaster-12d4265c', `.rozie-toaster[data-rozie-s-12d4265c] {
position: fixed;
z-index: var(--rozie-toast-z, 9999);
display: flex;
flex-direction: column;
gap: var(--rozie-toast-gap, 0.5rem);
padding: var(--rozie-toast-region-padding, 1rem);
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
pointer-events: none;
font: var(--rozie-toast-font, inherit);
}
.rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
pointer-events: auto;
}
.rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toast[data-rozie-s-12d4265c] {
display: flex;
align-items: center;
gap: var(--rozie-toast-content-gap, 0.75rem);
min-width: var(--rozie-toast-min-width, 16rem);
max-width: var(--rozie-toast-toast-max-width, 24rem);
padding: var(--rozie-toast-padding, 0.75rem 1rem);
color: var(--rozie-toast-color, #fff);
background: var(--rozie-toast-bg, #333);
border-radius: var(--rozie-toast-radius, 0.5rem);
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
}
.rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
.rozie-toast-message[data-rozie-s-12d4265c] {
flex: 1 1 auto;
font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close[data-rozie-s-12d4265c] {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-toast-close-size, 1.25rem);
height: var(--rozie-toast-close-size, 1.25rem);
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: inherit;
background: transparent;
border: none;
border-radius: 0.25rem;
opacity: var(--rozie-toast-close-opacity, 0.75);
cursor: pointer;
}
.rozie-toast-close[data-rozie-s-12d4265c]:hover {
opacity: 1;
}`);
interface ToastSlotCtx { toast: any; dismiss: any; }
interface ToasterProps {
/**
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
*/
position?: string;
/**
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
*/
duration?: number;
/**
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
*/
max?: number;
/**
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
*/
disablePauseOnHover?: boolean;
/**
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
*/
ariaLabel?: (string) | null;
toastSlot?: (ctx: ToastSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: ToasterHandle) => void;
}
export interface ToasterHandle {
show: (...args: any[]) => any;
dismiss: (...args: any[]) => any;
clear: (...args: any[]) => any;
}
export default function Toaster(_props: ToasterProps): JSX.Element {
const _merged = mergeProps({ position: 'bottom-right', duration: 4000, max: 0, disablePauseOnHover: false, ariaLabel: null }, _props);
const [local, attrs] = splitProps(_merged, ['position', 'duration', 'max', 'disablePauseOnHover', 'ariaLabel', 'ref']);
onMount(() => { local.ref?.({ show, dismiss, clear }); });
const [toasts, setToasts] = createSignal<any[]>([]);
const [seq, setSeq] = createSignal(0);
onCleanup(() => {
pauseTimers();
});
// Mutable cross-render scratch (NOT reactive): the per-id timeout handles. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
let timers = {};
// ---- timers ------------------------------------------------------------
function startTimer(toast: any) {
if (!toast || !toast.duration || toast.duration <= 0) return;
if (typeof window === 'undefined') return;
timers[toast.id] = window.setTimeout(() => dismiss(toast.id), toast.duration);
}
function clearTimer(id: any) {
if (timers[id] && typeof window !== 'undefined') window.clearTimeout(timers[id]);
delete timers[id];
}
function pauseTimers() {
if (typeof window === 'undefined') return;
for (const k in timers) window.clearTimeout(timers[k]);
timers = {};
}
// ---- queue (imperative handle implementations) -------------------------
function show(input: any) {
const t = input || {};
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id;
if (t.id != null) {
id = t.id;
} else {
const s = seq();
id = 't' + s;
setSeq(s + 1);
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : local.duration
};
const next = toasts().concat([toast]);
const max$local = local.max;
setToasts(max$local > 0 && next.length > max$local ? next.slice(next.length - max$local) : next);
startTimer(toast);
return id;
}
function dismiss(id: any) {
clearTimer(id);
setToasts(toasts().filter((t: any) => t.id !== id));
}
function clear() {
pauseTimers();
setToasts([]);
}
// ---- hover pause -------------------------------------------------------
function onMouseEnter() {
if (local.disablePauseOnHover) return;
pauseTimers();
}
function onMouseLeave() {
if (local.disablePauseOnHover) return;
for (const t of toasts() as any) startTimer(t);
}
// ---- helpers -----------------------------------------------------------
function regionLabel() {
return local.ariaLabel != null ? local.ariaLabel : 'Notifications';
}
function liveFor(type: any) {
return type === 'error' || type === 'warning' ? 'assertive' : 'polite';
}
// ---- lifecycle + handle ------------------------------------------------
return (
<>
<div role="region" aria-label={rozieAttr(regionLabel())} {...attrs} class={"rozie-toaster" + " " + rozieClass('rozie-toaster--' + local.position) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onMouseEnter: ($event) => { onMouseEnter(); }, onMouseLeave: ($event) => { onMouseLeave(); } }, attrs)} data-rozie-s-12d4265c="">
<For each={toasts()}>{(t) => <div class={"rozie-toast" + " " + rozieClass('rozie-toast--' + t.type)} role="status" aria-live={rozieAttr(liveFor(t.type))} data-rozie-s-12d4265c="">
{(_props.toastSlot ?? _props.slots?.['toast'])?.({ toast: t, dismiss }) ?? <><span class={"rozie-toast-message"} data-rozie-s-12d4265c="">{rozieDisplay(t.message)}</span><button type="button" aria-label="Dismiss" class={"rozie-toast-close"} onClick={($event) => { dismiss(t.id); }} data-rozie-s-12d4265c="">×</button></>}
</div>}</For>
</div>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { rozieAttr, rozieClass, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
interface RozieToastSlotCtx {
toast: unknown;
dismiss: unknown;
}
@customElement('rozie-toaster')
export default class Toaster extends SignalWatcher(LitElement) {
static styles = css`
.rozie-toaster[data-rozie-s-12d4265c] {
position: fixed;
z-index: var(--rozie-toast-z, 9999);
display: flex;
flex-direction: column;
gap: var(--rozie-toast-gap, 0.5rem);
padding: var(--rozie-toast-region-padding, 1rem);
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
pointer-events: none;
font: var(--rozie-toast-font, inherit);
}
.rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
pointer-events: auto;
}
.rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toast[data-rozie-s-12d4265c] {
display: flex;
align-items: center;
gap: var(--rozie-toast-content-gap, 0.75rem);
min-width: var(--rozie-toast-min-width, 16rem);
max-width: var(--rozie-toast-toast-max-width, 24rem);
padding: var(--rozie-toast-padding, 0.75rem 1rem);
color: var(--rozie-toast-color, #fff);
background: var(--rozie-toast-bg, #333);
border-radius: var(--rozie-toast-radius, 0.5rem);
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
}
.rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
.rozie-toast-message[data-rozie-s-12d4265c] {
flex: 1 1 auto;
font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close[data-rozie-s-12d4265c] {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-toast-close-size, 1.25rem);
height: var(--rozie-toast-close-size, 1.25rem);
padding: 0;
font-size: 1.1rem;
line-height: 1;
color: inherit;
background: transparent;
border: none;
border-radius: 0.25rem;
opacity: var(--rozie-toast-close-opacity, 0.75);
cursor: pointer;
}
.rozie-toast-close[data-rozie-s-12d4265c]:hover {
opacity: 1;
}
`;
/**
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
*/
@property({ type: String, reflect: true }) position: string = 'bottom-right';
/**
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
*/
@property({ type: Number, reflect: true }) duration: number = 4000;
/**
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
*/
@property({ type: Number, reflect: true }) max: number = 0;
/**
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
*/
@property({ type: Boolean, reflect: true }) disablePauseOnHover: boolean = false;
/**
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
*/
@property({ type: String, reflect: true }) ariaLabel: string | null = null;
private _toasts = signal<any[]>([]);
private _seq = signal(0);
@state() private _hasSlotToast = false;
@queryAssignedElements({ slot: 'toast', flatten: true }) private _slotToastElements!: Element[];
@property({ attribute: false }) toast?: (scope: { toast: unknown; dismiss: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="toast"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotToast = this._slotToastElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotToast = Array.from(this.children).some((el) => el.getAttribute('slot') === 'toast');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
() => {
this.pauseTimers();
};
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
render() {
return html`
<div class="rozie-toaster ${(rozieClass('rozie-toaster--' + this.position))}" role="region" aria-label=${rozieAttr(this.regionLabel())} ${rozieSpread(this.$attrs)} @mouseenter=${($event: Event) => { this.onMouseEnter(); }} @mouseleave=${($event: Event) => { this.onMouseLeave(); }} ${rozieListeners(this.$listeners)} data-rozie-s-12d4265c>
${repeat<any>(this._toasts.value, (t, _idx) => t.id, (t, _idx) => html`<div class="rozie-toast ${(rozieClass('rozie-toast--' + t.type))}" key=${rozieAttr(t.id)} role="status" aria-live=${rozieAttr(this.liveFor(t.type))} data-rozie-s-12d4265c>
${this.toast !== undefined ? this.toast({toast: t, dismiss: this.dismiss}) : html`<slot name="toast" data-rozie-params=${(() => { try { return JSON.stringify({toast: t}); } catch { return '{}'; } })()} @rozie-toast-dismiss=${($event: CustomEvent) => ((this.dismiss) as (...args: any[]) => any)($event.detail)}>
<span class="rozie-toast-message" data-rozie-s-12d4265c>${rozieDisplay(t.message)}</span>
<button class="rozie-toast-close" type="button" aria-label="Dismiss" @click=${($event: Event) => { this.dismiss(t.id); }} data-rozie-s-12d4265c>×</button>
</slot>`}
</div>`)}
</div>
`;
}
timers = {};
startTimer = (toast: any) => {
if (!toast || !toast.duration || toast.duration <= 0) return;
if (typeof window === 'undefined') return;
this.timers[toast.id] = window.setTimeout(() => this.dismiss(toast.id), toast.duration);
};
clearTimer = (id: any) => {
if (this.timers[id] && typeof window !== 'undefined') window.clearTimeout(this.timers[id]);
delete this.timers[id];
};
pauseTimers = () => {
if (typeof window === 'undefined') return;
for (const k in this.timers) window.clearTimeout(this.timers[k]);
this.timers = {};
};
show = (input: any) => {
const t = input || {};
// Derive the id from the reactive $data.seq counter (persists on React, unlike
// a module-let referenced only here). Read seq into a local BEFORE writing it
// back (no read-after-write of the same key in one fn → ROZ138-safe).
let id;
if (t.id != null) {
id = t.id;
} else {
const s = this._seq.value;
id = 't' + s;
this._seq.value = s + 1;
}
const toast = {
id,
message: t.message != null ? t.message : '',
type: t.type || 'info',
duration: t.duration != null ? t.duration : this.duration
};
const next = this._toasts.value.concat([toast]);
const max = this.max;
this._toasts.value = max > 0 && next.length > max ? next.slice(next.length - max) : next;
this.startTimer(toast);
return id;
};
dismiss = (id: any) => {
this.clearTimer(id);
this._toasts.value = this._toasts.value.filter((t: any) => t.id !== id);
};
clear = () => {
this.pauseTimers();
this._toasts.value = [];
};
onMouseEnter = () => {
if (this.disablePauseOnHover) return;
this.pauseTimers();
};
onMouseLeave = () => {
if (this.disablePauseOnHover) return;
for (const t of this._toasts.value as any) this.startTimer(t);
};
regionLabel = () => this.ariaLabel != null ? this.ariaLabel : 'Notifications';
liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['position', 'duration', 'max', 'disable-pause-on-hover', 'disablepauseonhover', 'aria-label', 'arialabel']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup>, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same show / dismiss / clear handle, same #toast scoped slot — all from the one source above, with no third-party engine behind it.
See also
- Toaster — showcase & API — install, quick start, theming, and the full reference.
- Headless toast / notification comparison — how
@rozie-ui/toaststacks up against sonner, react-hot-toast, vue-toastification, ngx-toastr, and the Angular CDK.