paint strokes

Color Themes in Next.js 14 and Tailwind CSS

Jul 16, 2024

I’ve always been fond of light/dark toggles since they became fashionable. They were easy to implement on React sites, with libraries like Styled Components providing theming out of the box, using the context API to make themed styles readily available to components in all levels of the render tree.

When building this personal site, I wanted to use two of the technologies I most commonly use, Next.js 14 with the app router and Tailwind CSS. However I quickly realized I had some new, interesting challenges in implementing themes. First, I wanted to be able to select a theme from my content source, Prismic CMS, so I needed to be able to set the styles in my global CSS and Tailwind config files based on data retrieved from Prismic. The point of this is that someday I'd like to fork the repo into a template that allows non-technical users to build their own sites using Prismic, and it’s a nice feature to offer a choice of color schemes. Secondly, because it piqued my curiosity, I wanted to know if I could make the theme dynamic on the client while mostly retaining the server side rendering of Next's app router.

The first task fortunately turned out to be easily resolved with CSS variables and the data-theme html attribute. First I set up my globals.css and tailwind.config.js files with the following:

/* globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

html {
  --primary-dark: #222831;
  --secondary-dark: #393E46;
  --primary-light: #EEEEEE;
  --highlight: #00ADB5;
  --highlight-light: #228e93;
}

[data-theme="Turquoise"] {
  --primary-dark: #222831;
  --secondary-dark: #393E46;
  --primary-light: #EEEEEE;
  --highlight: #00ADB5;
  --highlight-light: #1eb9c2;
}

[data-theme="Purple"] {
  --primary-dark: #000000;
  --secondary-dark: #52057B;
  --primary-light: #e3e1e4;
  --highlight: #BC6FF1;
  --highlight-light: #c385ed;
}

[data-theme="Violet"] {
  --primary-dark: #2b2734;
  --secondary-dark: #5C5470;
  --primary-light: #FAF0E6;
  --highlight: #afa7c4;
  --highlight-light: #B9B4C7;
}

body {
  background-color: var(--primary-dark);
  color: var(--primary-light);
}
// tailwind.config.js

module.exports = {
  theme: {
    colors: {
      "primary-dark": 'var(--primary-dark)',
      "secondary-dark": 'var(--secondary-dark)',
      "primary-light": 'var(--primary-light)',
      "highlight": 'var(--highlight)',
      "highlight-light": 'var(--highlight-light)'
    }
  }
};

Then in my layout.tsx, I retrieved my theme from Prismic (which is a select field that returns a single string) and set the data-theme.

// src/app/layout.tsx

export default async function RootLayout({ children }: RootLayoutProps ) {
  const client = createClient();
  const settings = await client.getSingle("settings");

  return (
    <html
      lang="en"
      className={`${nunito_sans.className} ${fira_mono.className}`}
      data-theme={settings.data.theme}
    >
      <body className="overflow-x-hidden antialiased">
        <main>
          {children}
          <PrismicPreview repositoryName={repositoryName} />
        </main>
      </body>
    </html>
  );
}

The next task of creating a theme switcher for browser side users was more challenging and there seems to be a lot of debate about the best approach to this in Next 14. With the app router, the idea is to keep most of the components rendered on the server, but the usual tools to maintain state in React are run client side. Fortunately, according the Next.js docs on composition patterns, we can still have a context provider at the root level and maintain a mix of server and client components as children, the only caveat is that all children that consume that context need to be client components.

Yet still I was curious if I could find a solution to the issue without a context provider. Spoiler alert: the answer is yes and no. Skip to the end of the article to see the full context API solution.

Some state solutions for SSR are using URL query parameters and cookies. While URL parameters are a great way to maintain state, they are more appropriate for use cases like tracking selected product variants (as in my example Next.js store) versus pure presentation preferences. Using a cookie's solution as a base, I tried the following approach, first modifying my layout.tsx as follows:

// src/app/layout.tsx

export default async function RootLayout({ children }: RootLayoutProps ) {
  const client = createClient();
  const settings = await client.getSingle("settings");
  const cookieStore = cookies();
  const theme = cookieStore.get('theme');

  return (
    <html
      lang="en"
      className={`${nunito_sans.className} ${fira_mono.className}`}
      data-theme={theme?.value ?? settings.data.theme}
    >
      <body className="overflow-x-hidden antialiased">
        <main>
          {children}
          <PrismicPreview repositoryName={repositoryName} />
        </main>
      </body>
    </html>
  );
}

Then I created a Theme Toggle component that renders on the client side that updates the cookies, sets the data-theme and maintains the toggle state:

// src/app/components/ThemeToggle.tsx

'use client';

import { useState, useEffect } from "react";
import { getCookie, setCookie } from 'cookies-next';

const themes = ["Turquoise", "Violet", "Purple"];

const ThemeSelector = () => {
  const [selectedTheme, setSelectedTheme] = useState<string>();

  useEffect(() => {
    const cookieTheme = getCookie('theme') as string | undefined;
    if (cookieTheme) {
      setSelectedTheme(cookieTheme);
      document.documentElement.setAttribute("data-theme", cookieTheme);
    }
  }, []);

  const handleThemeChange = (theme: string) => {
    setSelectedTheme(theme);
    setCookie('theme', theme, { path: '/', maxAge: 7 * 24 * 60 * 60 });
    document.documentElement.setAttribute("data-theme", theme);
  };

  const classNames = (...classes: string[]) => {
    return classes.filter(Boolean).join(' ');
  };

  return (
    <div>
      <nav className="flex space-x-4" aria-label="Tabs">
        {themes.map((theme) => (
          <button
            key={theme}
            onClick={() => handleThemeChange(theme)}
            className={classNames(
              selectedTheme === theme
                ? 'text-highlight-light bg-secondary-dark'
                : 'text-primary-light hover:text-highlight-light',
              'transition-colors rounded-lg px-4 py-1 text-sm tracking-tight'
            )}
            aria-current={selectedTheme === theme}
          >
            {theme}
          </button>
        ))}
      </nav>
    </div>
  );
};

export default ThemeSelector;

Initially I tried to avoid calling in the cookies client side, passing the theme cookie in from the Header component and setting it as the default selectedTheme, but on fast transitions between themes I found that the props could get out of sync with the cookie. So occasionally the button background color would jump briefly from the previous one. Removing the prop, making a client call for the cookie, and setting it as the default selectedTheme via useEffect solved this issue.

Unfortunately, relying on the useEffect to set the default state meant that on page refreshes, the background color of the toggle buttons (not the layout) would flicker on first load. If I changed my buttons to a dropdown where the selected choices would only be visible upon opening the selector, this would fix the issue, since the flicker was limited to the toggle. However I wanted to keep my buttons as they were and was curious to find a more complete solution.

Color Theme Solution with the Context API

In order to set up Context with the Next.js app router, I needed to make a theme provider with 'use-client' and then import that into my layout.tsx, passing in either the current theme cookie or settings theme to the ThemeProvider to use as the initialTheme:

// src/app/theme-provider.tsx

'use client';

import { createContext, useState, ReactNode, useEffect } from 'react';
import { setCookie } from 'cookies-next';

interface ThemeContextType {
  theme: string;
  setTheme: (theme: string) => void;
}

export const ThemeContext = createContext<ThemeContextType>({
  theme: 'Turquoise',
  setTheme: () => {},
});

interface ThemeProviderProps {
  children: ReactNode;
  initialTheme: string;
}

export default function ThemeProvider({
  children,
  initialTheme
}: ThemeProviderProps) {
  const [theme, setThemeState] = useState(initialTheme);

  const setTheme = (newTheme: string) => {
    setThemeState(newTheme);
    setCookie('theme', newTheme, { path: '/', maxAge: 7 * 24 * 60 * 60 });
    document.documentElement.setAttribute("data-theme", newTheme);
  };

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
// src/app/layout.tsx

export default async function RootLayout({ children }: RootLayoutProps ) {
  const client = createClient();
  const settings = await client.getSingle("settings");
  const cookieStore = cookies();
  const theme = cookieStore.get('theme');

  return (
    <html
      lang="en"
      className={`${nunito_sans.className} ${fira_mono.className}`}
      data-theme={theme?.value ?? settings.data.theme}
    >
      <body className="overflow-x-hidden antialiased">
        <main>
          <ThemeProvider initialTheme={theme?.value ?? settings.data.theme}>
            {children}
          </ThemeProvider>
          <PrismicPreview repositoryName={repositoryName} />
        </main>
      </body>
    </html>
  );
}

With the theme provider, I could handle setting the new theme and remove the useEffect to get the latest cookie. With these functions abstracted away, my toggle component became neat and tidy:

// ThemeToggle.tsx

'use client';

import { useContext } from "react";
import { ThemeContext } from "@/app/theme-provider";  

const themes = ["Turquoise", "Violet", "Purple"];

const ThemeSelector = () => {
  const { theme: selectedTheme, setTheme } = useContext(ThemeContext);

  const handleThemeChange = (theme: string) => {
    setTheme(theme);
  };

  const classNames = (...classes: string[]) => {
    return classes.filter(Boolean).join(' ');
  };

  return (
    <div>
      <nav className="flex space-x-4" aria-label="Tabs">
        {themes.map((theme) => (
          <button
            key={theme}
            onClick={() => handleThemeChange(theme)}
            className={classNames(
              selectedTheme === theme
                ? 'text-highlight-light bg-secondary-dark'
                : 'text-primary-light hover:text-highlight-light',
              'transition-colors rounded-lg px-4 py-1 text-sm tracking-tight'
            )}
            aria-current={selectedTheme === theme}
          >
            {theme}
          </button>
        ))}
      </nav>
    </div>
  );
};

export default ThemeSelector;

Conclusion
In the end, I used a mix of context with cookies to get a seamless dynamic theming experience for Next.js.