diff --git a/web/src/App.tsx b/web/src/App.tsx
index a5afdcc93..8ad767e5b 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -5,7 +5,7 @@ import { Outlet } from "react-router-dom";
import useNavigateTo from "./hooks/useNavigateTo";
import { userStore, instanceStore } from "./store";
import { cleanupExpiredOAuthState } from "./utils/oauth";
-import { loadTheme } from "./utils/theme";
+import { loadTheme, setupSystemThemeListener } from "./utils/theme";
const App = observer(() => {
const { i18n } = useTranslation();
@@ -85,6 +85,25 @@ const App = observer(() => {
}
}, [userGeneralSetting?.theme, instanceStore.state.theme]);
+ // Listen for system theme changes when using "system" theme
+ useEffect(() => {
+ const currentTheme = userGeneralSetting?.theme || instanceStore.state.theme;
+
+ // Only set up listener if theme is "system"
+ if (currentTheme !== "system") {
+ return;
+ }
+
+ // Set up listener for OS theme preference changes
+ const cleanup = setupSystemThemeListener(() => {
+ // Reload theme when system preference changes
+ loadTheme(currentTheme);
+ });
+
+ // Cleanup listener on unmount or when theme changes
+ return cleanup;
+ }, [userGeneralSetting?.theme, instanceStore.state.theme]);
+
return ;
});
diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx
index 5df3f42ad..3d1353d75 100644
--- a/web/src/components/Settings/PreferencesSection.tsx
+++ b/web/src/components/Settings/PreferencesSection.tsx
@@ -27,6 +27,9 @@ const PreferencesSection = observer(() => {
};
const handleThemeChange = async (theme: string) => {
+ // Update instance store immediately for instant UI feedback
+ instanceStore.state.setPartial({ theme });
+ // Persist to user settings
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
};
@@ -34,7 +37,7 @@ const PreferencesSection = observer(() => {
const setting: UserSetting_GeneralSetting = generalSetting || {
locale: "en",
memoVisibility: "PRIVATE",
- theme: "default",
+ theme: "system",
};
return (
diff --git a/web/src/components/ThemeSelect.tsx b/web/src/components/ThemeSelect.tsx
index d9f2fd1bc..623a5d04c 100644
--- a/web/src/components/ThemeSelect.tsx
+++ b/web/src/components/ThemeSelect.tsx
@@ -1,4 +1,4 @@
-import { Moon, Palette, Sun, Wallpaper } from "lucide-react";
+import { Moon, Monitor, Palette, Sun, Wallpaper } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { instanceStore } from "@/store";
import { THEME_OPTIONS } from "@/utils/theme";
@@ -10,6 +10,7 @@ interface ThemeSelectProps {
}
const THEME_ICONS: Record = {
+ system: ,
default: ,
"default-dark": ,
paper: ,
@@ -17,7 +18,7 @@ const THEME_ICONS: Record = {
};
const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => {
- const currentTheme = value || instanceStore.state.theme || "default";
+ const currentTheme = value || instanceStore.state.theme || "system";
const handleThemeChange = (newTheme: Theme) => {
if (onValueChange) {
diff --git a/web/src/store/instance.ts b/web/src/store/instance.ts
index b02a8ae4a..06787913a 100644
--- a/web/src/store/instance.ts
+++ b/web/src/store/instance.ts
@@ -17,7 +17,7 @@ import { createRequestKey } from "./store-utils";
/**
* Valid theme options
*/
-const VALID_THEMES = ["default", "default-dark", "paper", "whitewall"] as const;
+const VALID_THEMES = ["system", "default", "default-dark", "paper", "whitewall"] as const;
export type Theme = (typeof VALID_THEMES)[number];
/**
@@ -40,7 +40,7 @@ class InstanceState extends StandardState {
* Current theme
* Note: Accepts string for flexibility, but validates to Theme
*/
- theme: Theme | string = "default";
+ theme: Theme | string = "system";
/**
* Instance profile containing owner and metadata
@@ -249,7 +249,7 @@ export const initialInstanceStore = async (): Promise => {
const instanceGeneralSetting = instanceStore.state.generalSetting;
instanceStore.state.setPartial({
locale: instanceGeneralSetting.customProfile?.locale || "en",
- theme: "default",
+ theme: instanceGeneralSetting.theme || "system",
profile: instanceProfile,
});
} catch (error) {
@@ -257,7 +257,7 @@ export const initialInstanceStore = async (): Promise => {
// Set default fallback values
instanceStore.state.setPartial({
locale: "en",
- theme: "default",
+ theme: "system",
});
}
};
diff --git a/web/src/types/modules/setting.d.ts b/web/src/types/modules/setting.d.ts
index 456c428a8..ffe65b304 100644
--- a/web/src/types/modules/setting.d.ts
+++ b/web/src/types/modules/setting.d.ts
@@ -1 +1 @@
-type Theme = "default" | "default-dark" | "paper" | "whitewall";
+type Theme = "system" | "default" | "default-dark" | "paper" | "whitewall";
diff --git a/web/src/utils/theme.ts b/web/src/utils/theme.ts
index e4e80a2e0..d270c3a79 100644
--- a/web/src/utils/theme.ts
+++ b/web/src/utils/theme.ts
@@ -2,10 +2,11 @@ import defaultDarkThemeContent from "../themes/default-dark.css?raw";
import paperThemeContent from "../themes/paper.css?raw";
import whitewallThemeContent from "../themes/whitewall.css?raw";
-const VALID_THEMES = ["default", "default-dark", "paper", "whitewall"] as const;
+const VALID_THEMES = ["system", "default", "default-dark", "paper", "whitewall"] as const;
type ValidTheme = (typeof VALID_THEMES)[number];
const THEME_CONTENT: Record = {
+ system: null, // System theme dynamically chooses between default and default-dark
default: null,
"default-dark": defaultDarkThemeContent,
paper: paperThemeContent,
@@ -18,8 +19,9 @@ export interface ThemeOption {
}
export const THEME_OPTIONS: ThemeOption[] = [
- { value: "default", label: "Default Light" },
- { value: "default-dark", label: "Default Dark" },
+ { value: "system", label: "Sync with system" },
+ { value: "default", label: "Light" },
+ { value: "default-dark", label: "Dark" },
{ value: "paper", label: "Paper" },
{ value: "whitewall", label: "Whitewall" },
];
@@ -38,6 +40,18 @@ export const getSystemTheme = (): "default" | "default-dark" => {
return "default";
};
+/**
+ * Resolves the actual theme to apply based on user preference
+ * If theme is "system", returns the system preference, otherwise returns the theme as-is
+ */
+export const resolveTheme = (theme: string): "default" | "default-dark" | "paper" | "whitewall" => {
+ if (theme === "system") {
+ return getSystemTheme();
+ }
+ const validTheme = validateTheme(theme);
+ return validTheme === "system" ? getSystemTheme() : validTheme;
+};
+
/**
* Gets the theme that should be applied on initial load
* Priority: stored user preference -> system preference -> default
@@ -53,8 +67,8 @@ export const getInitialTheme = (): ValidTheme => {
// localStorage might not be available
}
- // Fall back to system preference
- return getSystemTheme();
+ // Fall back to system preference (return "system" to enable auto-switching)
+ return "system";
};
/**
@@ -68,12 +82,15 @@ export const applyThemeEarly = (): void => {
export const loadTheme = (themeName: string): void => {
const validTheme = validateTheme(themeName);
+ // Resolve "system" to actual theme based on OS preference
+ const resolvedTheme = resolveTheme(validTheme);
+
// Remove existing theme
document.getElementById("instance-theme")?.remove();
// Apply theme (skip for default)
- if (validTheme !== "default") {
- const css = THEME_CONTENT[validTheme];
+ if (resolvedTheme !== "default") {
+ const css = THEME_CONTENT[resolvedTheme];
if (css) {
const style = document.createElement("style");
style.id = "instance-theme";
@@ -82,13 +99,44 @@ export const loadTheme = (themeName: string): void => {
}
}
- // Set data attribute
- document.documentElement.setAttribute("data-theme", validTheme);
+ // Set data attribute with resolved theme
+ document.documentElement.setAttribute("data-theme", resolvedTheme);
- // Store theme preference for future loads
+ // Store theme preference (original, not resolved) for future loads
try {
localStorage.setItem("memos-theme", validTheme);
} catch {
// localStorage might not be available
}
};
+
+/**
+ * Sets up a listener for system theme preference changes
+ * Returns a cleanup function to remove the listener
+ */
+export const setupSystemThemeListener = (onThemeChange: () => void): (() => void) => {
+ if (typeof window === "undefined" || !window.matchMedia) {
+ return () => {}; // No-op cleanup
+ }
+
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+
+ // Handle theme change
+ const handleChange = () => {
+ onThemeChange();
+ };
+
+ // Modern API (addEventListener)
+ if (mediaQuery.addEventListener) {
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ }
+
+ // Legacy API (addListener) - for older browsers
+ if (mediaQuery.addListener) {
+ mediaQuery.addListener(handleChange);
+ return () => mediaQuery.removeListener(handleChange);
+ }
+
+ return () => {}; // No-op cleanup
+};