diff --git a/.eslintrc.yaml b/.eslintrc.yaml
index ea85ab1298..71d8dc3814 100644
--- a/.eslintrc.yaml
+++ b/.eslintrc.yaml
@@ -25,10 +25,11 @@ env:
   es2022: true
   node: true
 
-globals:
-  __webpack_public_path__: true
-
 overrides:
+  - files: ["web_src/**/*"]
+    globals:
+      __webpack_public_path__: true
+      process: false # https://github.com/webpack/webpack/issues/15833
   - files: ["web_src/**/*", "docs/**/*"]
     env:
       browser: true
diff --git a/package-lock.json b/package-lock.json
index 001fedb6cf..dcda7d2fed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,7 @@
         "swagger-ui-dist": "5.0.0",
         "throttle-debounce": "5.0.0",
         "tippy.js": "6.3.7",
+        "toastify-js": "1.12.0",
         "tributejs": "5.1.3",
         "uint8-to-base64": "0.2.0",
         "vue": "3.3.4",
@@ -10122,6 +10123,11 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/toastify-js": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz",
+      "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ=="
+    },
     "node_modules/toidentifier": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
diff --git a/package.json b/package.json
index 9219dbbe1d..9659771212 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
     "swagger-ui-dist": "5.0.0",
     "throttle-debounce": "5.0.0",
     "tippy.js": "6.3.7",
+    "toastify-js": "1.12.0",
     "tributejs": "5.1.3",
     "uint8-to-base64": "0.2.0",
     "vue": "3.3.4",
diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl
index 2c5a54c1c2..8b31957f2e 100644
--- a/templates/devtest/gitea-ui.tmpl
+++ b/templates/devtest/gitea-ui.tmpl
@@ -1,4 +1,5 @@
 {{template "base/head" .}}
+<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
 <div class="page-content devtest ui container">
 	<div>
 		<h1>Button</h1>
@@ -14,11 +15,6 @@
 			<label><input type="checkbox" name="button-state-disabled" value="disabled">disabled</label>
 		</div>
 		<div id="devtest-button-samples">
-			<style>
-				.button-sample-groups { margin: 0; padding: 0; }
-				.button-sample-groups .sample-group { list-style: none; margin: 0; padding: 0; }
-				.button-sample-groups .sample-group .ui.button { margin-bottom: 5px; }
-			</style>
 			<ul class="button-sample-groups">
 				<li class="sample-group">
 					<h2>General purpose:</h2>
@@ -242,17 +238,20 @@
 		</div>
 	</div>
 
+	<div>
+		<h1>Toast</h1>
+		<div>
+			<button class="ui button" id="info-toast">Show Info Toast</button>
+			<button class="ui button" id="warning-toast">Show Warning Toast</button>
+			<button class="ui button" id="error-toast">Show Error Toast</button>
+		</div>
+	</div>
+
 	<div>
 		<h1>ComboMarkdownEditor</h1>
 		<div>ps: no JS code attached, so just a layout</div>
 		{{template "shared/combomarkdowneditor" .}}
 	</div>
-
-	<style>
-		h1, h2 {
-			margin: 0;
-			padding: 10px 0;
-		}
-	</style>
+	<script src="{{AssetUrlPrefix}}/js/devtest.js?v={{AssetVersion}}"></script>
 </div>
 {{template "base/footer" .}}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 66a1a9ffd3..d7ac9f453d 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -8,6 +8,7 @@
 @import "./modules/card.css";
 @import "./modules/comment.css";
 @import "./modules/navbar.css";
+@import "./modules/toast.css";
 
 @import "./shared/issuelist.css";
 @import "./shared/milestone.css";
