diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts
index 7b850f6166..69070dc0cf 100644
--- a/server/src/bin/migrations.ts
+++ b/server/src/bin/migrations.ts
@@ -67,7 +67,7 @@ const runQuery = async (query: string) => {
 
 const runMigrations = async () => {
   const configRepository = new ConfigRepository();
-  const logger = new LoggingRepository(undefined, configRepository);
+  const logger = LoggingRepository.create();
   const db = getDatabaseClient();
   const databaseRepository = new DatabaseRepository(db, logger, configRepository);
   await databaseRepository.runMigrations();
diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts
index 388c4df96b..89b1921819 100644
--- a/server/test/medium.factory.ts
+++ b/server/test/medium.factory.ts
@@ -142,18 +142,15 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
     }
 
     case 'database': {
-      const configRepo = new ConfigRepository();
-      return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo);
+      return new DatabaseRepository(db, LoggingRepository.create(), new ConfigRepository());
     }
 
     case 'email': {
-      const logger = new LoggingRepository(undefined, new ConfigRepository());
-      return new EmailRepository(logger);
+      return new EmailRepository(LoggingRepository.create());
     }
 
     case 'logger': {
-      const configMock = { getEnv: () => ({ noColor: false }) };
-      return new LoggingRepository(undefined, configMock as ConfigRepository);
+      return LoggingRepository.create();
     }
 
     case 'memory': {
diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts
index e63c9f5224..4398da5c0a 100644
--- a/server/test/medium/globalSetup.ts
+++ b/server/test/medium/globalSetup.ts
@@ -42,7 +42,7 @@ const globalSetup = async () => {
   const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
 
   const configRepository = new ConfigRepository();
-  const logger = new LoggingRepository(undefined, configRepository);
+  const logger = LoggingRepository.create();
   await new DatabaseRepository(db, logger, configRepository).runMigrations();
 
   await db.destroy();
diff --git a/web/package-lock.json b/web/package-lock.json
index c76dd64840..75c55aa779 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -11,7 +11,7 @@
       "dependencies": {
         "@formatjs/icu-messageformat-parser": "^2.9.8",
         "@immich/sdk": "file:../open-api/typescript-sdk",
-        "@immich/ui": "^0.18.1",
+        "@immich/ui": "^0.19.0",
         "@mapbox/mapbox-gl-rtl-text": "0.2.3",
         "@mdi/js": "^7.4.47",
         "@photo-sphere-viewer/core": "^5.11.5",
@@ -1320,9 +1320,9 @@
       "link": true
     },
     "node_modules/@immich/ui": {
-      "version": "0.18.1",
-      "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.18.1.tgz",
-      "integrity": "sha512-XWWO6OTfH3MektyxCn0hWefZyOGyWwwx/2zHinuShpxTHSyfveJ4mOkFP8DkyMz0dnvJ1EfdkPBMkld3y5R/Hw==",
+      "version": "0.19.0",
+      "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.19.0.tgz",
+      "integrity": "sha512-XVjSUoQVIoe83pxM4q8kmlejb2xep/TZEfoGbasI7takEGKNiWEyXr5eZaXZCSVgq78fcNRr4jyWz290ZAXh7A==",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@mdi/js": "^7.4.47",
diff --git a/web/package.json b/web/package.json
index 9aa9bee6bc..7c5a0147bb 100644
--- a/web/package.json
+++ b/web/package.json
@@ -27,7 +27,7 @@
   "dependencies": {
     "@formatjs/icu-messageformat-parser": "^2.9.8",
     "@immich/sdk": "file:../open-api/typescript-sdk",
-    "@immich/ui": "^0.18.1",
+    "@immich/ui": "^0.19.0",
     "@mapbox/mapbox-gl-rtl-text": "0.2.3",
     "@mdi/js": "^7.4.47",
     "@photo-sphere-viewer/core": "^5.11.5",
diff --git a/web/src/app.css b/web/src/app.css
index 2c8d150b4f..61759eb1b0 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -8,7 +8,6 @@
     --immich-primary: 66 80 175;
     --immich-bg: 255 255 255;
     --immich-fg: 0 0 0;
-    --immich-gray: 246 246 244;
     --immich-error: 229 115 115;
     --immich-success: 129 199 132;
     --immich-warning: 255 183 77;
@@ -33,6 +32,7 @@
     --immich-ui-warning: 255 170 0;
     --immich-ui-info: 14 165 233;
     --immich-ui-default-border: 209 213 219;
+    --immich-ui-gray: 246 246 246;
   }
 
   .dark {
@@ -45,6 +45,7 @@
     --immich-ui-warning: 255 170 0;
     --immich-ui-info: 14 165 233;
     --immich-ui-default-border: 55 65 81;
+    --immich-ui-gray: 33 33 33;
   }
 }
 
diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
index 6eb603263e..e2d3c86bf3 100644
--- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
+++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
@@ -47,8 +47,7 @@
 <ConfirmDialog
   title={$t('delete_user')}
   confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
-  onConfirm={handleDeleteUser}
-  {onCancel}
+  onClose={(confirmed) => (confirmed ? handleDeleteUser() : onCancel())}
   disabled={deleteButtonDisabled}
 >
   {#snippet promptSnippet()}
diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte
index 1386ae9fc4..7fd51aaf06 100644
--- a/web/src/lib/components/admin-page/restore-dialogue.svelte
+++ b/web/src/lib/components/admin-page/restore-dialogue.svelte
@@ -33,8 +33,7 @@
   title={$t('restore_user')}
   confirmText={$t('continue')}
   confirmColor="success"
-  onConfirm={handleRestoreUser}
-  {onCancel}
+  onClose={(confirmed) => (confirmed ? handleRestoreUser() : onCancel())}
 >
   {#snippet promptSnippet()}
     <p>
diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
index b2454b06c3..2a270f7438 100644
--- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
@@ -49,8 +49,7 @@
 {#if isConfirmOpen}
   <ConfirmDialog
     title={$t('admin.disable_login')}
-    onCancel={() => (isConfirmOpen = false)}
-    onConfirm={() => handleSave(true)}
+    onClose={(confirmed) => (confirmed ? handleSave(true) : (isConfirmOpen = false))}
   >
     {#snippet promptSnippet()}
       <div class="flex flex-col gap-4">
diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte
index 884de8c2a2..4a8c018fbd 100644
--- a/web/src/lib/components/album-page/album-options.svelte
+++ b/web/src/lib/components/album-page/album-options.svelte
@@ -1,27 +1,27 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
+  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
+  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
+  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
+  import { handleError } from '$lib/utils/handle-error';
   import {
-    updateAlbumInfo,
+    AlbumUserRole,
+    AssetOrder,
     removeUserFromAlbum,
+    updateAlbumInfo,
+    updateAlbumUser,
     type AlbumResponseDto,
     type UserResponseDto,
-    AssetOrder,
-    AlbumUserRole,
-    updateAlbumUser,
   } from '@immich/sdk';
-  import { mdiArrowDownThin, mdiArrowUpThin, mdiPlus, mdiDotsVertical } from '@mdi/js';
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
-  import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
-  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
-  import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
-  import type { RenderedOption } from '../elements/dropdown.svelte';
-  import { handleError } from '$lib/utils/handle-error';
+  import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
   import { findKey } from 'lodash-es';
   import { t } from 'svelte-i18n';
-  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
-  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
+  import type { RenderedOption } from '../elements/dropdown.svelte';
   import { notificationController, NotificationType } from '../shared-components/notification/notification';
-  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+  import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte';
 
   interface Props {
     album: AlbumResponseDto;
@@ -195,7 +195,6 @@
     title={$t('album_remove_user')}
     prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
     confirmText={$t('remove_user')}
-    onConfirm={handleRemoveUser}
-    onCancel={() => (selectedRemoveUser = null)}
+    onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))}
   />
 {/if}
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte
index 35d8a84412..97bbb81ea5 100644
--- a/web/src/lib/components/album-page/share-info-modal.svelte
+++ b/web/src/lib/components/album-page/share-info-modal.svelte
@@ -1,22 +1,22 @@
 <script lang="ts">
+  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
+  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import {
+    AlbumUserRole,
     getMyUser,
     removeUserFromAlbum,
+    updateAlbumUser,
     type AlbumResponseDto,
     type UserResponseDto,
-    updateAlbumUser,
-    AlbumUserRole,
   } from '@immich/sdk';
   import { mdiDotsVertical } from '@mdi/js';
   import { onMount } from 'svelte';
+  import { t } from 'svelte-i18n';
   import { handleError } from '../../utils/handle-error';
-  import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
+  import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import UserAvatar from '../shared-components/user-avatar.svelte';
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
-  import { t } from 'svelte-i18n';
-  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 
   interface Props {
     album: AlbumResponseDto;
@@ -144,8 +144,7 @@
     title={$t('album_leave')}
     prompt={$t('album_leave_confirmation', { values: { album: album.albumName } })}
     confirmText={$t('leave')}
-    onConfirm={handleRemoveUser}
-    onCancel={() => (selectedRemoveUser = null)}
+    onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))}
   />
 {/if}
 
@@ -154,7 +153,6 @@
     title={$t('album_remove_user')}
     prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })}
     confirmText={$t('remove_user')}
