sebee.website🌱
  • Articles
  • CV
  • About
  • Contact

Sebastian Pieczynski's website and blog

Published on: yyyy.mm.dd

Back to Articles
sebee.website
  • Created with ❤️ by Sebastian Pieczyński © 2023-2025.

Links

  • Terms of Use
  • Privacy Policy
  • Cookie Policy
  • ethernal
  • @spieczynski

Dark mode and seamless design system with TailwindCSS in Next.js

Published on: 11/2/2023

Boat sailing into the night with starry sky overhead, with milky way visible boat is slightly lighted with last sun rays. Wide angle. Watercolor style.

Contents

  1. In the beginning...
  2. Design system
    1. Implementation
  3. Configuring Dark Mode in Tailwind
  4. Creating ThemeContext and Provider
    1. How and Why - storing the user preferences
    2. Designing the API for the component with TypeScript
    3. Implementing the Context
    4. Creating custom hook to return the ThemeContext
  5. Retrieving the theme settings and rendering theme on the server
    1. Toggling the theme and saving user preferences
  6. Defining styles for Dark Mode
  7. Bonus - Using with NextUI
  8. Conclusion

In the beginning...

There was a developer and there was a black IDE. IDE was empty and only the cursor was pulsing slowly. After a time a developer started to write code. The cursor was moving and the IDE was filled with code. The developer was happy it was good flow, pixels were drawn on the screen thanks to the words unspoken but written. Sooner rather than later something went wrong.. variables were all over the place with names set as they saw fit not as intended by their creator, paddings and margins were not uniform and changing color required editing each place separately. It was a mess and developer was sad.

The purge was needed.

...

If that short story reminds you of a thing or two then as you can clearly see you are not alone. Working with designers and having defined the details of how things are supposed to work helps a lot. If it's a solo project that has no defined structure yet then, well sometimes it starts living it's own life.

