Last active
December 8, 2025 21:29
-
-
Save MarkyJD/a0fb5d4c8b0396aaf858c0982125f2c4 to your computer and use it in GitHub Desktop.
Color themes with Next.js and Shadcn UI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ! The ColorTehemeProvider must be wrapped with <ThemeProvider /> using next-themes to be able to work with light and dark themes | |
| "use client"; | |
| import { createContext, useContext, useEffect, useMemo, useState } from "react"; | |
| // To add more themes: | |
| // add css variables to app/globals.css. With the format '.className.light' and '.className.dark'. | |
| // Then add the className to this THEMES array | |
| const THEMES = [ | |
| "default", // There is no defined .'default' css class, so when it is selected the base styles from shadcn are used | |
| "dracula", | |
| // "chocolate" | |
| // "slate", | |
| // "gold", | |
| // "nature", | |
| // "nord", | |
| // "netflix", | |
| // "winter", | |
| // "orange", | |
| // "rose", | |
| // "yellow", | |
| // "green", | |
| ] as const; | |
| // Generate the type from the THEMES array | |
| export type ThemeType = (typeof THEMES)[number]; | |
| // ColorThemeContextProps | |
| export interface ColorThemeContextProps { | |
| availableThemes: ThemeType[]; | |
| colorTheme: ThemeType; | |
| setColorTheme: (theme: ThemeType) => void; | |
| } | |
| // ColorThemeProvider | |
| export const ColorThemeContext = createContext< | |
| ColorThemeContextProps | undefined | |
| >(undefined); | |
| /** | |
| * The ColorThemeProvider component provides the color theme to its children. | |
| * It stores the color theme in local storage and sets it on the document element. | |
| * It also provides a way to change the color theme. | |
| * | |
| * @param children The children that will receive the color theme. | |
| * @returns The children wrapped in a ColorThemeContext Provider. | |
| */ | |
| export default function ColorThemeProvider({ | |
| children, | |
| }: { children: React.ReactNode }) { | |
| const availableThemes = useMemo(() => [...THEMES] as ThemeType[], []); | |
| /** | |
| * Gets the saved color theme from local storage. | |
| * If there is an error, or no theme is saved, returns "default". | |
| */ | |
| const getSavedTheme = () => { | |
| try { | |
| return localStorage.getItem("colorTheme") || "default"; | |
| } catch (error) { | |
| return "defualt"; | |
| } | |
| }; | |
| const [colorTheme, setColorTheme] = useState(getSavedTheme() as ThemeType); | |
| const [isMounted, setIsMounted] = useState(false); | |
| useEffect(() => { | |
| // Set new theme to local storage | |
| localStorage.setItem("colorTheme", colorTheme); | |
| // Remove old theme | |
| for (const existingTheme in availableThemes) { | |
| document.documentElement.classList.remove( | |
| `${availableThemes[existingTheme]}`, | |
| ); | |
| } | |
| // Add new theme | |
| document.documentElement.classList.add(`${colorTheme}`); | |
| if (!isMounted) { | |
| setIsMounted(true); | |
| } | |
| }, [colorTheme]); | |
| // This prevents the flicker that happens as the colorTheme is loaded | |
| if (!isMounted) return null; | |
| return ( | |
| <ColorThemeContext.Provider | |
| value={{ colorTheme, setColorTheme, availableThemes }} | |
| > | |
| {children} | |
| </ColorThemeContext.Provider> | |
| ); | |
| } | |
| /** | |
| * Hook to get the current color theme and a function to change it. | |
| * It must be used within a {@link ColorThemeProvider}. | |
| * | |
| * @returns An object with the current color theme, a function to change it, and an array of available themes. | |
| */ | |
| export function useColorTheme(): ColorThemeContextProps { | |
| const context = useContext(ColorThemeContext); | |
| if (!context) { | |
| throw new Error("useColorTheme must be used within a ColorThemeProvider"); | |
| } | |
| return context; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { useColorTheme } from "@/providers/color-theme-provider"; | |
| import { CheckIcon } from "@radix-ui/react-icons"; | |
| import { FaPalette } from "react-icons/fa"; | |
| // This typically lives in a @lib/utils file | |
| // I have added here specifically for this gist | |
| export function capitilizeFirstLetter(word: string) { | |
| if (!word) return word; | |
| return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); | |
| } | |
| /** | |
| * A dropdown menu that allows the user to change the color theme of the app. | |
| * | |
| * @returns A dropdown menu with a button to toggle the menu and a list of | |
| * themes. When a theme is selected, the theme is changed and the menu is | |
| * closed. | |
| */ | |
| export function ColorThemeToggle() { | |
| const { colorTheme, setColorTheme, availableThemes } = useColorTheme(); | |
| return ( | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button variant="outline" size="icon"> | |
| <FaPalette /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end"> | |
| {availableThemes.map((theme) => ( | |
| <DropdownMenuItem key={theme} onClick={() => setColorTheme(theme)}> | |
| <div className="flex w-full items-center justify-between"> | |
| <span>{capitilizeFirstLetter(theme)}</span> | |
| {colorTheme === theme && <CheckIcon />} | |
| </div> | |
| </DropdownMenuItem> | |
| ))} | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // This is directly copied from Shadcn UI's official guide: https://ui.shadcn.com/docs/dark-mode/next | |
| // I added the check icon | |
| "use client"; | |
| import { CheckIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons"; | |
| import { useTheme } from "next-themes"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| export function DarkModeToggle() { | |
| const { setTheme, theme } = useTheme(); | |
| return ( | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button variant="outline" size="icon"> | |
| <SunIcon className="dark:-rotate-90 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:scale-0" /> | |
| <MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> | |
| <span className="sr-only">Toggle theme</span> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end"> | |
| <DropdownMenuItem onClick={() => setTheme("light")}> | |
| <div className="flex w-full items-center justify-between"> | |
| <span>Light</span> | |
| {theme === "light" && <CheckIcon />} | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onClick={() => setTheme("dark")}> | |
| <div className="flex w-full items-center justify-between"> | |
| <span>Dark</span> | |
| {theme === "dark" && <CheckIcon />} | |
| </div> | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onClick={() => setTheme("system")}> | |
| <div className="flex w-full items-center justify-between"> | |
| <span>System</span> | |
| {theme === "system" && <CheckIcon />} | |
| </div> | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* Example theme located in '@/themes/dracula' */ | |
| .dracula.light { | |
| --background: 231 15% 100%; | |
| --foreground: 60 30% 10%; | |
| --muted: 232 14% 98%; | |
| --muted-foreground: 60 30% 20%; | |
| --popover: 231 15% 94%; | |
| --popover-foreground: 60 30% 20%; | |
| --border: 232 14% 31%; | |
| --input: 225 27% 51%; | |
| --card: 232 14% 98%; | |
| --card-foreground: 60 30% 5%; | |
| --primary: 265 89% 78%; | |
| --primary-foreground: 60 30% 96%; | |
| --secondary: 326 100% 74%; | |
| --secondary-foreground: 60 30% 96%; | |
| --accent: 225 27% 70%; | |
| --accent-foreground: 60 30% 10%; | |
| --destructive: 0 100% 67%; | |
| --destructive-foreground: 60 30% 96%; | |
| --ring: 225 27% 51%; | |
| --radius: 0.5rem; | |
| } | |
| .dracula.dark { | |
| --background: 231 15% 18%; | |
| --foreground: 60 30% 96%; | |
| --muted: 232 14% 31%; | |
| --muted-foreground: 60 30% 96%; | |
| --popover: 231 15% 18%; | |
| --popover-foreground: 60 30% 96%; | |
| --border: 232 14% 31%; | |
| --input: 225 27% 51%; | |
| --card: 232 14% 31%; | |
| --card-foreground: 60 30% 96%; | |
| --primary: 265 89% 78%; | |
| --primary-foreground: 60 30% 96%; | |
| --secondary: 326 100% 74%; | |
| --secondary-foreground: 60 30% 96%; | |
| --accent: 225 27% 51%; | |
| --accent-foreground: 60 30% 96%; | |
| --destructive: 0 100% 67%; | |
| --destructive-foreground: 60 30% 96%; | |
| --ring: 225 27% 51%; | |
| --radius: 0.5rem; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @tailwind base; | |
| @tailwind components; | |
| @tailwind utilities; | |
| /* Import themes */ | |
| @import url('@/themes/dracula'); | |
| /* | |
| @import url('@/themes/chocolate'); | |
| @import url('@/themes/gold'); | |
| @import url('@/themes/green'); | |
| @import url('@/themes/nature'); | |
| @import url('@/themes/netflix'); | |
| @import url('@/themes/nord'); | |
| @import url('@/themes/orange'); | |
| @import url('@/themes/rose'); | |
| @import url('@/themes/slate'); | |
| @import url('@/themes/winter'); | |
| @import url('@/themes/yellow'); | |
| */ | |
| html, | |
| body, | |
| :root { | |
| height: 100%; | |
| } | |
| @layer base { | |
| :root { | |
| --background: 229 57% 100%; | |
| --foreground: 229 63% 4%; | |
| --muted: 229 12% 86%; | |
| --muted-foreground: 229 10% 37%; | |
| --popover: 0 0% 99%; | |
| --popover-foreground: 229 63% 3%; | |
| --card: 0 0% 99%; | |
| --card-foreground: 229 63% 3%; | |
| --border: 220 13% 91%; | |
| --input: 220 13% 91%; | |
| --primary: 229 100% 62%; | |
| --primary-foreground: 0 0% 100%; | |
| --secondary: 229 20% 90%; | |
| --secondary-foreground: 229 20% 30%; | |
| --accent: 229 28% 85%; | |
| --accent-foreground: 229 28% 25%; | |
| --destructive: 3 100% 50%; | |
| --destructive-foreground: 3 0% 100%; | |
| --ring: 229 100% 62%; | |
| --radius: 0.5rem; | |
| } | |
| .dark { | |
| --background: 229 41% 4%; | |
| --foreground: 229 23% 99%; | |
| --muted: 229 12% 14%; | |
| --muted-foreground: 229 10% 63%; | |
| --popover: 229 41% 5%; | |
| --popover-foreground: 0 0% 100%; | |
| --card: 229 41% 5%; | |
| --card-foreground: 0 0% 100%; | |
| --border: 215 27.9% 16.9%; | |
| --input: 215 27.9% 16.9%; | |
| --primary: 229 100% 62%; | |
| --primary-foreground: 0 0% 100%; | |
| --secondary: 229 14% 8%; | |
| --secondary-foreground: 229 14% 68%; | |
| --accent: 229 23% 17%; | |
| --accent-foreground: 229 23% 77%; | |
| --destructive: 3 89% 54%; | |
| --destructive-foreground: 0 0% 100%; | |
| --ring: 229 100% 62%; | |
| --radius: 0.5rem; | |
| } | |
| } | |
| @layer base { | |
| * { | |
| @apply border-border; | |
| } | |
| body { | |
| @apply bg-background text-foreground; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Example layout | |
| import { ThemeProvider } from "@/components/theme-provider"; | |
| import { cn } from "@/lib/utils"; | |
| import ColorThemeProvider from "@/providers/color-theme-provider"; | |
| import type { Metadata } from "next"; | |
| import { Inter } from "next/font/google"; | |
| import "./globals.css"; | |
| const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); | |
| export const metadata: Metadata = { | |
| title: "CMMS", | |
| description: "Generated by create next app", | |
| }; | |
| export default async function RootLayout({ | |
| children, | |
| }: Readonly<{ | |
| children: React.ReactNode; | |
| }>) { | |
| return ( | |
| <html lang="en" suppressHydrationWarning> | |
| <head /> | |
| <body | |
| className={cn( | |
| "bg-background font-sans text-foreground antialiased", | |
| inter.variable, | |
| )} | |
| > | |
| <ThemeProvider | |
| attribute="class" | |
| defaultTheme="system" | |
| enableSystem | |
| disableTransitionOnChange | |
| > | |
| <ColorThemeProvider>{children}</ColorThemeProvider> | |
| </ThemeProvider> | |
| </body> | |
| </html> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // This is directly copied from Shadcn UI's official guide: https://ui.shadcn.com/docs/dark-mode/next | |
| "use client"; | |
| import { ThemeProvider as NextThemesProvider } from "next-themes"; | |
| import type { ThemeProviderProps } from "next-themes/dist/types"; | |
| export function ThemeProvider({ children, ...props }: ThemeProviderProps) { | |
| return <NextThemesProvider {...props}>{children}</NextThemesProvider>; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment