diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl
index 39379a977c..c25cbce315 100644
--- a/templates/repo/editor/edit.tmpl
+++ b/templates/repo/editor/edit.tmpl
@@ -51,24 +51,26 @@
 			{{template "repo/editor/commit_form" .}}
 		
 	
-	
-		
-		
-			
{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}
-		
-		
-			
-			
-		
-	
 
+	
 
 {{template "base/footer" .}}
diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl
index 1f046a8d4e..7712836953 100644
--- a/templates/repo/editor/patch.tmpl
+++ b/templates/repo/editor/patch.tmpl
@@ -34,24 +34,26 @@
 		
 	
 
-	
-		
-		
-			
{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}
-		
-		
-			
-			
-		
-	
 
+	
 
 {{template "base/footer" .}}
diff --git a/tests/e2e/dimmer.test.e2e.ts b/tests/e2e/dimmer.test.e2e.ts
index 48084b0e52..ed8d116e1f 100644
--- a/tests/e2e/dimmer.test.e2e.ts
+++ b/tests/e2e/dimmer.test.e2e.ts
@@ -56,19 +56,16 @@ test('Dimmed overflow', async ({page}, workerInfo) => {
   await page.locator('#commit-button').click();
 
   // Expect a 'are you sure, this file is empty' modal.
-  await expect(page.locator('.ui.dimmer')).toBeVisible();
-  await expect(page.locator('.ui.dimmer .header')).toContainText('Commit an empty file');
+  await expect(page.locator('#edit-empty-content-modal')).toBeVisible();
+  await expect(page.locator('#edit-empty-content-modal header')).toContainText('Commit an empty file');
   await save_visual(page);
 
-  // Trickery to check that the dimmer covers the whole page.
-  const viewport = page.viewportSize();
-  const box = await page.locator('.ui.dimmer').boundingBox();
-  expect(box.x).toBe(0);
-  expect(box.y).toBe(0);
-  expect(box.width).toBe(viewport.width);
-  expect(box.height).toBe(viewport.height);
-
   // Trickery to check the page cannot be scrolled.
-  const {scrollHeight, clientHeight} = await page.evaluate(() => document.body);
-  expect(scrollHeight).toBe(clientHeight);
+  const {overflow} = await page.evaluate(() => {
+    const s = getComputedStyle(document.body);
+    return {
+      overflow: s.overflow,
+    };
+  });
+  expect(overflow).toBe('hidden');
 });
diff --git a/tests/e2e/modal.test.e2e.ts b/tests/e2e/modal.test.e2e.ts
new file mode 100644
index 0000000000..dbcfcb3ea4
--- /dev/null
+++ b/tests/e2e/modal.test.e2e.ts
@@ -0,0 +1,45 @@
+// @watch start
+// templates/repo/editor/edit.tmpl
+// templates/repo/editor/patch.tmpl
+// web_src/js/features/repo-editor.js
+// web_src/css/modules/dialog.ts
+// web_src/css/modules/dialog.css
+// @watch end
+
+import {expect} from '@playwright/test';
+import {save_visual, dynamic_id, test} from './utils_e2e.ts';
+
+test.use({user: 'user2'});
+
+test('Dialog modal', async ({page}, workerInfo) => {
+  test.skip(['Mobile Safari', 'webkit'].includes(workerInfo.project.name), 'keyboard shortcuts do not work');
+  let response = await page.goto('/user2/repo1/_new/master', {waitUntil: 'domcontentloaded'});
+  expect(response?.status()).toBe(200);
+
+  const filename = `${dynamic_id()}.md`;
+
+  await page.getByPlaceholder('Name your fileā¦').fill(filename);
+  await page.locator('.monaco-editor').click();
+  await page.keyboard.type('Hi, nice to meet you. Can I talk about ');
+
+  await page.locator('.quick-pull-choice input[value="direct"]').click();
+  await page.getByRole('button', {name: 'Commit changes'}).click();
+
+  response = await page.goto(`/user2/repo1/_edit/master/${filename}`, {waitUntil: 'domcontentloaded'});
+  expect(response?.status()).toBe(200);
+
+  await page.locator('.monaco-editor-container').click();
+  await page.keyboard.press('ControlOrMeta+A');
+  await page.keyboard.press('Backspace');
+
+  await page.locator('#commit-button').click();
+  await save_visual(page);
+  await expect(page.locator('#edit-empty-content-modal')).toBeVisible();
+
+  await page.locator('#edit-empty-content-modal .cancel').click();
+  await expect(page.locator('#edit-empty-content-modal')).toBeHidden();
+
+  await page.locator('#commit-button').click();
+  await page.locator('#edit-empty-content-modal .ok').click();
+  await expect(page).toHaveURL(`/user2/repo1/src/branch/master/${filename}`);
+});
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 7b0fa45916..8cb25d8185 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -18,6 +18,7 @@
 @import "./modules/checkbox.css";
 @import "./modules/modal.css";
 @import "./modules/dimmer.css";
+@import "./modules/dialog.css";
 
 @import "./modules/switch.css";
 @import "./modules/dropdown.css";
