diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index 7ea6aa5b2a4..564f32e2573 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -24,6 +24,7 @@
"lucide-react": "0.513.0",
"moment": "2.30.1",
"next": "16.2.11",
+ "next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
@@ -9805,6 +9806,16 @@
}
}
},
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json
index 6b2d4e6106b..ff6448ad75c 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -40,6 +40,7 @@
"lucide-react": "0.513.0",
"moment": "2.30.1",
"next": "16.2.11",
+ "next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx
index 6ffc2a68f4f..60db7104a03 100644
--- a/ui/litellm-dashboard/src/app/layout.tsx
+++ b/ui/litellm-dashboard/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { NuqsAdapter } from "nuqs/adapters/next/app";
+import { ThemeProvider } from "next-themes";
import { AuthProvider } from "@/contexts/AuthContext";
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
@@ -22,14 +23,18 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+ // next-themes stamps the theme class on before paint, which the exported markup
+ // cannot predict; suppressHydrationWarning confines that mismatch to this element.
+
-
-
- {children}
-
-
-
+
+
+
+ {children}
+
+
+
+
);
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
index 7f474baafe9..31f2a0b715a 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
@@ -14,6 +14,7 @@ import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
+import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
@@ -73,6 +74,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
{!hideCommunityLinks && }
+
diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
new file mode 100644
index 00000000000..3f7af94bfa0
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ThemeProvider } from "next-themes";
+import { afterAll, beforeEach, describe, expect, it } from "vitest";
+import ThemeToggle from "./ThemeToggle";
+
+const renderToggle = () =>
+ render(
+
+
+ ,
+ );
+
+const openMenu = async () => {
+ await userEvent.click(screen.getByRole("button", { name: "Theme" }));
+ await screen.findByRole("menu");
+};
+
+const pick = async (label: string) => userEvent.click(screen.getByRole("menuitemradio", { name: label }));
+
+beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.classList.remove("dark", "light");
+});
+
+afterAll(() => {
+ document.documentElement.classList.remove("dark", "light");
+});
+
+describe("ThemeToggle", () => {
+ it("starts on light rather than following the system preference", async () => {
+ renderToggle();
+ await openMenu();
+
+ expect(screen.getByRole("menuitemradio", { name: "Light" })).toBeChecked();
+ expect(screen.getByRole("menuitemradio", { name: "Dark" })).not.toBeChecked();
+ expect(screen.getByRole("menuitemradio", { name: "System" })).not.toBeChecked();
+ });
+
+ it("puts the dark class on the document and remembers the choice", async () => {
+ renderToggle();
+ await openMenu();
+
+ await pick("Dark");
+
+ expect(document.documentElement).toHaveClass("dark");
+ expect(localStorage.getItem("theme")).toBe("dark");
+ });
+
+ it("hands control back to the system preference when asked", async () => {
+ renderToggle();
+ await openMenu();
+ await pick("Dark");
+
+ await pick("System");
+
+ expect(localStorage.getItem("theme")).toBe("system");
+ expect(document.documentElement).not.toHaveClass("dark");
+ });
+
+ it("flags dark mode as experimental, and only while it is on", async () => {
+ renderToggle();
+ await openMenu();
+ expect(screen.queryByText("Experimental")).not.toBeInTheDocument();
+
+ await pick("Dark");
+ expect(screen.getByText("Experimental")).toBeInTheDocument();
+
+ await pick("Light");
+ expect(screen.queryByText("Experimental")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
new file mode 100644
index 00000000000..6a006141523
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Monitor, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import React from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const THEMES = [
+ { value: "system", label: "System", Icon: Monitor },
+ { value: "light", label: "Light", Icon: Sun },
+ { value: "dark", label: "Dark", Icon: Moon },
+] as const;
+
+const ThemeToggle: React.FC = () => {
+ const { theme, setTheme, resolvedTheme } = useTheme();
+ const isDark = resolvedTheme === "dark";
+
+ return (
+
+ {isDark && (
+
+ Experimental
+
+ )}
+
+
+ }
+ >
+ {isDark ? : }
+
+
+
+ {THEMES.map(({ value, label, Icon }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default ThemeToggle;
diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx
index 96cd2f2e7e0..e33ec404916 100644
--- a/ui/litellm-dashboard/src/components/navbar.test.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.test.tsx
@@ -157,6 +157,21 @@ describe("Navbar", () => {
expect(screen.getByRole("link", { name: /litellm brand/i })).toHaveAttribute("href", "/ui");
});
+ it("pairs the logo with a dark-mode variant that swaps on the dark class", () => {
+ renderWithProviders();
+
+ const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm brand/i }).querySelectorAll("img"));
+ const classesOf = (el: Element) => new Set(el.className.split(/\s+/));
+
+ const lightSrc = light.getAttribute("src") ?? "";
+ expect(light).toHaveAttribute("src", expect.stringMatching(/\/get_image$/));
+ expect(dark).toHaveAttribute("src", `${lightSrc}?theme=dark`);
+ expect(classesOf(light).has("dark:hidden")).toBe(true);
+ expect(classesOf(light).has("hidden")).toBe(false);
+ expect(classesOf(dark).has("hidden")).toBe(true);
+ expect(classesOf(dark).has("dark:block")).toBe(true);
+ });
+
it("should display user information in dropdown", async () => {
const user = userEvent.setup();
renderWithProviders();
diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx
index 2b1568127a6..0cd3a0d44e5 100644
--- a/ui/litellm-dashboard/src/components/navbar.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.tsx
@@ -15,8 +15,10 @@ import React from "react";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
+import { cn } from "@/lib/cva.config";
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
+import ThemeToggle from "./ThemeToggle/ThemeToggle";
import ViewSwitcher from "./Navbar/ViewSwitcher";
import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown";
@@ -27,6 +29,8 @@ interface NavbarProps {
onToggleSidebar?: () => void;
}
+const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain";
+
const Navbar: React.FC = ({
accessToken,
isPublicPage = false,
@@ -44,6 +48,7 @@ const Navbar: React.FC = ({
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const imageUrl = logoUrl || `${baseUrl}/get_image`;
+ const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`;
const handleLogout = () => {
clearTokenCookies();
@@ -85,10 +90,12 @@ const Navbar: React.FC = ({
+
@@ -158,6 +165,8 @@ const Navbar: React.FC = ({
{!isPublicPage && (
+
+
@@ -165,7 +174,6 @@ const Navbar: React.FC = ({
)}
- {/* Dark mode toggle: keep disabled until the dashboard supports dark styles end-to-end. */}
diff --git a/ui/litellm-dashboard/src/components/ui/sonner.tsx b/ui/litellm-dashboard/src/components/ui/sonner.tsx
index 034e093a118..5c55557b024 100644
--- a/ui/litellm-dashboard/src/components/ui/sonner.tsx
+++ b/ui/litellm-dashboard/src/components/ui/sonner.tsx
@@ -1,12 +1,15 @@
"use client";
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
+import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
function Toaster({ ...props }: ToasterProps) {
+ const { resolvedTheme } = useTheme();
+
return (
{
- document.documentElement.classList.remove("dark");
-});
-
-afterAll(() => {
- document.documentElement.classList.remove("dark");
-});
-
-describe("useIsDarkMode", () => {
- it("reports the dark class already on the root element at mount", () => {
- document.documentElement.classList.add("dark");
-
- const { result } = renderHook(() => useIsDarkMode());
-
- expect(result.current).toBe(true);
- });
-
- it("follows the root element's dark class as it is toggled", async () => {
- const { result } = renderHook(() => useIsDarkMode());
- expect(result.current).toBe(false);
-
- document.documentElement.classList.add("dark");
- await waitFor(() => expect(result.current).toBe(true));
-
- document.documentElement.classList.remove("dark");
- await waitFor(() => expect(result.current).toBe(false));
- });
-
- it("stops observing the root element once unmounted", () => {
- const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
-
- const { unmount } = renderHook(() => useIsDarkMode());
- unmount();
-
- expect(disconnect).toHaveBeenCalled();
- disconnect.mockRestore();
- });
-});
diff --git a/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts b/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts
deleted file mode 100644
index bccaa31cb3a..00000000000
--- a/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useSyncExternalStore } from "react";
-
-const subscribe = (onStoreChange: () => void): (() => void) => {
- const observer = new MutationObserver(onStoreChange);
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
- return () => observer.disconnect();
-};
-
-const getSnapshot = (): boolean => document.documentElement.classList.contains("dark");
-
-const getServerSnapshot = (): boolean => false;
-
-export const useIsDarkMode = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
diff --git a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx
index 13b7d8136ef..53a152e6f44 100644
--- a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx
+++ b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx
@@ -1,47 +1,50 @@
import { act, renderHook } from "@testing-library/react";
+import { ThemeProvider, useTheme } from "next-themes";
+import type { ReactNode } from "react";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { useSyntaxTheme, type SyntaxTheme } from "./useSyntaxTheme";
const callerLightTheme: SyntaxTheme = { 'code[class*="language-"]': { color: "rebeccapurple" } };
-const setRootDark = async (enabled: boolean) => {
- await act(async () => {
- document.documentElement.classList.toggle("dark", enabled);
- await Promise.resolve();
+const renderSyntaxTheme = (defaultTheme: string) =>
+ renderHook(() => ({ syntax: useSyntaxTheme(callerLightTheme), setTheme: useTheme().setTheme }), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
});
-};
beforeEach(() => {
- document.documentElement.classList.remove("dark");
+ localStorage.clear();
+ document.documentElement.classList.remove("dark", "light");
});
afterAll(() => {
- document.documentElement.classList.remove("dark");
+ document.documentElement.classList.remove("dark", "light");
});
describe("useSyntaxTheme", () => {
it("keeps the caller's own stylesheet in light mode", () => {
- const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
+ const { result } = renderSyntaxTheme("light");
- expect(result.current).toBe(callerLightTheme);
+ expect(result.current.syntax).toBe(callerLightTheme);
});
- it("swaps to oneDark when the root element turns dark", async () => {
- const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
+ it("serves oneDark when the resolved theme is dark", () => {
+ const { result } = renderSyntaxTheme("dark");
- await setRootDark(true);
-
- expect(result.current).toBe(oneDark);
+ expect(result.current.syntax).toBe(oneDark);
});
- it("restores the caller's stylesheet when dark mode is turned back off", async () => {
- document.documentElement.classList.add("dark");
- const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
- expect(result.current).toBe(oneDark);
+ it("swaps stylesheets when the theme is changed at runtime", () => {
+ const { result } = renderSyntaxTheme("light");
- await setRootDark(false);
+ act(() => result.current.setTheme("dark"));
+ expect(result.current.syntax).toBe(oneDark);
- expect(result.current).toBe(callerLightTheme);
+ act(() => result.current.setTheme("light"));
+ expect(result.current.syntax).toBe(callerLightTheme);
});
});
diff --git a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts
index 80f5b514751..d107aa9ed18 100644
--- a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts
+++ b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts
@@ -1,8 +1,8 @@
import type { CSSProperties } from "react";
+import { useTheme } from "next-themes";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
-import { useIsDarkMode } from "./useIsDarkMode";
-
export type SyntaxTheme = Record;
-export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => (useIsDarkMode() ? oneDark : light);
+export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme =>
+ useTheme().resolvedTheme === "dark" ? oneDark : light;