Thankfully I was already using TailwindCSS (it's awesome, really!) and there is documentation showing how to use CSS variables with Tailwind classes: see how to use variables with Tailwind and especially how to define colors to be fully usable the same way as "native" Tailwind classes: see how to define colors . Using these two methods I was able to keep things configurable with variables as I wanted and also use only Tailwind to get there. Win - win!

Try it now:

  1. Open the developer tools (press in most browsers F12) and in the Inspect tab and filter for root element then in the CSS section paste this code for the element:

For light mode:

For dark mode:

Now the page background and few other elements are light green.

Design system

The system is a fancy name here for a set of rules and tokens, the ones I chose work as follows:

Every variable tries to follow this patters theme-[light/dark]-[property]-[value]-[modifier/discriminator]. For example: --theme-light-background-primary so it's theme variable for light mode that modifies the background property and it is it's primary version.

This requires us to be very specific about the use of the variable and has added benefit of being verbose. I do prefer that to some short names no one will remember in three weeks or two. It makes code more readable and most importantly anyone reading this code will figure out what it does after checking other classes following the same pattern.

Implementation

To create design system using TailwindCSS and CSS variables we need to define the variable in the :root element and then we can use it in the tailwind.config.ts. This is the only "drawback" as the variables are defined in one file and used mostly in another but they are sometimes needed when we want to create global styles utilizing them.

Example of few CSS variables used:

And then we can use these variables to create classes in Tailwind.

You probably saw that I was not completely honest with you. I have broken the pattern for the variable names here:

These short values are working more like globals / universal values or partials ex. --theme-mute-factor is used in tandem with colors to create muted color variants. If at any point I decide they are muted too little or too much I only need to change value in this one place.

Examples:

By defining only the values for hsl(...) CSS function we can now use the Tailwind's <alpha-value> placeholder for the alpha channel and use the same opacity syntax as for every other Tailwind defined color:

If we defined the CSS variable including hsl function we would have lost the ability to control the alpha channel and the code above would not work.

Configuring Dark Mode in Tailwind

Configuring the "dark mode" in Tailwind is done with only one line of code in the tailwind.config.ts file. Using it with our design is also straightforward but I will show you some cool ways to make it easier to implement into the website or app.

First we need to setup how dark mode should be handled by Tailwind itself. For Next.js I have found that using the class configuration option is the most optimal way as it integrates well with other solutions and will prevent content flashing since the layout will be server side rendered first.

Now whenever we want to see dark mode CSS applied via Tailwind we only need to toggle dark class on the html element.

If the class on html element is set to 'dark' now the text will be white and black if not.

Handling the actual change and saving user preferences is slightly more involving.

Creating ThemeContext and Provider

There are two main parts of the functionality we need to handle:

  1. Storing the user preferences.
    1. Allow user to change the theme.
    2. Save the data on the client (browser).
  2. Retrieving and sharing the preferred setting.
    1. Send current theme to the server.
    2. Share the current theme with across the components.

How and Why - storing the user preferences

To keep user preferences regarding the theme we will use cookies as they are a key-value pair "database" but in the browser and they are sent to the server on every request. This fits our purpose perfectly as we need that value on initial render (for Server Side Rendering) and it stays with the user and is under his or hers control.

To prevent hydration errors we need to share the same state between server and client and both must be the same.

To store the theme for the client (browser) we will use React's Context API . In React we create Context when we want to share a property between components without prop-drilling (passing it down manually to all components).

Designing the API for the component with TypeScript

I'll start slowly and explain crucial parts of the code, if you are familiar with the pattern you can scroll below to see the full source code.

To create new Context (and you can have many in your application) we use createContext function provided by React.

Let's think what we need our Context to be and what to provide:

  1. we need to know what theme is currently selected and...
  2. we need to be able to change the theme.

So our Context will need to keep both theme variable (that can be set to light or dark) and setTheme function to manipulate the theme. Knowing that let's name give it a name of ThemeContext and strongly type it:

To keep the theme variable in check we limit it's values to light and dark with the ColorThemeType type:

That way it will only be able to be set to predefined values when using TypeScript.

Implementing the Context

Now that we have design of our function we can start coding it.

This will create new Context (think of it as an external slot or bucket for variables, outside of the components tree) and assign it a value of null (more on this in a second).

Context MUST be created OUTSIDE of the component itself.

I've decided to name it Theme but you will see components like this named ThemeProvider, 'ThemeContext' or similar more often to indicate they are utilizing the Context API.

Reference: React createContext documentation

You can see that we are allowing to set the initialTheme, this is very important for SSR and just in case the value is not provided we are setting it to light by default. That way we are ensuring that the value is always available and stays consistent.

You will note that we are using Provider sub component that is a part of ThemeContest. The provider accepts a value prop that is the value/object/property that we want to share across the component's tree. It will be passed to all underlying components (children) of the Theme component. That value can be anything we want and usually it will be an object holding multiple properties or it could be a single property if we wanted theme to be read only.

Exxample of passing single read only value to context from React documentation:

Reference: React documentation .

Finally we are rendering the children passed to the Theme - this will render all the components that we pass as the children of the Theme. This allows us to control from what level the theme and setTheme props are available.

Creating custom hook to return the ThemeContext

One unusual thing about the way this Context Provider is written is the creation of useThemeContext hook.

Let's first look at how we would utilize ThemeContext without the custom hook.

Without the custom useThemeContext hook we would need to:

  1. import the ThemeContext itself...
  2. verify if the theme and setTheme values actually exist in the context...
  3. throw an error if they don't.
  4. Destructure them from the context variable and use them.

I think you can see the pattern here, and we would need to do this every time. And then there are those pesky TypeScript errors. As someone wise once said, that's no way to live your life.

This is why for every context we create we'll also provide a custom hook that will look like this:

By utilizing this pattern we encapsulate all the logic related to verifying that the hook can be safely used, we make our TypeScript checks happy and can simplify the way the consumers (developers) interact with the ThemeContext. Now we only need to destructure theme and setTheme variables from the useThemeContext hook. If the hook is used outside of where the ThemeContext is defined it will throw an error notifying the developer that the function cannot be used at that level.

Here's the full code for ThemeContext in TypeScript. Note that Context use requires us to use the 'use client'; directive in Next.js.

Our theme sharing logic is complete, we can use the ThemeContext to share what theme is currently selected with theme variable and we can change it via setTheme function shared by the useThemeContext hook.

But our users can't..

Retrieving the theme settings and rendering theme on the server

With the business logic part done we can now start providing the functionality to the users on frontend of our application.

First we need to use the Theme component in a place from which we want to start allowing changes to the theme. Since theme is a part of every component it will usually be the root of the application and in Next.js 13 and later with App Router the best place to do this would be the main layout.tsx file located at src/app/layout.tsx.

Note that some code was omitted for the sake of brevity.

Note that this component does not use the use client; directive as it is a server side component and must be used on the server as it uses data from the cookie to retrieve the saved theme before it gets sent to the user.

The part where we retrieve the theme variable from the cookies and set it while the page is still rendering on the server is the secret sauce to preventing content flashing as it will always be in sync with that the user sets on the clinet (soon) and by utilizing Tailwind we are sure that all the CSS we need for the page is already present.

We are also making sure that if the cookie is not set a default value is passed.

Then we pass that variable as initialTheme, since that value can change in the future, to the Theme context component.

We also set html class to the value of the savedTheme:

The data-color-theme helps to clearly see what theme is set in case your html tag is heavily customized.

This way we have completed the retrieving theme setting for the user.

Toggling the theme and saving user preferences

For now we have achieved these goals:

  1. We have a dark mode and light mode enabled via TailwindCSS.
  2. We can retrieve the user preferences from the cookies stored in the browser.
  3. We can share the theme between components in the application.

But we still cannot change the theme. Let's work on that next.

Cookie operations provided by next are available only on the server side, but we need to use it on the client side as well.

To manipulate cookies we'll use the js-cookie package.

install it with:

Then we'll create a ThemeToggle component.

Thanks to our custom useThemeContext hook we can easily get current theme and setTheme functions.

Then:

  1. Using a button we will render the moon or sun icons based on what value theme variable holds and
  2. change it with handleThemeChange when it's clicked.
  3. handleThemeChange function will in turn:
    1. Compute the next theme based on current theme value.
    2. Remove all possible values for the theme (remove everything to be safe).
    3. Replace the value of the theme to the opposite one.
    4. Store the new value or update existing one in a cookie.

Again some parts of the code (like imports and classes) had been removed to focus on the solution.

After displaying the ThemeToggle on a page and clicking the sun (or moon) icon the class assigned to the html tag changes!

With this we have achieved our final goal of allowing user to change the theme and save it.

Defining styles for Dark Mode

Defining dark mode in TailwindCSS is straightforward: just add dark: prefix to the utility class name and it will only activate in dark mode. But now we can both use custom classes that we have full control of with tailwind and we have fully functional dark mode in our app.

How cool is that?! 😁

Bonus - Using with NextUI

NextUI is a set of components that are beautifully crafted with nice touch of sensible animations styled with TailwindCSS 🤯. With the presented setup it will work out of the box after following the installation process as it uses Tailwind and class based detection for the dark mode. See the installation guide for nextui components for more information.

Conclusion

With the presented approach we have successfully created the design system with Tailwind and Next.js, configured and setup dark mode without content flashing and provided user with simple way to preserve the theme between site visits.

I hope you found this helpful and enjoyed it, if you have any questions please reach out to me on Twitter or Contact Page.

Back to Articles
1npm install js-cookie
globals.css
1 --theme-gap-card: 2rem;
2 --theme-padding-default: 2rem;
3 --theme-radius-default: 6px;
4
5 --theme-mute-factor: 0.4;
6
7 /* COLORS */
8 /* Light */
9 --theme-light-color-primary: 80deg 90% 30%;
10
11 --theme-light-foreground-light: 50deg 10% 10%;
12 --theme-light-foreground-dark: 50deg 0% 0%;
13
14 --theme-light-background-primary: 50deg 10% 90%;
15 --theme-light-background-secondary: 50deg 20% 80%;
16
17 /* Dark */
18
19 --theme-dark-color-primary: 220deg 90% 20%;
20
21 --theme-dark-foreground-light: 230deg 95% 95%;
22 --theme-dark-foreground-dark: 230deg 45% 5%;
23
24 --theme-dark-background-primary: 230deg 45% 4%;
25 --theme-dark-background-secondary: 225deg 35% 15%;
26
27 --theme-dark-foreground-primary: 220deg 100% 100%;
28
29 --theme-accent: 12 80% 43%;
1--theme-gap-card: 2rem;
2--theme-padding-default: 2rem;
3--theme-radius-default: 6px;
4--theme-mute-factor: 0.4;
1--theme-light-background-primary: 55 30% 70%;
1--theme-dark-background-primary: 55 30% 70%;
documentation-example.jsx
1function App() {
2 const [theme, setTheme] = useState('light');
3 // ...
4 return (
5 <ThemeContext.Provider value={theme}>
6 <Page />
7 </ThemeContext.Provider>
8 );
9}
tailwind.config.ts
1theme: {
2 extend: {
3 borderRadius: {
4 'theme-default': 'var(--theme-radius-default)',
5 },
6 /* ,,, */
7 colors: {
8 'theme-dark-background-primary':
9 'hsl(var(--theme-dark-background-primary) / <alpha-value>)',
10 'theme-dark-background-primary-muted':
11 'hsl(var(--theme-dark-background-primary) / var(--theme-mute-factor))',
12 },
13 }
14}
tailwind.config.ts
1'theme-dark-background-primary': 'hsl(var(--theme-dark-background-primary) / <alpha-value>)',
2
3'theme-dark-background-primary-muted': 'hsl(var(--theme-dark-background-primary) / var(--theme-mute-factor))',
tailwind.config.ts
1import type { Config } from 'tailwindcss';
2
3const config: Config = {
4 darkMode: 'class',
5 content: [
6 //...
7 ],
8 //...
9}
1type ColorThemeType = 'light' | 'dark';
1<html className="dark"> // this can be either light or dark
2// ...
3<p className="text-slate-900 dark:text-slate-50">Hello World</p>
4// ...
5</html>
1type ThemeContext = {
2 theme: ColorThemeType;
3 setTheme: React.Dispatch<React.SetStateAction<ColorThemeType>>;
4};
1const ThemeContext = createContext<ThemeContext | null>(null);
2
3function Theme({ initialTheme = 'light', children }: ThemeProps) {
4 //...
5}
1function Theme({ initialTheme = 'light', children }: ThemeProps) {
2 const [theme, setTheme] = useState<ColorThemeType>(initialTheme);
3
4 return (
5 <ThemeContext.Provider value={{ theme, setTheme }}>
6 {children}
7 </ThemeContext.Provider>
8 );
9}
1import { ThemeContext } from '@/context/ThemeContext';
2
3const context = useContext(ThemeContext);
4
5if (!context) {
6 throw new Error('ThemeContext must be used within ThemeContextProvider');
7}
8
9const { theme, setTheme } = context;
1export function useThemeContext(): ThemeContext {
2 const context = useContext(ThemeContext);
3
4 if (context === undefined || context === null) {
5 throw new Error('useThemeContext must be used within ThemeContextProvider');
6 }
7
8 return context;
9}
usage-of-useThemeContext.tsx
1 const { theme, setTheme } = useThemeContext();
ThemeContext.tsx
1'use client';
2import React, { createContext, ReactNode, useContext, useState } from 'react';
3
4import ColorThemeType from '@/types/ColorThemeType';
5
6type ThemeContext = {
7 theme: ColorThemeType;
8 setTheme: React.Dispatch<React.SetStateAction<ColorThemeType>>;
9};
10
11type ThemeProps = {
12 initialTheme: ColorThemeType;
13 children: ReactNode;
14};
15
16const ThemeContext = createContext<ThemeContext | null>(null);
17
18function Theme({ initialTheme = 'light', children }: ThemeProps) {
19 const [theme, setTheme] = useState<ColorThemeType>(initialTheme);
20
21 return (
22 <ThemeContext.Provider value={{ theme, setTheme }}>
23 {children}
24 </ThemeContext.Provider>
25 );
26}
27
28export function useThemeContext(): ThemeContext {
29 const context = useContext(ThemeContext);
30
31 if (context === undefined || context === null) {
32 throw new Error('useThemeContext must be used within ThemeContextProvider');
33 }
34
35 return context;
36}
37
38export default Theme;
src/app/layout.tsx
1import { cookies } from 'next/headers';
2
3function RootLayout({ children }: { children: ReactNode }) {
4
5 const savedThemeCookie = cookies().get('theme');
6 const savedTheme: ColorThemeType = (savedThemeCookie?.value ??
7 'light') as ColorThemeType;
8
9 return (
10 <Theme initialTheme={savedTheme}>
11 <html
12 lang="en"
13 data-color-theme={savedTheme}
14 className={cn(
15 savedTheme,
16 )}
17 >
18 <body
19 className={cn(
20 `
21 text-theme-light-text-black
22 bg-theme-light-background-primary
23
24 dark:bg-theme-dark-background-primary
25 dark:text-theme-dark-text-light
26 `,
27 )}
28 >
29 <div>
30 <Header/>
31 <main>{children}</main>
32 <Footer/>
33 </div>
34 </body>
35 </html>
36 </Theme>
37 );
38}
39
40export default RootLayout;
1 const savedThemeCookie = cookies().get('theme');
2 const savedTheme: ColorThemeType = (savedThemeCookie?.value ?? 'light') as ColorThemeType;
3
4 return (
5 <Theme initialTheme={savedTheme}>
6 /* ^...^ */
7 </Theme>
8 );
1<html
2 lang="en"
3 data-color-theme={savedTheme}
4 className={savedTheme}
5>
6// ...
7</html>
ThemeToggle.tsx
1const ThemeToggle: React.FC<ThemeToggleProps> = () => {
2 const { theme, setTheme } = useThemeContext();
3
4 const handleThemeChange = () => {
5 // we are computing the next theme value so we do not end up using the stale value of the theme before it changes
6 const nextTheme = theme === 'light' ? 'dark' : 'light';
7
8 setTheme(nextTheme);
9 Cookie.set('theme', nextTheme, { expires: 380 });
10
11 const root = document.documentElement;
12 root.classList.remove('light');
13 root.classList.remove('dark');
14 root.classList.add(nextTheme);
15 };
16
17 return (
18 <button onClick={handleThemeChange}>
19 {theme === 'light' ? (
20 <Sun/>
21 ) : (
22 <Moon/>
23 )}
24 <VisuallyHidden>Toggle dark / light mode</VisuallyHidden>
25 </button>
26 );
27};
28
29export default ThemeToggle;
1<p className="text-theme-light-text-black dark:text-theme-dark-text-light">Hello World</p>
1<div class="bg-theme-light-background-primary/10">
2 <!-- ... -->
3</div>