-    onConfirm={handleRemoveUser}
-    onCancel={() => (selectedRemoveUser = null)}
+    onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))}
   />
 {/if}
diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
index 39ba7ac200..7efe4a1a73 100644
--- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
+++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
@@ -1,13 +1,13 @@
 <script lang="ts">
+  import { shortcut } from '$lib/actions/shortcut';
+  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
+  import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
   import { websocketEvents } from '$lib/stores/websocket';
   import { type AssetResponseDto } from '@immich/sdk';
   import { mdiClose } from '@mdi/js';
   import { onMount } from 'svelte';
-  import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
-  import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
-  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
-  import { shortcut } from '$lib/actions/shortcut';
+  import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte';
 
   onMount(() => {
     return websocketEvents.on('on_asset_update', (assetUpdate) => {
@@ -31,10 +31,13 @@
   setTimeout(() => {
     onUpdateSelectedType(selectedType);
   }, 1);
+
   function selectType(name: string) {
     selectedType = name;
     onUpdateSelectedType(selectedType);
   }
+
+  const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog());
 </script>
 
 <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
@@ -71,9 +74,6 @@
     cancelColor="secondary"
     confirmColor="danger"
     confirmText={$t('close')}
-    onCancel={() => {
-      $showCancelConfirmDialog = false;
-    }}
-    onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())}
+    onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))}
   />
 {/if}
diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte
index 83b3154d4b..34e498ce1c 100644
--- a/web/src/lib/components/forms/create-user-form.svelte
+++ b/web/src/lib/components/forms/create-user-form.svelte
@@ -1,21 +1,29 @@
 <script lang="ts">
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { userInteraction } from '$lib/stores/user.svelte';
   import { ByteUnit, convertToBytes } from '$lib/utils/byte-units';
   import { handleError } from '$lib/utils/handle-error';
-  import { createUserAdmin } from '@immich/sdk';
-  import { Alert, Button, Field, HelperText, Input, PasswordInput, Stack, Switch } from '@immich/ui';
+  import { createUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
+  import {
+    Alert,
+    Button,
+    Field,
+    HelperText,
+    Input,
+    Modal,
+    ModalBody,
+    ModalFooter,
+    PasswordInput,
+    Stack,
+    Switch,
+  } from '@immich/ui';
   import { t } from 'svelte-i18n';
 
   interface Props {
-    onClose: () => void;
-    onSubmit: () => void;
-    onCancel: () => void;
-    oauthEnabled?: boolean;
+    onClose: (user?: UserAdminResponseDto) => void;
   }
 
-  let { onClose, onSubmit: onDone, onCancel, oauthEnabled = false }: Props = $props();
+  let { onClose }: Props = $props();
 
   let error = $state('');
   let success = $state(false);
@@ -50,7 +58,7 @@
     error = '';
 
     try {
-      await createUserAdmin({
+      const user = await createUserAdmin({
         userAdminCreateDto: {
           email,
           password,
@@ -63,8 +71,7 @@
 
       success = true;
 
-      onDone();
-
+      onClose(user);
       return;
     } catch (error) {
       handleError(error, $t('errors.unable_to_create_user'));
@@ -74,55 +81,60 @@
   };
 </script>
 
-<form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
-  <FullScreenModal title={$t('create_new_user')} showLogo {onClose}>
-    {#if error}
-      <Alert color="danger" size="small" title={error} closable />
-    {/if}
-
-    {#if success}
-      <p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
-    {/if}
-
-    <Stack gap={4}>
-      <Field label={$t('email')} required>
-        <Input bind:value={email} type="email" />
-      </Field>
-
-      {#if $featureFlags.email}
-        <Field label={$t('admin.send_welcome_email')}>
-          <Switch id="send-welcome-email" bind:checked={notify} class="text-sm" />
-        </Field>
+<Modal title={$t('create_new_user')} {onClose} size="small" class="text-dark bg-light">
+  <ModalBody>
+    <form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
+      {#if error}
+        <Alert color="danger" size="small" title={error} closable />
       {/if}
 
-      <Field label={$t('password')} required={!oauthEnabled}>
-        <PasswordInput id="password" bind:value={password} autocomplete="new-password" />
-      </Field>
+      {#if success}
+        <p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
+      {/if}
 
-      <Field label={$t('confirm_password')} required={!oauthEnabled}>
-        <PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" />
-        <HelperText color="danger">{passwordMismatchMessage}</HelperText>
-      </Field>
+      <Stack gap={4}>
+        <Field label={$t('email')} required>
+          <Input bind:value={email} type="email" />
+        </Field>
 
-      <Field label={$t('admin.require_password_change_on_login')}>
-        <Switch id="require-password-change" bind:checked={shouldChangePassword} class="text-sm" />
-      </Field>
-
-      <Field label={$t('name')} required>
-        <Input bind:value={name} />
-      </Field>
-
-      <Field label={$t('admin.quota_size_gib')}>
-        <Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
-        {#if quotaSizeWarning}
-          <HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
+        {#if $featureFlags.email}
+          <Field label={$t('admin.send_welcome_email')}>
+            <Switch id="send-welcome-email" bind:checked={notify} class="text-sm" />
+          </Field>
         {/if}
-      </Field>
-    </Stack>
 
-    {#snippet stickyBottom()}
-      <Button color="secondary" fullWidth onclick={onCancel} shape="round">{$t('cancel')}</Button>
-      <Button type="submit" disabled={!valid} fullWidth shape="round">{$t('create')}</Button>
-    {/snippet}
-  </FullScreenModal>
-</form>
+        <Field label={$t('password')} required={!$featureFlags.oauth}>
+          <PasswordInput id="password" bind:value={password} autocomplete="new-password" />
+        </Field>
+
+        <Field label={$t('confirm_password')} required={!$featureFlags.oauth}>
+          <PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" />
+          <HelperText color="danger">{passwordMismatchMessage}</HelperText>
+        </Field>
+
+        <Field label={$t('admin.require_password_change_on_login')}>
+          <Switch id="require-password-change" bind:checked={shouldChangePassword} class="text-sm text-start" />
+        </Field>
+
+        <Field label={$t('name')} required>
+          <Input bind:value={name} />
+        </Field>
+
+        <Field label={$t('admin.quota_size_gib')}>
+          <Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
+          {#if quotaSizeWarning}
+            <HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
+          {/if}
+        </Field>
+      </Stack>
+    </form>
+  </ModalBody>
+
+  <ModalFooter>
+    <div class="flex gap-3 w-full">
+      <Button color="secondary" fullWidth onclick={() => onClose()} shape="round">{$t('cancel')}</Button>
+      <Button type="submit" disabled={!valid} fullWidth shape="round" form="create-new-user-form">{$t('create')}</Button
+      >
+    </div>
+  </ModalFooter>
+</Modal>
diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte
index ab914e6430..d2f56a974a 100644
--- a/web/src/lib/components/forms/edit-user-form.svelte
+++ b/web/src/lib/components/forms/edit-user-form.svelte
@@ -1,34 +1,26 @@
 <script lang="ts">
-  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { AppRoute } from '$lib/constants';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
   import { userInteraction } from '$lib/stores/user.svelte';
   import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
   import { handleError } from '$lib/utils/handle-error';
   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
-  import { Button } from '@immich/ui';
+  import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
   import { mdiAccountEditOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
   interface Props {
     user: UserAdminResponseDto;
     canResetPassword?: boolean;
-    newPassword: string;
-    onClose: () => void;
-    onResetPasswordSuccess: () => void;
-    onEditSuccess: () => void;
+    onClose: (
+      data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string },
+    ) => void;
   }
 
-  let {
-    user,
-    canResetPassword = true,
-    newPassword = $bindable(),
-    onClose,
-    onResetPasswordSuccess,
-    onEditSuccess,
-  }: Props = $props();
+  let { user, canResetPassword = true, onClose }: Props = $props();
 
   let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB));
+  let newPassword = $state<string>('');
 
   const previousQutoa = user.quotaSizeInBytes;
 
@@ -42,7 +34,7 @@
   const editUser = async () => {
     try {
       const { id, email, name, storageLabel } = user;
-      await updateUserAdmin({
+      const newUser = await updateUserAdmin({
         id,
         userAdminUpdateDto: {
           email,
@@ -52,14 +44,14 @@
         },
       });
 
-      onEditSuccess();
+      onClose({ action: 'update', data: newUser });
     } catch (error) {
       handleError(error, $t('errors.unable_to_update_user'));
     }
   };
 
   const resetPassword = async () => {
-    const isConfirmed = await dialogController.show({
+    const isConfirmed = await modalManager.openDialog({
       prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }),
     });
 
@@ -78,7 +70,7 @@
         },
       });
 
-      onResetPasswordSuccess();
+      onClose({ action: 'resetPassword', data: newPassword });
     } catch (error) {
       handleError(error, $t('errors.unable_to_reset_password'));
     }
@@ -107,61 +99,65 @@
   };
 </script>
 
-<FullScreenModal title={$t('edit_user')} icon={mdiAccountEditOutline} {onClose}>
-  <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
-    <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="email">{$t('email')}</label>
-      <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
+<Modal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose}>
+  <ModalBody>
+    <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
+      <div class="my-4 flex flex-col gap-2">
+        <label class="immich-form-label" for="email">{$t('email')}</label>
+        <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
+      </div>
+
+      <div class="my-4 flex flex-col gap-2">
+        <label class="immich-form-label" for="name">{$t('name')}</label>
+        <input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
+      </div>
+
+      <div class="my-4 flex flex-col gap-2">
+        <label class="flex items-center gap-2 immich-form-label" for="quotaSize">
+          {$t('admin.quota_size_gib')}
+          {#if quotaSizeWarning}
+            <p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p>
+          {/if}</label
+        >
+        <input
+          class="immich-form-input"
+          id="quotaSize"
+          name="quotaSize"
+          placeholder={$t('unlimited')}
+          type="number"
+          min="0"
+          bind:value={quotaSize}
+        />
+      </div>
+
+      <div class="my-4 flex flex-col gap-2">
+        <label class="immich-form-label" for="storage-label">{$t('storage_label')}</label>
+        <input
+          class="immich-form-input"
+          id="storage-label"
+          name="storage-label"
+          type="text"
+          bind:value={user.storageLabel}
+        />
+
+        <p>
+          {$t('admin.note_apply_storage_label_previous_assets')}
+          <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
+            {$t('admin.storage_template_migration_job')}
+          </a>
+        </p>
+      </div>
+    </form>
+  </ModalBody>
+
+  <ModalFooter>
+    <div class="flex gap-3 w-full">
+      {#if canResetPassword}
+        <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword}
+          >{$t('reset_password')}</Button
+        >
+      {/if}
+      <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
     </div>
-
-    <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="name">{$t('name')}</label>
-      <input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
-    </div>
-
-    <div class="my-4 flex flex-col gap-2">
-      <label class="flex items-center gap-2 immich-form-label" for="quotaSize">
-        {$t('admin.quota_size_gib')}
-        {#if quotaSizeWarning}
-          <p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p>
-        {/if}</label
-      >
-      <input
-        class="immich-form-input"
-        id="quotaSize"
-        name="quotaSize"
-        placeholder={$t('unlimited')}
-        type="number"
-        min="0"
-        bind:value={quotaSize}
-      />
-    </div>
-
-    <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="storage-label">{$t('storage_label')}</label>
-      <input
-        class="immich-form-input"
-        id="storage-label"
-        name="storage-label"
-        type="text"
-        bind:value={user.storageLabel}
-      />
-
-      <p>
-        {$t('admin.note_apply_storage_label_previous_assets')}
-        <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
-          {$t('admin.storage_template_migration_job')}
-        </a>
-      </p>
-    </div>
-  </form>
-
-  {#snippet stickyBottom()}
-    {#if canResetPassword}
-      <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword}
-        >{$t('reset_password')}</Button
-      >
-    {/if}
-    <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
-  {/snippet}
-</FullScreenModal>
+  </ModalFooter>
+</Modal>
diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
index 3053600a47..fbdec86244 100644
--- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte
+++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
@@ -1,9 +1,9 @@
 <script lang="ts">
-  import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
-  import { showDeleteModal } from '$lib/stores/preferences.store';
   import Checkbox from '$lib/components/elements/checkbox.svelte';
-  import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { showDeleteModal } from '$lib/stores/preferences.store';
+  import { t } from 'svelte-i18n';
+  import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
 
   interface Props {
     size: number;
@@ -26,8 +26,7 @@
 <ConfirmDialog
   title={$t('permanently_delete_assets_count', { values: { count: size } })}
   confirmText={$t('delete')}
-  onConfirm={handleConfirm}
-  {onCancel}
+  onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
 >
   {#snippet promptSnippet()}
     <p>
diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte
index ef682d9048..d6b575f772 100644
--- a/web/src/lib/components/shared-components/change-date.svelte
+++ b/web/src/lib/components/shared-components/change-date.svelte
@@ -1,9 +1,9 @@
 <script lang="ts">
   import { DateTime } from 'luxon';
-  import ConfirmDialog from './dialog/confirm-dialog.svelte';
-  import Combobox, { type ComboBoxOption } from './combobox.svelte';
-  import DateInput from '../elements/date-input.svelte';
   import { t } from 'svelte-i18n';
+  import DateInput from '../elements/date-input.svelte';
+  import Combobox, { type ComboBoxOption } from './combobox.svelte';
+  import ConfirmDialog from './dialog/confirm-dialog.svelte';
 
   interface Props {
     initialDate?: DateTime;
@@ -138,8 +138,7 @@
   title={$t('edit_date_and_time')}
   prompt="Please select a new date:"
   disabled={!date.isValid}
-  onConfirm={handleConfirm}
-  {onCancel}
+  onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
 >
   <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
   <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte
index f981e85029..3539945911 100644
--- a/web/src/lib/components/shared-components/change-location.svelte
+++ b/web/src/lib/components/shared-components/change-location.svelte
@@ -1,20 +1,20 @@
 <script lang="ts">
-  import ConfirmDialog from './dialog/confirm-dialog.svelte';
   import { timeDebounceOnSearch } from '$lib/constants';
-  import { handleError } from '$lib/utils/handle-error';
   import { lastChosenLocation } from '$lib/stores/asset-editor.store';
+  import { handleError } from '$lib/utils/handle-error';
+  import ConfirmDialog from './dialog/confirm-dialog.svelte';
 
   import { clickOutside } from '$lib/actions/click-outside';
-  import LoadingSpinner from './loading-spinner.svelte';
-  import { delay } from '$lib/utils/asset-utils';
-  import { timeToLoadTheMap } from '$lib/constants';
-  import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
-  import SearchBar from '../elements/search-bar.svelte';
   import { listNavigation } from '$lib/actions/list-navigation';
-  import { t } from 'svelte-i18n';
   import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
   import type Map from '$lib/components/shared-components/map/map.svelte';
+  import { timeToLoadTheMap } from '$lib/constants';
+  import { delay } from '$lib/utils/asset-utils';
+  import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
   import { get } from 'svelte/store';
+  import SearchBar from '../elements/search-bar.svelte';
+  import LoadingSpinner from './loading-spinner.svelte';
   interface Point {
     lng: number;
     lat: number;
@@ -112,7 +112,12 @@
   };
 </script>
 
-<ConfirmDialog confirmColor="primary" title={$t('change_location')} width="wide" onConfirm={handleConfirm} {onCancel}>
+<ConfirmDialog
+  confirmColor="primary"
+  title={$t('change_location')}
+  size="medium"
+  onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
+>
   {#snippet promptSnippet()}
     <div class="flex flex-col w-full h-full gap-2">
       <div class="relative w-64 sm:w-96">
diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
index dad16d52ca..75c07aebc6 100644
--- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
+++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
@@ -1,8 +1,7 @@
 <script lang="ts">
-  import FullScreenModal from '../full-screen-modal.svelte';
-  import { t } from 'svelte-i18n';
+  import { Button, Modal, ModalBody, ModalFooter, type Color } from '@immich/ui';
   import type { Snippet } from 'svelte';
-  import { Button, type Color } from '@immich/ui';
+  import { t } from 'svelte-i18n';
 
   interface Props {
     title?: string;
@@ -13,9 +12,8 @@
     cancelColor?: Color;
     hideCancelButton?: boolean;
     disabled?: boolean;
-    width?: 'wide' | 'narrow';
-    onCancel: () => void;
-    onConfirm: () => void;
+    size?: 'small' | 'medium';
+    onClose: (confirmed: boolean) => void;
     promptSnippet?: Snippet;
   }
 
@@ -28,32 +26,33 @@
     cancelColor = 'secondary',
     hideCancelButton = false,
     disabled = false,
-    width = 'narrow',
-    onCancel,
-    onConfirm,
+    size = 'small',
+    onClose,
     promptSnippet,
   }: Props = $props();
 
   const handleConfirm = () => {
-    onConfirm();
+    onClose(true);
   };
 </script>
 
-<FullScreenModal {title} onClose={onCancel} {width}>
-  <div class="text-md py-5 text-center">
+<Modal {title} onClose={() => onClose(false)} {size} class="bg-light text-dark">
+  <ModalBody>
     {#if promptSnippet}{@render promptSnippet()}{:else}
       <p>{prompt}</p>
     {/if}
-  </div>
+  </ModalBody>
 
-  {#snippet stickyBottom()}
-    {#if !hideCancelButton}
-      <Button shape="round" color={cancelColor} fullWidth onclick={onCancel}>
-        {cancelText}
+  <ModalFooter>
+    <div class="flex gap-3 w-full">
+      {#if !hideCancelButton}
+        <Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}>
+          {cancelText}
+        </Button>
+      {/if}
+      <Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}>
+        {confirmText}
       </Button>
-    {/if}
-    <Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}>
-      {confirmText}
-    </Button>
-  {/snippet}
-</FullScreenModal>
+    </div>
+  </ModalFooter>
+</Modal>
diff --git a/web/src/lib/components/shared-components/dialog/dialog.ts b/web/src/lib/components/shared-components/dialog/dialog.ts
index 8efff58da0..69a64aad21 100644
--- a/web/src/lib/components/shared-components/dialog/dialog.ts
+++ b/web/src/lib/components/shared-components/dialog/dialog.ts
@@ -1,8 +1,7 @@
 import { writable } from 'svelte/store';
 
 type DialogActions = {
-  onConfirm: () => void;
-  onCancel: () => void;
+  onClose: (confirmed: boolean) => void;
 };
 
 type DialogOptions = {
@@ -24,13 +23,9 @@ function createDialogWrapper() {
     return new Promise<boolean>((resolve) => {
       const newDialog: Dialog = {
         ...options,
-        onConfirm: () => {
+        onClose: (confirmed) => {
           dialog.set(undefined);
-          resolve(true);
-        },
-        onCancel: () => {
-          dialog.set(undefined);
-          resolve(false);
+          resolve(confirmed);
         },
       };
 
diff --git a/web/src/lib/forms/password-reset-success.svelte b/web/src/lib/forms/password-reset-success.svelte
new file mode 100644
index 0000000000..7091047eb8
--- /dev/null
+++ b/web/src/lib/forms/password-reset-success.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
+  import { copyToClipboard } from '$lib/utils';
+  import { Code, IconButton, Text } from '@immich/ui';
+  import { mdiContentCopy } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+
+  type Props = {
+    onClose: () => void;
+    newPassword: string;
+  };
+
+  const { onClose, newPassword }: Props = $props();
+</script>
+
+<ConfirmDialog
+  title={$t('password_reset_success')}
+  confirmText={$t('done')}
+  {onClose}
+  hideCancelButton={true}
+  confirmColor="success"
+>
+  {#snippet promptSnippet()}
+    <div class="flex flex-col gap-4">
+      <Text>{$t('admin.user_password_has_been_reset')}</Text>
+
+      <div class="flex justify-center gap-2 items-center">
+        <Code color="primary">{newPassword}</Code>
+        <IconButton
+          icon={mdiContentCopy}
+          shape="round"
+          color="secondary"
+          variant="ghost"
+          onclick={() => copyToClipboard(newPassword)}
+          title={$t('copy_password')}
+          aria-label={$t('copy_password')}
+        />
+      </div>
+
+      <Text>{$t('admin.user_password_reset_description')}</Text>
+    </div>
+  {/snippet}
+</ConfirmDialog>
diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts
new file mode 100644
index 0000000000..c8cefe8a58
--- /dev/null
+++ b/web/src/lib/managers/modal-manager.svelte.ts
@@ -0,0 +1,33 @@
+import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
+import { mount, unmount, type Component, type ComponentProps } from 'svelte';
+
+type OnCloseData<T> = T extends { onClose: (data: infer R) => void } ? R : never;
+// TODO make `props` optional if component only has `onClose`
+// type OptionalIfEmpty<T extends object> = keyof T extends never ? undefined : T;
+
+class ModalManager {
+  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: Omit<T, 'onClose'>) {
+    return new Promise<K>((resolve) => {
+      let modal: object = {};
+
+      const onClose = async (data: K) => {
+        await unmount(modal);
+        resolve(data);
+      };
+
+      modal = mount(Component, {
+        target: document.body,
+        props: {
+          ...(props as T),
+          onClose,
+        },
+      });
+    });
+  }
+
+  openDialog(options: Omit<ComponentProps<typeof ConfirmDialog>, 'onClose'>) {
+    return this.open(ConfirmDialog, options);
+  }
+}
+
+export const modalManager = new ModalManager();
diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte
index 07757614e5..af7f3d11af 100644
--- a/web/src/routes/admin/jobs-status/+page.svelte
+++ b/web/src/routes/admin/jobs-status/+page.svelte
@@ -102,8 +102,7 @@
     confirmColor="primary"
     title={$t('admin.create_job')}
     disabled={!selectedJob}
-    onConfirm={handleCreate}
-    onCancel={handleCancel}
+    onClose={(confirmed) => (confirmed ? handleCreate() : handleCancel())}
   >
     {#snippet promptSnippet()}
       <form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full">
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index a25799588a..42d1404177 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -6,20 +6,20 @@
   import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
   import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
-  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
   import {
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
+  import PasswordResetSuccess from '$lib/forms/password-reset-success.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
   import { locale } from '$lib/stores/preferences.store';
-  import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
+  import { serverConfig } from '$lib/stores/server-config.store';
   import { user } from '$lib/stores/user.store';
   import { websocketEvents } from '$lib/stores/websocket';
-  import { copyToClipboard } from '$lib/utils';
   import { getByteUnitString } from '$lib/utils/byte-units';
   import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
-  import { Button, Code, IconButton, Text } from '@immich/ui';
-  import { mdiContentCopy, mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
+  import { Button, IconButton } from '@immich/ui';
+  import { mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
   import { DateTime } from 'luxon';
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
@@ -32,13 +32,9 @@
   let { data }: Props = $props();
 
   let allUsers: UserAdminResponseDto[] = $state([]);
-  let shouldShowEditUserForm = $state(false);
-  let shouldShowCreateUserForm = $state(false);
-  let shouldShowPasswordResetSuccess = $state(false);
   let shouldShowDeleteConfirmDialog = $state(false);
   let shouldShowRestoreDialog = $state(false);
   let selectedUser = $state<UserAdminResponseDto>();
-  let newPassword = $state('');
 
   const refresh = async () => {
     allUsers = await searchUsersAdmin({ withDeleted: true });
@@ -65,25 +61,23 @@
     return DateTime.fromISO(deletedAt).plus({ days: $serverConfig.userDeleteDelay }).toJSDate();
   };
 
-  const onUserCreated = async () => {
+  const handleCreate = async () => {
+    await modalManager.open(CreateUserForm, {});
     await refresh();
-    shouldShowCreateUserForm = false;
   };
 
-  const editUserHandler = (user: UserAdminResponseDto) => {
-    selectedUser = user;
-    shouldShowEditUserForm = true;
-  };
-
-  const onEditUserSuccess = async () => {
-    await refresh();
-    shouldShowEditUserForm = false;
-  };
-
-  const onEditPasswordSuccess = async () => {
-    await refresh();
-    shouldShowEditUserForm = false;
-    shouldShowPasswordResetSuccess = true;
+  const handleEdit = async (dto: UserAdminResponseDto) => {
+    const result = await modalManager.open(EditUserForm, { user: dto, canResetPassword: dto.id !== $user.id });
+    switch (result?.action) {
+      case 'resetPassword': {
+        await modalManager.open(PasswordResetSuccess, { newPassword: result.data });
+        break;
+      }
+      case 'update': {
+        await refresh();
+        break;
+      }
+    }
   };
 
   const deleteUserHandler = (user: UserAdminResponseDto) => {
@@ -110,26 +104,6 @@
 <UserPageLayout title={data.meta.title} admin>
   <section id="setting-content" class="flex place-content-center sm:mx-4">
     <section class="w-full pb-28 lg:w-[850px]">
-      {#if shouldShowCreateUserForm}
-        <CreateUserForm
-          onSubmit={onUserCreated}
-          onCancel={() => (shouldShowCreateUserForm = false)}
-          onClose={() => (shouldShowCreateUserForm = false)}
-          oauthEnabled={$featureFlags.oauth}
-        />
-      {/if}
-
-      {#if shouldShowEditUserForm && selectedUser}
-        <EditUserForm
-          user={selectedUser}
-          bind:newPassword
-          canResetPassword={selectedUser?.id !== $user.id}
-          onEditSuccess={onEditUserSuccess}
-          onResetPasswordSuccess={onEditPasswordSuccess}
-          onClose={() => (shouldShowEditUserForm = false)}
-        />
-      {/if}
-
       {#if shouldShowDeleteConfirmDialog && selectedUser}
         <DeleteConfirmDialog
           user={selectedUser}
@@ -148,38 +122,6 @@
         />
       {/if}
 
-      {#if shouldShowPasswordResetSuccess}
-        <ConfirmDialog
-          title={$t('password_reset_success')}
-          confirmText={$t('done')}
-          onConfirm={() => (shouldShowPasswordResetSuccess = false)}
-          onCancel={() => (shouldShowPasswordResetSuccess = false)}
-          hideCancelButton={true}
-          confirmColor="success"
-        >
-          {#snippet promptSnippet()}
-            <div class="flex flex-col gap-4">
-              <Text>{$t('admin.user_password_has_been_reset')}</Text>
-
-              <div class="flex justify-center gap-2 items-center">
-                <Code color="primary">{newPassword}</Code>
-                <IconButton
-                  icon={mdiContentCopy}
-                  shape="round"
-                  color="secondary"
-                  variant="ghost"
-                  onclick={() => copyToClipboard(newPassword)}
-                  title={$t('copy_password')}
-                  aria-label={$t('copy_password')}
-                />
-              </div>
-
-              <Text>{$t('admin.user_password_reset_description')}</Text>
-            </div>
-          {/snippet}
-        </ConfirmDialog>
-      {/if}
-
       <table class="my-5 w-full text-start">
         <thead
           class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
@@ -225,7 +167,7 @@
                       size="small"
                       icon={mdiPencilOutline}
                       title={$t('edit_user')}
-                      onclick={() => editUserHandler(immichUser)}
+                      onclick={() => handleEdit(immichUser)}
                       aria-label={$t('edit_user')}
                     />
                     {#if immichUser.id !== $user.id}
@@ -257,7 +199,7 @@
           {/if}
         </tbody>
       </table>
-      <Button shape="round" size="small" onclick={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button>
+      <Button shape="round" size="small" onclick={handleCreate}>{$t('create_user')}</Button>
     </section>
   </section>
 </UserPageLayout>
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 95611d486d..2e13e5997d 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -36,7 +36,7 @@ export default {
         danger: 'rgb(var(--immich-ui-danger) / <alpha-value>)',
         warning: 'rgb(var(--immich-ui-warning) / <alpha-value>)',
         info: 'rgb(var(--immich-ui-info) / <alpha-value>)',
-        subtle: 'rgb(var(--immich-gray) / <alpha-value>)',
+        subtle: 'rgb(var(--immich-ui-gray) / <alpha-value>)',
       },
       borderColor: ({ theme }) => ({
         ...theme('colors'),