├── src ├── routes │ ├── +layout.ts │ ├── +layout.svelte │ └── +page.svelte ├── lib │ ├── CodeBlock │ │ ├── index.ts │ │ ├── CodeBlock.svelte │ │ └── Copy.svelte │ ├── usage-code.svelte │ ├── RichContent.svelte │ ├── Examples.svelte │ ├── ChangePositionExamples.svelte │ └── examples.ts ├── svelte-hot-french-toast │ ├── components │ │ ├── icons │ │ │ ├── index.ts │ │ │ ├── IconLoading.svelte │ │ │ ├── IconSuccess.svelte │ │ │ ├── IconError.svelte │ │ │ ├── IconInfo.svelte │ │ │ └── IconWarning.svelte │ │ ├── ToastMessage.svelte │ │ ├── Toaster.svelte │ │ ├── ToastIcon.svelte │ │ ├── ToastWrapper.svelte │ │ └── ToastBar.svelte │ ├── core │ │ ├── utils.ts │ │ ├── useToaster.svelte.ts │ │ ├── types.ts │ │ ├── toast.ts │ │ └── state.svelte.ts │ └── index.ts ├── app.css └── app.html ├── .npmrc ├── pnpm-workspace.yaml ├── header-image.png ├── static ├── favicon.png ├── og-image.png └── fonts │ ├── jost-latin-wght-normal.woff2 │ └── jetbrains-mono-latin-wght-normal.woff2 ├── .gitignore ├── README-DEV.md ├── vite.config.js ├── tsconfig.json ├── svelte.config.js ├── prettier.config.js ├── eslint.config.js ├── LICENSE ├── CHANGELOG.md ├── README.md └── package.json /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true 2 | -------------------------------------------------------------------------------- /src/lib/CodeBlock/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CodeBlock } from "./CodeBlock.svelte" 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | package-lock=false 3 | prefer-offline=true 4 | save-exact=true 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - "@tailwindcss/oxide" 3 | - esbuild 4 | -------------------------------------------------------------------------------- /header-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babakfp/svelte-hot-french-toast/HEAD/header-image.png -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babakfp/svelte-hot-french-toast/HEAD/static/favicon.png -------------------------------------------------------------------------------- /static/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babakfp/svelte-hot-french-toast/HEAD/static/og-image.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | /.svelte-kit/ 4 | /dist/ 5 | /build/ 6 | /vite.config.*.timestamp-* 7 | -------------------------------------------------------------------------------- /static/fonts/jost-latin-wght-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babakfp/svelte-hot-french-toast/HEAD/static/fonts/jost-latin-wght-normal.woff2 -------------------------------------------------------------------------------- /static/fonts/jetbrains-mono-latin-wght-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/babakfp/svelte-hot-french-toast/HEAD/static/fonts/jetbrains-mono-latin-wght-normal.woff2 -------------------------------------------------------------------------------- /README-DEV.md: -------------------------------------------------------------------------------- 1 | Up to date with [Svelte French Toast](https://github.com/kbrgl/svelte-french-toast) until Jun 26, 2025 commit. SHA: `ac3a4dfb03735b62cf08615da48f93cb7a80647a`. 2 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from "@sveltejs/kit/vite" 2 | import tailwindcss from "@tailwindcss/vite" 3 | import { defineConfig } from "vite" 4 | 5 | export default defineConfig({ 6 | plugins: [tailwindcss(), sveltekit()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/lib/usage-code.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as IconError } from "./IconError.svelte" 2 | export { default as IconInfo } from "./IconInfo.svelte" 3 | export { default as IconLoading } from "./IconLoading.svelte" 4 | export { default as IconSuccess } from "./IconSuccess.svelte" 5 | export { default as IconWarning } from "./IconWarning.svelte" 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/RichContent.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | Custom and {myText} 15 | 16 | 17 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-static" 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" 3 | 4 | /** @type {import("@sveltejs/kit").Config} */ 5 | export default { 6 | kit: { 7 | adapter: adapter(), 8 | alias: { 9 | "svelte-hot-french-toast": "src/svelte-hot-french-toast/index.ts", 10 | }, 11 | }, 12 | preprocess: vitePreprocess(), 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/CodeBlock/CodeBlock.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {#await htmlPromise} 11 |

Loading...

12 | {:then html} 13 |
14 | {@html html} 15 | 16 |
17 | {:catch error} 18 |

{error}

19 | {/await} 20 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/core/utils.ts: -------------------------------------------------------------------------------- 1 | export const genId = (() => { 2 | let count = 0 3 | 4 | return () => { 5 | count += 1 6 | return count.toString() 7 | } 8 | })() 9 | 10 | export const prefersReducedMotion = (() => { 11 | // Cache result 12 | let shouldReduceMotion: boolean | undefined 13 | 14 | return () => { 15 | if (shouldReduceMotion === undefined && typeof window !== "undefined") { 16 | const mediaQuery = matchMedia("(prefers-reduced-motion: reduce)") 17 | shouldReduceMotion = !mediaQuery || mediaQuery.matches 18 | } 19 | return shouldReduceMotion 20 | } 21 | })() 22 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config & import("prettier-plugin-svelte").PluginConfig & import("@ianvs/prettier-plugin-sort-imports").PluginConfig} */ 2 | export default { 3 | semi: false, 4 | tabWidth: 4, 5 | htmlWhitespaceSensitivity: "ignore", 6 | experimentalTernaries: true, 7 | experimentalOperatorPosition: "start", 8 | plugins: [ 9 | "prettier-plugin-svelte", 10 | "@ianvs/prettier-plugin-sort-imports", 11 | "prettier-plugin-tailwindcss", 12 | ], 13 | importOrder: [ 14 | "^@", 15 | "", 16 | "^\\$(?!lib/)", 17 | "^\\$lib/", 18 | "^[.]", 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/ToastMessage.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {#if typeof toast.message === "string"} 13 | {toast.message} 14 | {:else} 15 | 16 | {/if} 17 |
18 | 19 | 29 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/index.ts: -------------------------------------------------------------------------------- 1 | import toast from "./core/toast" 2 | 3 | export type { 4 | ToastOptions, 5 | ToastPosition, 6 | Toast, 7 | Renderable, 8 | ValueOrFunction, 9 | DefaultToastOptions, 10 | PromiseToastOptions, 11 | IconTheme, 12 | ToastType, 13 | ValueFunction, 14 | } from "./core/types" 15 | 16 | export { default as useToaster } from "./core/useToaster.svelte" 17 | export { useToasterState } from "./core/state.svelte" 18 | export { default as ToastBar } from "./components/ToastBar.svelte" 19 | export { default as ToastIcon } from "./components/ToastIcon.svelte" 20 | export { default as Toaster } from "./components/Toaster.svelte" 21 | export * from "./components/icons" 22 | export { resolveValue } from "./core/types" 23 | 24 | export { toast } 25 | export default toast 26 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/icons/IconLoading.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 | 30 | -------------------------------------------------------------------------------- /src/lib/CodeBlock/Copy.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js" 2 | import prettier from "eslint-config-prettier" 3 | import svelte from "eslint-plugin-svelte" 4 | import globals from "globals" 5 | import ts from "typescript-eslint" 6 | 7 | export default ts.config( 8 | js.configs.recommended, 9 | ...ts.configs.recommended, 10 | ...svelte.configs["flat/recommended"], 11 | prettier, 12 | ...svelte.configs["flat/prettier"], 13 | { 14 | languageOptions: { 15 | globals: { 16 | ...globals.browser, 17 | ...globals.node, 18 | }, 19 | }, 20 | rules: { 21 | "svelte/no-at-html-tags": "off", 22 | }, 23 | }, 24 | { 25 | files: ["**/*.svelte"], 26 | languageOptions: { 27 | parserOptions: { 28 | parser: ts.parser, 29 | }, 30 | }, 31 | }, 32 | { 33 | ignores: ["build/", ".svelte-kit/", "dist/"], 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 babakfp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 4.0.0 2 | 3 | - fix!: change `keepAfterSesolutionOrRejection` to `keepAfterResolutionOrRejection`. 4 | 5 | ## 3.1.0 6 | 7 | - feat: add `keepAfterSesolutionOrRejection` option to `toast.promise()`. 8 | 9 | ## 3.0.0 10 | 11 | ### Breaking 12 | 13 | - fix: [#1](https://github.com/kbrgl/svelte-french-toast/issues/1) by adding `svelte-hot-french-toast__` prefix to all classes. 14 | 15 | ## 2.0.0 16 | 17 | ### Breaking 18 | 19 | - Rename `useToasterStore` to `useToasterState`. 20 | 21 | ### Features 22 | 23 | - Add Warning and Info Toasts. https://github.com/kbrgl/svelte-french-toast/issues/92. 24 | 25 | ### Fixes 26 | 27 | - When toast content height changes, change toast height accordingly. https://github.com/kbrgl/svelte-french-toast/pull/75. 28 | 29 | ## 1.0.0 30 | 31 | Migration from [Svelte French Toast](https://github.com/kbrgl/svelte-french-toast): 32 | 33 | - [breaking]: Svelte 5 only. 34 | - [breaking]: Uses runes instead of stores. 35 | - [breaking]: `className` prop renamed to `class`. 36 | - [breaking]: `containerClassName` prop renamed to `containerClass`. 37 | - [breaking]: Rename `startPause` to `pause` and `endPause` to `resume`. 38 | - [breaking]: Removed non-logical positions: 39 | - `"top-left"` 40 | - `"top-right"` 41 | - `"bottom-left"` 42 | - `"bottom-right"` 43 | - [breaking]: Converted `interface` to `type`. 44 | - [breaking]: Possibly some other breaking changes. 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Svelte Hot French Toast 4 | 5 | Buttery smooth toast notifications for Svelte 5. Lightweight, customizable, and beautiful by default. 6 | 7 | - [Demo](https://svelte-hot-french-toast.vercel.app) 8 | - [CHANGELOG](https://github.com/babakfp/svelte-hot-french-toast/blob/main/CHANGELOG.md) 9 | - [NPM](https://www.npmjs.com/package/svelte-hot-french-toast) 10 | - [GitHub](https://github.com/babakfp/svelte-hot-french-toast) 11 | 12 | > [!IMPORTANT] 13 | > This is only compatible with Svelte 5. If you're using Svelte 4, please use a compatible version of [Svelte French Toast](https://github.com/kbrgl/svelte-french-toast). 14 | 15 | ## Installation 16 | 17 | ```bash 18 | pnpm add -D svelte-hot-french-toast 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```svelte 24 | 31 | 32 | 33 | 34 | 35 | ``` 36 | 37 | [More examples](https://svelte-hot-french-toast.vercel.app). 38 | 39 | ## About 40 | 41 | I created this because the original project [Svelte French Toast](https://github.com/kbrgl/svelte-french-toast) wasn't compatible with Svelte 5 and my changes were possibly not going to be merged. 42 | 43 | ### Thanks 44 | 45 | - Thanks to [React Hot Toast](https://github.com/timolins/react-hot-toast) and its contributors. 46 | - Thanks to [Svelte French Toast](https://github.com/kbrgl/svelte-french-toast) and its contributors. 47 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | Svelte Hot French Toast 9 | 10 | 14 | 15 | 16 | 17 | 21 | 22 | 26 | 30 | 31 | 32 | 33 | 37 | 38 | 42 | 46 | 47 | 48 | {@render children?.()} 49 | -------------------------------------------------------------------------------- /src/lib/Examples.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | {#each examples as example (example.title)} 10 | 31 | {/each} 32 |
33 | {#each examples as example} 34 |
35 | {#if Array.isArray(example.snippet)} 36 | {#each example.snippet as snippet} 37 |
38 | 39 |
40 | {/each} 41 | {:else} 42 | 46 | {/if} 47 |
48 | {/each} 49 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tailwindcss-addons"; 3 | 4 | @theme { 5 | /* --color-black: initial; */ 6 | /* --color-white: initial; */ 7 | --color-zinc-*: initial; 8 | --color-slate-*: initial; 9 | --color-stone-*: initial; 10 | --color-neutral-*: initial; 11 | 12 | --color-gray-50: oklch(0.985 0 0); /* --color-neutral-50 */ 13 | --color-gray-100: oklch(0.97 0 0); /* --color-neutral-100 */ 14 | --color-gray-200: oklch(0.922 0 0); /* --color-neutral-200 */ 15 | --color-gray-300: oklch(0.87 0 0); /* --color-neutral-300 */ 16 | --color-gray-400: oklch(0.708 0 0); /* --color-neutral-400 */ 17 | --color-gray-500: oklch(0.556 0 0); /* --color-neutral-500 */ 18 | --color-gray-600: oklch(0.439 0 0); /* --color-neutral-600 */ 19 | --color-gray-700: oklch(0.371 0 0); /* --color-neutral-700 */ 20 | --color-gray-800: oklch(0.269 0 0); /* --color-neutral-800 */ 21 | --color-gray-900: oklch(0.205 0 0); /* --color-neutral-900 */ 22 | --color-gray-950: oklch(0.145 0 0); /* --color-neutral-950 */ 23 | 24 | --color-background: oklch(0.97 0 0); /* --color-neutral-100 */ 25 | --color-foreground: oklch(0.205 0 0); /* --color-neutral-900 */ 26 | 27 | --radius-lg: initial; 28 | --radius: 0.5rem; /* --radius-lg */ 29 | 30 | --font-sans: "Jost Variable"; 31 | --font-mono: "JetBrains Mono Variable"; 32 | --font-mono--font-variation-settings: "MONO" 1; 33 | } 34 | 35 | @utility container { 36 | @apply mx-auto max-w-screen-xl px-4; 37 | } 38 | 39 | @layer base { 40 | * { 41 | @apply drag-none scrollbar-none [outline:transparent]; 42 | } 43 | } 44 | 45 | pre { 46 | @apply -mx-4 block overflow-x-auto p-4 select-text selection:bg-white/10 sm:mx-0 sm:rounded-xl sm:p-6; 47 | } 48 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/icons/IconSuccess.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 | 67 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/Toaster.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | 60 | 61 | 71 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/icons/IconError.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 | 77 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 21 | 45 | %sveltekit.head% 46 | 47 | 48 |
%sveltekit.body%
49 | 50 | 51 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/ToastIcon.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if typeof Icon === "string"} 23 |
{Icon}
24 | {:else if typeof Icon !== "undefined"} 25 | 26 | {:else if type !== "blank"} 27 |
28 | 29 | {#if type !== "loading"} 30 |
31 | {#if type === "error"} 32 | 33 | {:else if type === "warning"} 34 | 35 | {:else if type === "info"} 36 | 37 | {:else} 38 | 39 | {/if} 40 |
41 | {/if} 42 |
43 | {/if} 44 | 45 | 79 | -------------------------------------------------------------------------------- /src/lib/ChangePositionExamples.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
42 | {#each positions as position} 43 | 63 | {/each} 64 |
65 | 66 | {#each positions as position} 67 | {@const code = example.snippet(position)} 68 |
69 | 70 |
71 | {/each} 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-hot-french-toast", 3 | "version": "4.0.0", 4 | "description": "Buttery smooth toast notifications for Svelte 5. Lightweight, customizable, and beautiful by default.", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build && pnpm package", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check", 11 | "check:watch": "svelte-kit sync && svelte-check --watch", 12 | "format": "prettier -w .", 13 | "lint": "prettier -c . && eslint .", 14 | "package": "svelte-kit sync && svelte-package -i src/svelte-hot-french-toast && publint", 15 | "prepare": "svelte-kit sync", 16 | "prepublishOnly": "pnpm package" 17 | }, 18 | "peerDependencies": { 19 | "svelte": "^5.0.0" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "9.33.0", 23 | "@ianvs/prettier-plugin-sort-imports": "4.6.1", 24 | "@sveltejs/adapter-static": "3.0.9", 25 | "@sveltejs/kit": "2.27.3", 26 | "@sveltejs/package": "2.4.1", 27 | "@sveltejs/vite-plugin-svelte": "6.1.1", 28 | "@tailwindcss/vite": "4.1.11", 29 | "@typescript-eslint/eslint-plugin": "8.39.0", 30 | "@typescript-eslint/parser": "8.39.0", 31 | "eslint": "9.33.0", 32 | "eslint-config-prettier": "10.1.8", 33 | "eslint-plugin-svelte": "3.11.0", 34 | "globals": "16.3.0", 35 | "phosphor-icons-svelte": "2.0.9", 36 | "prettier": "3.6.2", 37 | "prettier-plugin-svelte": "3.4.0", 38 | "prettier-plugin-tailwindcss": "0.6.14", 39 | "publint": "0.3.12", 40 | "shiki": "3.9.2", 41 | "svelte": "5.38.0", 42 | "svelte-check": "4.3.1", 43 | "tailwindcss": "4.1.11", 44 | "tailwindcss-addons": "4.2.0", 45 | "tslib": "2.8.1", 46 | "typescript": "5.9.2", 47 | "typescript-eslint": "8.39.0", 48 | "vite": "7.1.1" 49 | }, 50 | "repository": "github:babakfp/svelte-hot-french-toast", 51 | "files": [ 52 | "dist", 53 | "header-image.png" 54 | ], 55 | "exports": { 56 | ".": { 57 | "types": "./dist/index.d.ts", 58 | "svelte": "./dist/index.js" 59 | } 60 | }, 61 | "keywords": [ 62 | "svelte", 63 | "notifications", 64 | "toast", 65 | "snackbar" 66 | ], 67 | "type": "module" 68 | } 69 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/icons/IconInfo.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | 10 | 11 |
12 | 13 | 78 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/core/useToaster.svelte.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pause as _pause, 3 | resume as _resume, 4 | update, 5 | useToasterState, 6 | } from "./state.svelte" 7 | import toast from "./toast" 8 | import type { Toast, ToastOptions, ToastPosition } from "./types" 9 | 10 | const calculateOffset = ( 11 | toast: Toast, 12 | toasts: Toast[], 13 | opts?: { 14 | reverseOrder?: boolean 15 | gutter?: number 16 | defaultPosition?: ToastPosition 17 | }, 18 | ) => { 19 | const { reverseOrder, gutter = 8, defaultPosition } = opts || {} 20 | 21 | const relevantToasts = toasts.filter( 22 | (t) => 23 | (t.position || defaultPosition) 24 | === (toast.position || defaultPosition) && t.height, 25 | ) 26 | const toastIndex = relevantToasts.findIndex((t) => t.id === toast.id) 27 | const toastsBefore = relevantToasts.filter( 28 | (toast, i) => i < toastIndex && toast.visible, 29 | ).length 30 | 31 | const offset = relevantToasts 32 | .filter((t) => t.visible) 33 | .slice(...(reverseOrder ? [toastsBefore + 1] : [0, toastsBefore])) 34 | .reduce((acc, t) => acc + (t.height || 0) + gutter, 0) 35 | 36 | return offset 37 | } 38 | 39 | const handlers = { 40 | pause() { 41 | _pause(Date.now()) 42 | }, 43 | resume() { 44 | _resume(Date.now()) 45 | }, 46 | updateHeight: (toastId: string, height: number) => { 47 | update({ id: toastId, height }, false) 48 | }, 49 | calculateOffset, 50 | } 51 | 52 | export default (toastOptions?: ToastOptions) => { 53 | const toaster = useToasterState(toastOptions) 54 | const timeouts = new Map>() 55 | 56 | $effect(() => { 57 | if (toaster.pausedAt) { 58 | timeouts.values().forEach((id) => clearTimeout(id)) 59 | timeouts.clear() 60 | } else { 61 | const now = Date.now() 62 | 63 | for (const t of toaster.toasts) { 64 | if (timeouts.has(t.id) || t.duration === Infinity) { 65 | continue 66 | } 67 | 68 | const timeSinceCreated = now - t.createdAt 69 | const totalDuration = (t.duration || 0) + t.pauseDuration 70 | const timeLeft = totalDuration - timeSinceCreated 71 | const hasLifeTime = timeLeft > 0 72 | 73 | if (hasLifeTime) { 74 | const timeout = setTimeout( 75 | () => toast.dismiss(t.id), 76 | timeLeft, 77 | ) 78 | timeouts.set(t.id, timeout) 79 | } else { 80 | if (t.visible) { 81 | toast.dismiss(t.id) 82 | } 83 | } 84 | } 85 | } 86 | }) 87 | 88 | return { 89 | get toasts() { 90 | return toaster.toasts 91 | }, 92 | handlers, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/ToastWrapper.svelte: -------------------------------------------------------------------------------- 1 | 53 | 54 |
65 | {#if toast.type === "custom"} 66 | 67 | {:else if children} 68 | {@render children({ toast })} 69 | {:else} 70 | 71 | {/if} 72 |
73 | 74 | 94 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from "svelte" 2 | 3 | export type ToastType = 4 | | "success" 5 | | "error" 6 | | "info" 7 | | "loading" 8 | | "blank" 9 | | "custom" 10 | | "warning" 11 | 12 | export type ToastPosition = 13 | | "top-start" 14 | | "top-center" 15 | | "top-end" 16 | | "bottom-start" 17 | | "bottom-center" 18 | | "bottom-end" 19 | 20 | export type Renderable< 21 | T extends Record = Record, 22 | > = Component | string | undefined 23 | 24 | export type IconTheme = { 25 | primary: string 26 | secondary: string 27 | } 28 | 29 | export type ValueFunction = (arg: TArg) => TValue 30 | export type ValueOrFunction = TValue | ValueFunction 31 | 32 | const isFunction = ( 33 | valOrFunction: ValueOrFunction, 34 | ): valOrFunction is ValueFunction => 35 | typeof valOrFunction === "function" 36 | 37 | export const resolveValue = ( 38 | valOrFunction: ValueOrFunction, 39 | arg: TArg, 40 | ): TValue => (isFunction(valOrFunction) ? valOrFunction(arg) : valOrFunction) 41 | 42 | export type Toast = Record> = 43 | { 44 | type: ToastType 45 | id: string 46 | message: Renderable 47 | icon?: Renderable 48 | duration?: number 49 | pauseDuration: number 50 | position?: ToastPosition 51 | 52 | // We use `Omit` here in the case that the Component has `let { toast }: { toast: Toast } = $props()`. 53 | // We are already passing the toast to the component, and it should not be included in props. 54 | props?: Omit 55 | 56 | ariaProps: { 57 | role: "status" | "alert" 58 | "aria-live": "assertive" | "off" | "polite" 59 | } 60 | 61 | style?: string 62 | class?: string 63 | iconTheme?: IconTheme 64 | 65 | createdAt: number 66 | visible: boolean 67 | height?: number 68 | } 69 | 70 | export type DOMToast< 71 | T extends Record = Record, 72 | > = Toast & { 73 | offset: number 74 | } 75 | 76 | export type ToastOptions< 77 | T extends Record = Record, 78 | > = Partial< 79 | Pick< 80 | Toast, 81 | | "id" 82 | | "icon" 83 | | "duration" 84 | | "ariaProps" 85 | | "class" 86 | | "style" 87 | | "position" 88 | | "iconTheme" 89 | | "props" 90 | > 91 | > 92 | 93 | export type DefaultToastOptions = ToastOptions & { 94 | [key in ToastType]?: ToastOptions 95 | } 96 | 97 | export type PromiseToastOptions = DefaultToastOptions & { 98 | /** 99 | * If you want to keep the toast after the promise is resolved or rejected, set this to true. 100 | * You would need to add a close button to the toast, so that the user can dismiss the toast manually. 101 | */ 102 | keepAfterResolutionOrRejection?: boolean 103 | } 104 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/icons/IconWarning.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | 10 | 11 |
12 | 13 | 104 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/core/toast.ts: -------------------------------------------------------------------------------- 1 | import { dismiss, remove, upsert } from "./state.svelte" 2 | import { 3 | resolveValue, 4 | type PromiseToastOptions, 5 | type Renderable, 6 | type Toast, 7 | type ToastOptions, 8 | type ToastType, 9 | type ValueOrFunction, 10 | } from "./types" 11 | import { genId } from "./utils" 12 | 13 | type Message = Record> = 14 | Renderable 15 | 16 | type ToastHandler = < 17 | T extends Record = Record, 18 | >( 19 | message: Message, 20 | options?: ToastOptions, 21 | ) => string 22 | 23 | const createToast = < 24 | T extends Record = Record, 25 | >( 26 | message: Message, 27 | type: ToastType = "blank", 28 | opts?: ToastOptions, 29 | ): Toast => ({ 30 | createdAt: Date.now(), 31 | visible: true, 32 | type, 33 | ariaProps: { 34 | role: "status", 35 | "aria-live": "polite", 36 | }, 37 | message, 38 | pauseDuration: 0, 39 | ...opts, 40 | id: opts?.id || genId(), 41 | }) 42 | 43 | const createHandler = (type?: ToastType): ToastHandler => { 44 | return (message, options) => { 45 | const toast = createToast(message, type, options) 46 | // @ts-expect-error: TODO: I have no idea how to fix this. 47 | upsert(toast) 48 | return toast.id 49 | } 50 | } 51 | 52 | const toast = = Record>( 53 | message: Message, 54 | opts?: ToastOptions, 55 | ) => createHandler("blank")(message, opts) 56 | 57 | toast.error = createHandler("error") 58 | toast.success = createHandler("success") 59 | toast.loading = createHandler("loading") 60 | toast.custom = createHandler("custom") 61 | toast.warning = createHandler("warning") 62 | toast.info = createHandler("info") 63 | 64 | toast.dismiss = (toastId?: string) => { 65 | dismiss(toastId) 66 | } 67 | 68 | toast.remove = (toastId?: string) => remove(toastId) 69 | 70 | toast.promise = ( 71 | promise: Promise, 72 | msgs: { 73 | loading: Renderable 74 | success: ValueOrFunction 75 | error: ValueOrFunction 76 | }, 77 | opts?: PromiseToastOptions, 78 | ) => { 79 | const id = toast.loading(msgs.loading, { ...opts, ...opts?.loading }) 80 | 81 | promise 82 | .then((p) => { 83 | toast.success(resolveValue(msgs.success, p), { 84 | id, 85 | ...opts, 86 | ...opts?.success, 87 | ...(opts?.keepAfterResolutionOrRejection ? 88 | { duration: Infinity } 89 | : {}), 90 | }) 91 | return p 92 | }) 93 | .catch((e) => { 94 | toast.error(resolveValue(msgs.error, e), { 95 | id, 96 | ...opts, 97 | ...opts?.error, 98 | ...(opts?.keepAfterResolutionOrRejection ? 99 | { duration: Infinity } 100 | : {}), 101 | }) 102 | }) 103 | 104 | return promise 105 | } 106 | 107 | export default toast 108 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/core/state.svelte.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultToastOptions, Toast, ToastType } from "./types" 2 | 3 | const TOAST_LIMIT = 20 4 | const TOAST_EXPIRE_DISMISS_DELAY = 1000 5 | 6 | type State = { 7 | toasts: Toast[] 8 | pausedAt: number 9 | } 10 | 11 | let toasts: State["toasts"] = $state([]) 12 | let pausedAt: State["pausedAt"] = $state(0) 13 | 14 | const toastTimeouts = new Map>() 15 | 16 | const addToRemoveQueue = (toastId: string) => { 17 | if (toastTimeouts.has(toastId)) { 18 | return 19 | } 20 | 21 | const timeout = setTimeout(() => { 22 | toastTimeouts.delete(toastId) 23 | remove(toastId) 24 | }, TOAST_EXPIRE_DISMISS_DELAY) 25 | 26 | toastTimeouts.set(toastId, timeout) 27 | } 28 | 29 | const clearFromRemoveQueue = (toastId: string) => { 30 | const timeout = toastTimeouts.get(toastId) 31 | if (timeout) { 32 | clearTimeout(timeout) 33 | } 34 | } 35 | 36 | export const update = (toast: Partial, clearTimeout = true) => { 37 | if (clearTimeout && toast.id) { 38 | clearFromRemoveQueue(toast.id) 39 | } 40 | toasts = toasts.map((t) => (t.id === toast.id ? { ...t, ...toast } : t)) 41 | } 42 | 43 | export const add = (toast: Toast) => { 44 | toasts.unshift(toast) 45 | toasts = toasts.slice(0, TOAST_LIMIT) 46 | } 47 | 48 | export const upsert = (toast: Toast) => { 49 | if (toasts.find((t) => t.id === toast.id)) { 50 | update(toast) 51 | } else { 52 | add(toast) 53 | } 54 | } 55 | 56 | export const dismiss = (toastId?: Toast["id"]) => { 57 | if (toastId) { 58 | addToRemoveQueue(toastId) 59 | } else { 60 | toasts.forEach((toast) => { 61 | addToRemoveQueue(toast.id) 62 | }) 63 | } 64 | 65 | toasts = toasts.map((t) => 66 | t.id === toastId || toastId === undefined ? 67 | { ...t, visible: false } 68 | : t, 69 | ) 70 | } 71 | 72 | export const remove = (toastId?: Toast["id"]) => { 73 | if (toastId === undefined) { 74 | toasts = [] 75 | } 76 | 77 | toasts = toasts.filter((t) => t.id !== toastId) 78 | } 79 | 80 | export const pause = (time: number) => { 81 | pausedAt = time 82 | } 83 | 84 | export const resume = (time: number) => { 85 | const timeSincePaused = time - pausedAt 86 | 87 | toasts = toasts.map((t) => ({ 88 | ...t, 89 | pauseDuration: t.pauseDuration + timeSincePaused, 90 | })) 91 | 92 | pausedAt = 0 93 | } 94 | 95 | const defaultTimeouts: { 96 | [key in ToastType]: number 97 | } = { 98 | blank: 4000, 99 | error: 4000, 100 | success: 2000, 101 | loading: Infinity, 102 | custom: 4000, 103 | warning: 4000, 104 | info: 4000, 105 | } 106 | 107 | export const useToasterState = ( 108 | toastOptions: DefaultToastOptions = {}, 109 | ): State => { 110 | const mergedToasts = $derived( 111 | toasts.map((t) => ({ 112 | ...toastOptions, 113 | ...toastOptions[t.type], 114 | ...t, 115 | duration: 116 | t.duration 117 | || toastOptions[t.type]?.duration 118 | || toastOptions?.duration 119 | || defaultTimeouts[t.type], 120 | style: [ 121 | toastOptions.style, 122 | toastOptions[t.type]?.style, 123 | t.style, 124 | ].join(";"), 125 | })), 126 | ) 127 | 128 | return { 129 | get toasts() { 130 | return mergedToasts 131 | }, 132 | get pausedAt() { 133 | return pausedAt 134 | }, 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/svelte-hot-french-toast/components/ToastBar.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 |
55 | {#if children} 56 | {@render children({ 57 | toast, 58 | ToastIcon, 59 | ToastMessage, 60 | })} 61 | {:else} 62 | 67 | 68 | {/if} 69 |
70 | 71 | 153 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 |
27 |
28 | 29 |
32 |
35 | Svelte 36 |
37 |
40 | Hot French 41 |
42 |
45 | Toast 46 |
47 |
48 |

49 | Buttery smooth toast notifications. 50 |

51 |

52 | Lightweight, customizable, and beautiful by default. 53 |

54 |
55 | 62 | 66 | Source 67 | 68 |
69 |
72 | {#each ["Emoji Support", "Customizable", "Promise API", "Pause on hover", "Accessible", "Headless use"] as feature} 73 |
74 | 75 |

{feature}

76 |
77 | {/each} 78 |
79 |
80 |
81 | 82 |
83 | 84 |
85 |
86 |
87 |

Installation

88 | 92 |
93 |
94 |

Usage

95 | 96 |
97 |
98 | 99 |
100 |

Examples

101 | 102 |
103 | 104 |
105 |

Change Position

106 | 107 |
108 |
109 | 110 |
111 | 112 | 122 | -------------------------------------------------------------------------------- /src/lib/examples.ts: -------------------------------------------------------------------------------- 1 | import type { BundledLanguage } from "shiki" 2 | import toast from "svelte-hot-french-toast" 3 | import RichContent from "./RichContent.svelte" 4 | import richContent from "./RichContent.svelte?raw" 5 | 6 | type Example = { 7 | title: string 8 | action: () => void 9 | emoji: string 10 | snippet: 11 | | { lang: BundledLanguage; code: string } 12 | | { lang: BundledLanguage; code: string }[] 13 | html?: boolean 14 | } 15 | 16 | export default [ 17 | { 18 | title: "Success", 19 | emoji: "✅", 20 | snippet: { 21 | lang: "js", 22 | code: `toast.success("Successfully toasted!")`, 23 | }, 24 | action: () => { 25 | toast.success("Successfully toasted!") 26 | }, 27 | }, 28 | { 29 | title: "Error", 30 | emoji: "❌", 31 | snippet: { 32 | lang: "js", 33 | code: `toast.error("This didn't work.")`, 34 | }, 35 | action: () => { 36 | toast.error("This didn't work.") 37 | }, 38 | }, 39 | { 40 | title: "Promise", 41 | emoji: "⏳", 42 | snippet: { 43 | lang: "js", 44 | code: `toast.promise( 45 | saveSettings(settings), 46 | { 47 | loading: "Saving...", 48 | success: "Settings saved!", 49 | error: "Could not save!", 50 | } 51 | )`, 52 | }, 53 | action: () => { 54 | const promise = new Promise((resolve, reject) => { 55 | setTimeout(Math.random() < 0.8 ? resolve : reject, 1000) 56 | }) 57 | 58 | toast.promise(promise, { 59 | loading: "Saving...", 60 | success: "Settings saved!", 61 | error: "Could not save!", 62 | }) 63 | }, 64 | }, 65 | { 66 | title: "Multiline", 67 | emoji: "↩️", 68 | snippet: { 69 | lang: "js", 70 | code: `toast( 71 | "This toast is super big. I don't think anyone could eat it in one bite.\\n\\nIt's larger than you expected. You eat it but it does not seem to get smaller.", 72 | { 73 | duration: 6000, 74 | } 75 | )`, 76 | }, 77 | action: () => { 78 | toast( 79 | "This toast is super big. I don't think anyone could eat it in one bite.\n\n It's larger than you expected. You eat it but it does not seem to get smaller.", 80 | { 81 | duration: 6000, 82 | }, 83 | ) 84 | }, 85 | }, 86 | { 87 | title: "Emoji", 88 | emoji: "👏", 89 | snippet: { 90 | lang: "js", 91 | code: `toast("Good Job!", { 92 | icon: "👏", 93 | })`, 94 | }, 95 | action: () => { 96 | toast("Good Job!", { 97 | icon: "👏", 98 | }) 99 | }, 100 | }, 101 | { 102 | title: "Dark mode", 103 | emoji: "🌚", 104 | snippet: { 105 | lang: "js", 106 | code: `toast("Hello Darkness!", { 107 | icon: "👏", 108 | style: "border-radius: 200px; background: #333; color: #fff;" 109 | })`, 110 | }, 111 | action: () => { 112 | toast("Hello Darkness!", { 113 | icon: "👏", 114 | style: "border-radius: 200px; background: #333; color: #fff;", 115 | }) 116 | }, 117 | }, 118 | { 119 | title: "Rich content", 120 | emoji: "🔩", 121 | snippet: [ 122 | { 123 | lang: "svelte", 124 | code: ``, 130 | }, 131 | { 132 | lang: "svelte", 133 | code: `// RichContent.svelte 134 | 135 | ${richContent}`, 136 | }, 137 | ], 138 | html: true, 139 | action: () => { 140 | toast(RichContent, { props: { myText: "bold" } }) 141 | }, 142 | }, 143 | { 144 | title: "Themed", 145 | emoji: "🎨", 146 | snippet: { 147 | lang: "js", 148 | code: `toast.success("Look at me!", { 149 | style: "border: 1px solid #713200; padding: 16px; color: #713200;", 150 | iconTheme: { 151 | primary: "#713200", 152 | secondary: "#FFFAEE" 153 | } 154 | })`, 155 | }, 156 | 157 | action: () => { 158 | toast.success("Look at me!", { 159 | style: "border: 1px solid #713200; padding: 16px; color: #713200;", 160 | iconTheme: { 161 | primary: "#713200", 162 | secondary: "#FFFAEE", 163 | }, 164 | }) 165 | }, 166 | }, 167 | { 168 | title: "Positioning", 169 | emoji: "⬆️", 170 | snippet: { 171 | lang: "js", 172 | code: `toast.success("Always at the bottom.", { 173 | position: "bottom-center" 174 | })`, 175 | }, 176 | action: () => { 177 | toast.success("Always at the bottom.", { 178 | position: "bottom-center", 179 | duration: 10000, 180 | }) 181 | }, 182 | }, 183 | { 184 | title: "Warning", 185 | emoji: "⚠️", 186 | snippet: { 187 | lang: "js", 188 | code: `toast.warning("Be careful!")`, 189 | }, 190 | action: () => { 191 | toast.warning("Be careful!") 192 | }, 193 | }, 194 | { 195 | title: "Info", 196 | emoji: "ℹ️", 197 | snippet: { 198 | lang: "js", 199 | code: `toast.info("This is some info.")`, 200 | }, 201 | action: () => { 202 | toast.info("This is some info.") 203 | }, 204 | }, 205 | ] satisfies Example[] 206 | --------------------------------------------------------------------------------