diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9945eb4949..ffce4b7e2f 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -765,6 +765,7 @@ uploaded_avatar_not_a_image = The uploaded file is not an image.
 uploaded_avatar_is_too_big = The uploaded file size (%d KiB) exceeds the maximum size (%d KiB).
 update_avatar_success = Your avatar has been updated.
 update_user_avatar_success = The user's avatar has been updated.
+cropper_prompt = You can edit the image before saving. The edited image will be saved as PNG.
 
 change_password = Update Password
 old_password = Current Password
diff --git a/package-lock.json b/package-lock.json
index 989c2bd77f..54e387a107 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
         "chartjs-adapter-dayjs-4": "1.0.4",
         "chartjs-plugin-zoom": "2.0.1",
         "clippie": "4.1.3",
+        "cropperjs": "1.6.2",
         "css-loader": "7.1.2",
         "dayjs": "1.11.13",
         "dropzone": "6.0.0-beta.2",
@@ -6876,6 +6877,12 @@
         }
       }
     },
+    "node_modules/cropperjs": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
+      "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
+      "license": "MIT"
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
diff --git a/package.json b/package.json
index 03c3b79990..e596b444b6 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "chartjs-adapter-dayjs-4": "1.0.4",
     "chartjs-plugin-zoom": "2.0.1",
     "clippie": "4.1.3",
+    "cropperjs": "1.6.2",
     "css-loader": "7.1.2",
     "dayjs": "1.11.13",
     "dropzone": "6.0.0-beta.2",
diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl
index 9c7e2de218..f879587c71 100644
--- a/templates/user/settings/profile.tmpl
+++ b/templates/user/settings/profile.tmpl
@@ -127,6 +127,11 @@
 					<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
 				</div>
 
+				<div class="field tw-pl-4 cropper-panel tw-hidden">
+					<div>{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
+					<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
+				</div>
+
 				<div class="field">
 					<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button>
 					<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button>
diff --git a/web_src/css/features/cropper.css b/web_src/css/features/cropper.css
new file mode 100644
index 0000000000..ed7171e770
--- /dev/null
+++ b/web_src/css/features/cropper.css
@@ -0,0 +1,6 @@
+@import "cropperjs/dist/cropper.css";
+
+.page-content.user.profile .cropper-panel .cropper-wrapper {
+  max-width: 400px;
+  max-height: 400px;
+}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 817f6997da..174a4a9cbc 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -40,6 +40,7 @@
 @import "./features/codeeditor.css";
 @import "./features/projects.css";
 @import "./features/tribute.css";
+@import "./features/cropper.css";
 @import "./features/console.css";
 
 @import "./markup/content.css";
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
new file mode 100644
index 0000000000..3961b79b49
--- /dev/null
+++ b/web_src/js/features/comp/Cropper.ts
@@ -0,0 +1,40 @@
+import {showElem} from '../../utils/dom.ts';
+
+type CropperOpts = {
+  container: HTMLElement,
+  imageSource: HTMLImageElement,
+  fileInput: HTMLInputElement,
+}
+
+export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
+  const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
+  let currentFileName = '';
+  let currentFileLastModified = 0;
+  const cropper = new Cropper(imageSource, {
+    aspectRatio: 1,
+    viewMode: 2,
+    autoCrop: false,
+    crop() {
+      const canvas = cropper.getCroppedCanvas();
+      canvas.toBlob((blob) => {
+        const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
+        const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
+        const dataTransfer = new DataTransfer();
+        dataTransfer.items.add(croppedFile);
+        fileInput.files = dataTransfer.files;
+      });
+    },
+  });
+
+  fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
+    const files = e.target.files;
+    if (files?.length > 0) {
+      currentFileName = files[0].name;
+      currentFileLastModified = files[0].lastModified;
+      const fileURL = URL.createObjectURL(files[0]);
+      imageSource.src = fileURL;
+      cropper.replace(fileURL);
+      showElem(container);
+    }
+  });
+}
diff --git a/web_src/js/features/repo-settings-branches.test.ts b/web_src/js/features/repo-settings-branches.test.ts
index 023039334f..c4609999be 100644
--- a/web_src/js/features/repo-settings-branches.test.ts
+++ b/web_src/js/features/repo-settings-branches.test.ts
@@ -1,5 +1,5 @@
 import {beforeEach, describe, expect, test, vi} from 'vitest';
