From 933e28d900521dee6dc81cd7dc25cbbc920dfd72 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 20 Aug 2026 12:58:29 -0700 Subject: [PATCH] feat(ui): add a light/dark/system theme toggle to the top bar (#37669) * feat(ui): add a light/dark/system theme toggle The dashboard already carried a full `.dark` palette, dark-aware surfaces and a dark logo variant, but nothing ever put the `dark` class on the document, so none of it could be reached. next-themes now owns that class: it reads the stored choice, falls back to the OS preference, and stamps the class from an inline script before first paint so there is no light flash on load. The toggle is a three-way System / Light / Dark control in the account menu, in both the sidebar menu and the older navbar dropdown, so it is reachable from the gateway dashboard, chat and the model hub alike. useIsDarkMode watched the root element with a MutationObserver purely to answer a question next-themes now answers directly, so it goes, and useSyntaxTheme reads resolvedTheme instead. The toaster follows the resolved theme too. * feat(ui): move the theme control to the top bar and default to light The toggle now lives in the header toolbar of both shells, the gateway dashboard's DashboardHeader and the older full-width Navbar, where it replaces the placeholder comment that had been holding its spot. It reads better there as a single icon button with a System / Light / Dark menu than as a segmented row buried in the account popover, so the account menus lose their theme row. Dark mode is still being rolled out, so an install that has never touched the control now stays light instead of following the OS. System is still a choice, just no longer the default. While dark is active the toolbar carries a small Experimental badge, so nobody mistakes an unstyled surface for a bug. * fix(ui): serve the dark logo in the legacy navbar too The sidebar already paired its logo with a dark variant, but the full-width navbar kept a single light-only image. That did not matter while dark mode was unreachable; now that the toggle sits in that shell's own top bar, the white JPEG slab lands on a dark bar. It gets the same two-image swap the sidebar uses, and a test that pins the pairing so the two shells cannot drift apart again. --- ui/litellm-dashboard/package-lock.json | 11 +++ ui/litellm-dashboard/package.json | 1 + ui/litellm-dashboard/src/app/layout.tsx | 19 +++-- .../src/components/DashboardHeader.tsx | 2 + .../ThemeToggle/ThemeToggle.test.tsx | 72 +++++++++++++++++++ .../components/ThemeToggle/ThemeToggle.tsx | 61 ++++++++++++++++ .../src/components/navbar.test.tsx | 15 ++++ .../src/components/navbar.tsx | 16 +++-- .../src/components/ui/sonner.tsx | 5 +- .../src/hooks/useIsDarkMode.test.tsx | 42 ----------- .../src/hooks/useIsDarkMode.ts | 13 ---- .../src/hooks/useSyntaxTheme.test.tsx | 43 +++++------ .../src/hooks/useSyntaxTheme.ts | 6 +- 13 files changed, 216 insertions(+), 90 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx delete mode 100644 ui/litellm-dashboard/src/hooks/useIsDarkMode.test.tsx delete mode 100644 ui/litellm-dashboard/src/hooks/useIsDarkMode.ts 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 = ({
+ LiteLLM Brand LiteLLM Brand
@@ -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;