diff --git a/apps/web/src/components/ThemeProvider.tsx b/apps/web/src/components/ThemeProvider.tsx
new file mode 100644
index 00000000..ddd1a1bb
--- /dev/null
+++ b/apps/web/src/components/ThemeProvider.tsx
@@ -0,0 +1,89 @@
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+
+export type ThemePreference = "system" | "light" | "dark";
+export type ResolvedTheme = "light" | "dark";
+
+interface ThemeContextValue {
+ preference: ThemePreference;
+ resolvedTheme: ResolvedTheme;
+ setPreference: (preference: ThemePreference) => void;
+}
+
+interface ThemeProviderProps {
+ children: ReactNode;
+}
+
+const THEME_STORAGE_KEY = "edgeever.theme";
+const LIGHT_THEME_COLOR = "#f8fafc";
+const DARK_THEME_COLOR = "#0f172a";
+const ThemeContext = createContext
(null);
+
+const getSystemTheme = () =>
+ typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+
+const resolveTheme = (preference: ThemePreference): ResolvedTheme =>
+ preference === "system" ? getSystemTheme() : preference;
+
+export const getStoredThemePreference = (): ThemePreference => {
+ if (typeof window === "undefined") {
+ return "system";
+ }
+
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
+ return stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
+};
+
+const applyThemeToDocument = (preference: ThemePreference) => {
+ const resolvedTheme = resolveTheme(preference);
+ const root = document.documentElement;
+ root.classList.toggle("dark", resolvedTheme === "dark");
+ root.style.colorScheme = resolvedTheme;
+
+ const themeColor = document.querySelector('meta[name="theme-color"]');
+ themeColor?.setAttribute("content", resolvedTheme === "dark" ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
+ return resolvedTheme;
+};
+
+export const initializeTheme = () => {
+ applyThemeToDocument(getStoredThemePreference());
+};
+
+export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+ const [preference, setPreferenceState] = useState(getStoredThemePreference);
+ const [resolvedTheme, setResolvedTheme] = useState(() => resolveTheme(preference));
+
+ useEffect(() => {
+ setResolvedTheme(applyThemeToDocument(preference));
+ window.localStorage.setItem(THEME_STORAGE_KEY, preference);
+
+ if (preference !== "system") {
+ return undefined;
+ }
+
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const handleSystemThemeChange = () => setResolvedTheme(applyThemeToDocument("system"));
+ mediaQuery.addEventListener("change", handleSystemThemeChange);
+ return () => mediaQuery.removeEventListener("change", handleSystemThemeChange);
+ }, [preference]);
+
+ const value = useMemo(
+ () => ({
+ preference,
+ resolvedTheme,
+ setPreference: (nextPreference: ThemePreference) => setPreferenceState(nextPreference),
+ }),
+ [preference, resolvedTheme]
+ );
+
+ return {children};
+};
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+
+ if (!context) {
+ throw new Error("useTheme must be used within ThemeProvider");
+ }
+
+ return context;
+};
diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx
new file mode 100644
index 00000000..e44c9848
--- /dev/null
+++ b/apps/web/src/components/ThemeToggle.tsx
@@ -0,0 +1,24 @@
+import { MoonStar, SunMedium } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { useTheme } from "./ThemeProvider";
+
+export const ThemeToggle = () => {
+ const { t } = useTranslation();
+ const { resolvedTheme, setPreference } = useTheme();
+ const nextTheme = resolvedTheme === "dark" ? "light" : "dark";
+ const label = nextTheme === "dark" ? t("settings.themeToggleToDark") : t("settings.themeToggleToLight");
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/components/settings/PreferenceCard.tsx b/apps/web/src/components/settings/PreferenceCard.tsx
index 96314728..498141c3 100644
--- a/apps/web/src/components/settings/PreferenceCard.tsx
+++ b/apps/web/src/components/settings/PreferenceCard.tsx
@@ -13,6 +13,7 @@ import {
type AppLocalePreference,
} from "@/i18n";
import { ShortcutSettingsItem } from "./ShortcutSettingsItem";
+import { useTheme, type ThemePreference } from "../ThemeProvider";
interface PreferenceCardProps {
imageCompressionEnabled: boolean;
@@ -28,6 +29,7 @@ export const PreferenceCard = ({
onShortcutSettingsChange,
}: PreferenceCardProps) => {
const { t } = useTranslation();
+ const { preference: themePreference, setPreference: setThemePreference } = useTheme();
const [activeLocalePreference, setActiveLocalePreference] = useState(() => getAppLocalePreference());
const handleLocalePreferenceChange = (preference: AppLocalePreference) => {
@@ -82,6 +84,28 @@ export const PreferenceCard = ({
/>
+
+
+
+
{t("settings.themeTitle")}
+
{t("settings.themeDescription")}
+
+
+
+
+