diff --git a/web_src/css/modules/dialog.css b/web_src/css/modules/dialog.css
new file mode 100644
index 0000000000..711d54b3ea
--- /dev/null
+++ b/web_src/css/modules/dialog.css
@@ -0,0 +1,98 @@
+body:has(dialog[open]) {
+  overflow: hidden;
+}
+
+dialog {
+  align-items: center;
+  justify-content: center;
+  position: fixed;
+  text-align: left;
+  border: none;
+  background: var(--color-body);
+  box-shadow:
+    1px 3px 3px 0 var(--color-shadow),
+    1px 3px 15px 2px var(--color-shadow);
+  border-radius: 0.28571429rem;
+  outline: none;
+  padding: 0;
+  max-width: min(800px, 90vw);
+  width: fit-content;
+  z-index: 1001;
+
+  pointer-events: auto;
+  touch-action: auto;
+}
+
+dialog[open],
+dialog:target {
+  display: flex;
+}
+
+dialog::backdrop {
+  background-color: var(--color-overlay-backdrop);
+  will-change: opacity;
+  opacity: 0;
+  animation-name: fadein;
+  animation-duration: 100ms;
+  animation-timing-function: ease-in-out;
+}
+
+dialog[open]::backdrop {
+  opacity: 1;
+}
+
+dialog header {
+  font-size: 1.42857143rem;
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  font-family: var(--fonts-regular);
+  color: var(--color-text-dark);
+  margin: 0;
+  padding: 1.25rem 1.5rem;
+  box-shadow: none;
+  border-top-left-radius: var(--border-radius);
+  border-top-right-radius: var(--border-radius);
+  border-bottom: 1px solid var(--color-secondary);
+}
+
+dialog article {
+  display: block;
+  width: 100%;
+  font-size: 1em;
+  line-height: 1.4;
+}
+
+dialog .content {
+  padding: 1.5em;
+  background: var(--color-body);
+  color: var(--color-text);
+}
+
+dialog .actions {
+  background: var(--color-secondary-bg);
+  border-color: var(--color-secondary);
+  display: flex;
+  gap: 1em;
+  justify-content: flex-end;
+  padding: 1rem;
+}
+
+/* positive/negative action buttons */
+dialog .actions .ui.button {
+  display: inline-flex;
+  align-items: center;
+  padding: 10px 12px;
+  margin-right: 0;
+}
+
+dialog .actions .ui.button.danger {
+  display: block;
+  width: 100%;
+  margin: 0 auto;
+  text-align: center;
+}
+
+dialog .actions .ui.button .svg {
+  margin-right: 5px;
+}
diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js
index ac4fc8a75e..44db51e98f 100644
--- a/web_src/js/features/repo-editor.js
+++ b/web_src/js/features/repo-editor.js
@@ -6,6 +6,7 @@ import {initMarkupContent} from '../markup/content.js';
 import {attachRefIssueContextPopup} from './contextpopup.js';
 import {POST} from '../modules/fetch.js';
 import {initTab} from '../modules/tab.ts';
+import {showModal} from '../modules/modal.ts';
 
 function initEditPreviewTab($form) {
   const $tabMenu = $form.find('.tabular.menu');
@@ -183,14 +184,8 @@ export function initRepoEditor() {
     commitButton?.addEventListener('click', (e) => {
       // A modal which asks if an empty file should be committed
       if (!$editArea.val()) {
-        $('#edit-empty-content-modal')
-          .modal({
-            onApprove() {
-              document.querySelector('.edit.form').requestSubmit();
-            },
-          })
-          .modal('show');
         e.preventDefault();
+        showModal('edit-empty-content-modal', () => { document.querySelector('.edit.form').requestSubmit()});
       }
     });
   })();
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 98e57ef450..dd4da29e18 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -86,6 +86,7 @@ import {initDirAuto} from './modules/dirauto.js';
 import {initRepositorySearch} from './features/repo-search.js';
 import {initColorPickers} from './features/colorpicker.js';
 import {initRepoMilestoneEditor} from './features/repo-milestone.js';
+import {initModalClose} from './modules/modal.ts';
 
 // Init Gitea's Fomantic settings
 initGiteaFomantic();
@@ -189,6 +190,7 @@ onDomReady(() => {
   initRepoDiffView();
   initScopedAccessTokenCategories();
   initColorPickers();
+  initModalClose();
 
   // Deactivate CSS-only noJS usability supplements
   document.body.classList.remove('no-js');
diff --git a/web_src/js/modules/modal.ts b/web_src/js/modules/modal.ts
new file mode 100644
index 0000000000..290dccee70
--- /dev/null
+++ b/web_src/js/modules/modal.ts
@@ -0,0 +1,33 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// showModal will show the given modal and run `onApprove` if the approve/ok/yes
+// button is pressed.
+export function showModal(modalID: string, onApprove: () => void) {
+  const modal = document.getElementById(modalID) as HTMLDialogElement;
+  // Move the modal to ``, to avoid inheriting any bad CSS or if the
+  // parent becomes `display: hidden`.
+  document.body.append(modal);
+
+  // Close the modal if the cancel button is pressed.
+  modal.querySelector('.cancel')?.addEventListener('click', () => {
+    modal.close();
+  }, {once: true, passive: true});
+  modal.querySelector('.ok')?.addEventListener('click', onApprove, {once: true, passive: true});
+
+  // The modal is ready to be shown.
+  modal.showModal();
+}
+
+// NOTE: Can be replaced in late 2026 with `closedBy` attribute on `