Skip to content

Instantly share code, notes, and snippets.

@MarkyJD
Last active December 8, 2025 21:29
Show Gist options
  • Select an option

  • Save MarkyJD/a0fb5d4c8b0396aaf858c0982125f2c4 to your computer and use it in GitHub Desktop.

Select an option

Save MarkyJD/a0fb5d4c8b0396aaf858c0982125f2c4 to your computer and use it in GitHub Desktop.
Color themes with Next.js and Shadcn UI.
// ! 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;
}
"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 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>
);
}
/* 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;
}
@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;
}
}
// 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 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