-import {initRepoBranchesSettings} from './repo-settings-branches.ts';
+import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
 import {POST} from '../modules/fetch.ts';
 import {createSortable} from '../modules/sortable.ts';
 
@@ -31,7 +31,7 @@ describe('Repository Branch Settings', () => {
   });
 
   test('should initialize sortable for protected branches list', () => {
-    initRepoBranchesSettings();
+    initRepoSettingsBranchesDrag();
 
     expect(createSortable).toHaveBeenCalledWith(
       document.querySelector('#protected-branches-list'),
@@ -45,7 +45,7 @@ describe('Repository Branch Settings', () => {
   test('should not initialize if protected branches list is not present', () => {
     document.body.innerHTML = '';
 
-    initRepoBranchesSettings();
+    initRepoSettingsBranchesDrag();
 
     expect(createSortable).not.toHaveBeenCalled();
   });
@@ -59,7 +59,7 @@ describe('Repository Branch Settings', () => {
       return {destroy: vi.fn()};
     });
 
-    initRepoBranchesSettings();
+    initRepoSettingsBranchesDrag();
 
     expect(POST).toHaveBeenCalledWith(
       'some/repo/branches/priority',
diff --git a/web_src/js/features/repo-settings-branches.ts b/web_src/js/features/repo-settings-branches.ts
index 43b98f79b3..40cdf9f981 100644
--- a/web_src/js/features/repo-settings-branches.ts
+++ b/web_src/js/features/repo-settings-branches.ts
@@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
 import {showErrorToast} from '../modules/toast.ts';
 import {queryElemChildren} from '../utils/dom.ts';
 
-export function initRepoBranchesSettings() {
+export function initRepoSettingsBranchesDrag() {
   const protectedBranchesList = document.querySelector('#protected-branches-list');
   if (!protectedBranchesList) return;
 
diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts
index 5a009cfea4..9ea546f76d 100644
--- a/web_src/js/features/repo-settings.ts
+++ b/web_src/js/features/repo-settings.ts
@@ -3,7 +3,7 @@ import {minimatch} from 'minimatch';
 import {createMonaco} from './codeeditor.ts';
 import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
 import {POST} from '../modules/fetch.ts';
-import {initRepoBranchesSettings} from './repo-settings-branches.ts';
+import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
 
 const {appSubUrl, csrfToken} = window.config;
 
@@ -155,5 +155,5 @@ export function initRepoSettings() {
   initRepoSettingsCollaboration();
   initRepoSettingsSearchTeamBox();
   initRepoSettingsGitHook();
-  initRepoBranchesSettings();
+  initRepoSettingsBranchesDrag();
 }
diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts
index 41939c0f52..c097df7b6c 100644
--- a/web_src/js/features/user-settings.ts
+++ b/web_src/js/features/user-settings.ts
@@ -1,7 +1,17 @@
 import {hideElem, showElem} from '../utils/dom.ts';
+import {initCompCropper} from './comp/Cropper.ts';
+
+function initUserSettingsAvatarCropper() {
+  const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
+  const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
+  const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
+  initCompCropper({container, fileInput, imageSource});
+}
 
 export function initUserSettings() {
-  if (!document.querySelectorAll('.user.settings.profile').length) return;
+  if (!document.querySelector('.user.settings.profile')) return;
+
+  initUserSettingsAvatarCropper();
 
   const usernameInput = document.querySelector('#username');
   if (!usernameInput) return;
diff --git a/web_src/js/modules/sortable.ts b/web_src/js/modules/sortable.ts
index c31135357c..b318386d08 100644
--- a/web_src/js/modules/sortable.ts
+++ b/web_src/js/modules/sortable.ts
@@ -1,6 +1,6 @@
 import type {SortableOptions, SortableEvent} from 'sortablejs';
 
-export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) {
+export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}) {
   // @ts-expect-error: wrong type derived by typescript
   const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');