diff --git a/web_src/css/modules/toast.css b/web_src/css/modules/toast.css
new file mode 100644
index 0000000000..c96521f273
--- /dev/null
+++ b/web_src/css/modules/toast.css
@@ -0,0 +1,78 @@
+.toastify {
+  color: var(--color-white);
+  position: fixed;
+  opacity: 0;
+  transition: all .2s ease;
+  z-index: 500;
+  border-radius: 4px;
+  box-shadow: 0 8px 24px var(--color-shadow);
+  display: flex;
+  max-width: 50vw;
+  min-width: 300px;
+  padding: 4px;
+}
+
+.toastify.on {
+  opacity: 1;
+}
+
+.toast-body {
+  flex: 1;
+  padding: 5px 0;
+  overflow-wrap: anywhere;
+}
+
+.toast-close,
+.toast-icon {
+  color: currentcolor;
+  border-radius: 3px;
+  background: transparent;
+  border: none;
+  display: inline-block;
+  display: flex;
+  width: 30px;
+  height: 30px;
+  justify-content: center;
+  align-items: center;
+}
+
+.toast-close:hover {
+  background: var(--color-hover);
+}
+
+.toast-close:active {
+  background: var(--color-active);
+}
+
+.toastify-right {
+  right: 15px;
+}
+
+.toastify-left {
+  left: 15px;
+}
+
+.toastify-top {
+  top: -150px;
+}
+
+.toastify-bottom {
+  bottom: -150px;
+}
+
+.toastify-center {
+  margin-left: auto;
+  margin-right: auto;
+  left: 0;
+  right: 0;
+}
+
+@media (max-width: 360px) {
+  .toastify-right, .toastify-left {
+    margin-left: auto;
+    margin-right: auto;
+    left: 0;
+    right: 0;
+    max-width: fit-content;
+  }
+}
diff --git a/web_src/css/standalone/devtest.css b/web_src/css/standalone/devtest.css
new file mode 100644
index 0000000000..a7b00e1e56
--- /dev/null
+++ b/web_src/css/standalone/devtest.css
@@ -0,0 +1,16 @@
+.button-sample-groups {
+  margin: 0; padding: 0;
+}
+
+.button-sample-groups .sample-group {
+  list-style: none; margin: 0; padding: 0;
+}
+
+.button-sample-groups .sample-group .ui.button {
+  margin-bottom: 5px;
+}
+
+h1, h2 {
+  margin: 0;
+  padding: 10px 0;
+}
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index e5fd7c29fc..a99b29141d 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -9,6 +9,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js';
 import {htmlEscape} from 'escape-goat';
 import {createTippy} from '../modules/tippy.js';
 import {confirmModal} from './comp/ConfirmModal.js';
+import {showErrorToast} from '../modules/toast.js';
 
 const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
 
@@ -439,7 +440,7 @@ export function initGlobalButtons() {
       return;
     }
     // should never happen, otherwise there is a bug in code
-    alert('Nothing to hide');
+    showErrorToast('Nothing to hide');
   });
 
   initGlobalShowModal();
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index 103e71daae..3d696be75b 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -8,6 +8,7 @@ import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
 import {renderPreviewPanelContent} from '../repo-editor.js';
 import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
 import {initTextExpander} from './TextExpander.js';
+import {showErrorToast} from '../../modules/toast.js';
 
 let elementIdCounter = 0;
 
@@ -26,7 +27,7 @@ export function validateTextareaNonEmpty($textarea) {
       $form[0]?.reportValidity();
     } else {
       // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
-      alert('Require non-empty content');
+      showErrorToast('Require non-empty content');
     }
     return false;
   }
diff --git a/web_src/js/features/repo-issue-content.js b/web_src/js/features/repo-issue-content.js
index d66f6ad4a4..fc916aea19 100644
--- a/web_src/js/features/repo-issue-content.js
+++ b/web_src/js/features/repo-issue-content.js
@@ -1,5 +1,6 @@
 import $ from 'jquery';
 import {svg} from '../svg.js';
+import {showErrorToast} from '../modules/toast.js';
 
 const {appSubUrl, csrfToken} = window.config;
 let i18nTextEdited;
@@ -39,12 +40,12 @@ function showContentHistoryDetail(issueBaseUrl, commentId, historyId, itemTitleH
             if (resp.ok) {
               $dialog.modal('hide');
             } else {
-              alert(resp.message);
+              showErrorToast(resp.message);
             }
           });
         }
       } else { // required by eslint
-        window.alert(`unknown option item: ${optionItem}`);
+        showErrorToast(`unknown option item: ${optionItem}`);
       }
     },
     onHide() {
diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.js
index 4d61de0ce5..5402f958f2 100644
--- a/web_src/js/features/repo-issue-list.js
+++ b/web_src/js/features/repo-issue-list.js
@@ -4,6 +4,7 @@ import {toggleElem} from '../utils/dom.js';
 import {htmlEscape} from 'escape-goat';
 import {Sortable} from 'sortablejs';
 import {confirmModal} from './comp/ConfirmModal.js';
+import {showErrorToast} from '../modules/toast.js';
 
 function initRepoIssueListCheckboxes() {
   const $issueSelectAll = $('.issue-checkbox-all');
@@ -75,7 +76,7 @@ function initRepoIssueListCheckboxes() {
     ).then(() => {
       window.location.reload();
     }).catch((reason) => {
-      window.alert(reason.responseJSON.error);
+      showErrorToast(reason.responseJSON.error);
     });
   });
 }
diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js
new file mode 100644
index 0000000000..b0d02dc644
--- /dev/null
+++ b/web_src/js/modules/toast.js
@@ -0,0 +1,60 @@
+import {htmlEscape} from 'escape-goat';
+import {svg} from '../svg.js';
+
+const levels = {
+  info: {
+    icon: 'octicon-check',
+    background: 'var(--color-green)',
+    duration: 2500,
+  },
+  warning: {
+    icon: 'gitea-exclamation',
+    background: 'var(--color-orange)',
+    duration: -1, // requires dismissal to hide
+  },
+  error: {
+    icon: 'gitea-exclamation',
+    background: 'var(--color-red)',
+    duration: -1, // requires dismissal to hide
+  },
+};
+
+// See https://github.com/apvarun/toastify-js#api for options
+async function showToast(message, level, {gravity, position, duration, ...other} = {}) {
+  if (!message) return;
+
+  const {default: Toastify} = await import(/* webpackChunkName: 'toastify' */'toastify-js');
+  const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
+
+  const toast = Toastify({
+    text: `
+      <div class='toast-icon'>${svg(icon)}</div>
+      <div class='toast-body'>${htmlEscape(message)}</div>
+      <button class='toast-close'>${svg('octicon-x')}</button>
+    `,
+    escapeMarkup: false,
+    gravity: gravity ?? 'top',
+    position: position ?? 'center',
+    duration: duration ?? levelDuration,
+    style: {background},
+    ...other,
+  });
+
+  toast.showToast();
+
+  toast.toastElement.querySelector('.toast-close').addEventListener('click', () => {
+    toast.removeElement(toast.toastElement);
+  });
+}
+
+export async function showInfoToast(message, opts) {
+  return await showToast(message, 'info', opts);
+}
+
+export async function showWarningToast(message, opts) {
+  return await showToast(message, 'warning', opts);
+}
+
+export async function showErrorToast(message, opts) {
+  return await showToast(message, 'error', opts);
+}
diff --git a/web_src/js/modules/toast.test.js b/web_src/js/modules/toast.test.js
new file mode 100644
index 0000000000..b691aaebb6
--- /dev/null
+++ b/web_src/js/modules/toast.test.js
@@ -0,0 +1,17 @@
+import {test, expect} from 'vitest';
+import {showInfoToast, showErrorToast, showWarningToast} from './toast.js';
+
+test('showInfoToast', async () => {
+  await showInfoToast('success 😀', {duration: -1});
+  expect(document.querySelector('.toastify')).toBeTruthy();
+});
+
+test('showWarningToast', async () => {
+  await showWarningToast('warning 😐', {duration: -1});
+  expect(document.querySelector('.toastify')).toBeTruthy();
+});
+
+test('showErrorToast', async () => {
+  await showErrorToast('error 🙁', {duration: -1});
+  expect(document.querySelector('.toastify')).toBeTruthy();
+});
diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.js
new file mode 100644
index 0000000000..d0ca511c0f
--- /dev/null
+++ b/web_src/js/standalone/devtest.js
@@ -0,0 +1,11 @@
+import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js';
+
+document.getElementById('info-toast').addEventListener('click', () => {
+  showInfoToast('success 😀');
+});
+document.getElementById('warning-toast').addEventListener('click', () => {
+  showWarningToast('warning 😐');
+});
+document.getElementById('error-toast').addEventListener('click', () => {
+  showErrorToast('error 🙁');
+});
diff --git a/webpack.config.js b/webpack.config.js
index fb442521e9..6070dae247 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -73,6 +73,12 @@ export default {
     'eventsource.sharedworker': [
       fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.js', import.meta.url)),
     ],
+    ...(!isProduction && {
+      devtest: [
+        fileURLToPath(new URL('web_src/js/standalone/devtest.js', import.meta.url)),
+        fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)),
+      ],
+    }),
     ...themes,
   },
   devtool: false,