skies.dev

How to Create Themes with Tailwind CSS and React

7 min read

Let's create custom themes with Tailwind and React.

You'll learn how to:

  • Set up and use CSS variables to create custom Tailwind CSS colors.
  • How to switch between multiple themes using React.js and Next.js.
  • How to save the user's theme preference to local storage.

The full project code is on GitHub.

This article assumes some familiarity with Tailwind CSS and React, but you should be able to apply the patterns to any flavor of CSS and JavaScript.

By default, Tailwind gives us color classes using the name and shade of color.

<button class="bg-blue-500 text-gray-100">Button</button>

This works fine for many projects, but if we want our project to support multiple themes, it can be helpful to use semantic color names.

<button class="bg-primaryBg text-onPrimaryBg">Button</button>

Instead of hard-coding the name of a color, we create an abstraction to give us the flexibility to easily change the theme of the site.

We define two different color types:

  • Primary—this is our brand's colors. For example, Twitter's primary color is blue.
  • Neutral—this is our neutral color—gray in most cases.

For each semantic type, we define variants to handle different shades of color. For example

  • neutralBg is the color we want to use on a colorless background.
  • onNeutralBg is a color we want to use for things on the neutral background.

You'll want to choose variants that make sense for your project.

Using CSS variables with Tailwind CSS to define themes

Create a repository using this template. This is a bare-bones Next.js app with Tailwind CSS installed.

Inside of styles/globals.css, we'll set up our various neutral colors in the base layer.

global.css
@layer base {
    :root {
        --color-neutral-50: theme('colors.gray.50');
        --color-neutral-100: theme('colors.gray.100');
        --color-neutral-200: theme('colors.gray.200');
        --color-neutral-300: theme('colors.gray.300');
        --color-neutral-400: theme('colors.gray.400');
        --color-neutral-500: theme('colors.gray.500');
        --color-neutral-600: theme('colors.gray.600');
        --color-neutral-700: theme('colors.gray.700');
        --color-neutral-800: theme('colors.gray.800');
        --color-neutral-900: theme('colors.gray.900');
    }
}

Then, we'll set up classes which we'll apply to the document depending on the primary color preference the user chooses. These classes set up the color shades for the primary color.

styles/globals.css
.theme-green {
    --color-primary-50: theme('colors.green.50');
    --color-primary-100: theme('colors.green.100');
    --color-primary-200: theme('colors.green.200');
    --color-primary-300: theme('colors.green.300');
    --color-primary-400: theme('colors.green.400');
    --color-primary-500: theme('colors.green.500');
    --color-primary-600: theme('colors.green.600');
    --color-primary-700: theme('colors.green.700');
    --color-primary-800: theme('colors.green.800');
    --color-primary-900: theme('colors.green.900');
}

.theme-red {
    --color-primary-50: theme('colors.red.50');
    --color-primary-100: theme('colors.red.100');
    --color-primary-200: theme('colors.red.200');
    --color-primary-300: theme('colors.red.300');
    --color-primary-400: theme('colors.red.400');
    --color-primary-500: theme('colors.red.500');
    --color-primary-600: theme('colors.red.600');
    --color-primary-700: theme('colors.red.700');
    --color-primary-800: theme('colors.red.800');
    --color-primary-900: theme('colors.red.900');
}

.theme-blue {
    --color-primary-50: theme('colors.blue.50');
    --color-primary-100: theme('colors.blue.100');
    --color-primary-200: theme('colors.blue.200');
    --color-primary-300: theme('colors.blue.300');
    --color-primary-400: theme('colors.blue.400');
    --color-primary-500: theme('colors.blue.500');
    --color-primary-600: theme('colors.blue.600');
    --color-primary-700: theme('colors.blue.700');
    --color-primary-800: theme('colors.blue.800');
    --color-primary-900: theme('colors.blue.900');
}

Next we'll set up our light mode and dark mode classes. Here we'll create our "higher-order" semantic CSS variables.

In your own app, you may want to experiment with shades and a naming convention you like. This example is just to give you a flavor of what's possible.

global.css
.theme-light {
    --neutralBg: theme('colors.white');
    --onNeutralBg: var(--color-neutral-900);
    --primaryBg: var(--color-primary-100);
    --onPrimaryBg: var(--color-primary-900);
    --primary: var(--color-primary-500);
}

.theme-dark {
    --neutralBg: var(--color-neutral-900);
    --onNeutralBg: theme('colors.white');
    --primaryBg: var(--color-primary-900);
    --onPrimaryBg: var(--color-primary-50);
    --primary: var(--color-primary-400);
}

Now we'll update our tailwind.config to use the CSS variables we defined.

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
    content: [
        "./pages/**/*.{js,ts,jsx,tsx}",
        "./components/**/*.{js,ts,jsx,tsx}",
    ],
    theme: {
        extend: {
            colors: {
                onNeutralBg: 'var(--onNeutralBg)',
                neutralBg: 'var(--neutralBg)',
                onPrimaryBg: 'var(--onPrimaryBg)',
                primaryBg: 'var(--primaryBg)',
                primary: 'var(--primary)',
            }
        },
    },
    plugins: [],
}

Now we should be able to use Tailwind classes like bg-neutralBg, border-onNeutralBg, text-primary, and so forth.

Using React to switch themes

We'll install Headless UI to use their radio and switch components. This will make it simple to create accessible components that we'll use to switch themes.

yarn add @headlessui/react @headlessui/tailwindcss

We need to update our tailwind.config to get access to the Headless UI CSS modifiers. This will let us style the different Headless UI component states like with class name prefixes like ui-checked:, ui-not-checked:, and so forth.

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
    ...
    plugins: [require('@headlessui/tailwindcss')],
}

Let's turn to our home page: pages/index.tsx. First thing we'll do is define our various theme states. We'll use these values as inputs to the radio and switch components, and use them to dynamically set the theme classes we defined earlier.

pages/index.tsx
const colors = ['green', 'red', 'blue'];
const modes = ['light', 'dark'];

We'll use React state to determine which theme is set, which we'll do via the theme-{color} and theme-{mode} classes.

pages/index.tsx
import {useState} from 'react';
import type {NextPage} from 'next';

const Home: NextPage = () => {
  const [color, setColor] = useState(colors[0], 'theme-color');
  const [mode, setMode] = useState(modes[0], 'theme-mode');

  return (
    <div
      className={[
        'bg-primaryBg flex h-screen flex-col justify-center font-mono',
        color && `theme-${color}`,
        mode && `theme-${mode}`,
      ]
        .filter(Boolean)
        .join(' ')}
    >
      <div className="bg-neutralBg text-onNeutralBg border-onNeutralBg mx-auto max-w-lg border-8 p-5">
        <h1 className="text-center text-3xl font-bold">Tailwind Themes</h1>
      </div>
    </div>
  );
};

We'll use Headless UI's RadioGroup to switch between each primary color theme.

pages/index.tsx
import { RadioGroup } from '@headlessui/react';

// ...

const Home: NextPage = () => {
  const [color, setColor] = useState(colors[0]);
  const [mode, setMode] = useState(modes[0]);

  return (
    <div className={'...'}>
      <div className="...">
        {/* ... */}
        <RadioGroup value={color} onChange={setColor}>
          <RadioGroup.Label className="mt-5 block">
            Select a color:
          </RadioGroup.Label>
          <div className="mt-2 flex justify-between space-x-8">
            {colors.map((c) => {
              return (
                <RadioGroup.Option
                  className="ui-checked:text-onPrimaryBg ui-checked:bg-primaryBg ui-checked:ring-primary ui-not-checked:ring-onNeutralBg flex h-20 w-full cursor-pointer items-center justify-center font-bold uppercase ring-4"
                  value={c}
                  key={c}
                >
                  {c}
                </RadioGroup.Option>
              );
            })}
          </div>
        </RadioGroup>
      </div>
    </div>
  );
};

And Headless UI's Switch component to toggle between light and dark mode.

pages/index.tsx
import { RadioGroup, Switch } from '@headlessui/react';

// ...

const Home: NextPage = () => {
  const [color, setColor] = useState(colors[0]);
  const [mode, setMode] = useState(modes[0]);

  return (
    <div className={'...'}>
      <div className="...">
        {/* ... */}
        <Switch.Group>
          <div className="mt-10">
            <Switch.Label className="block">Enable dark mode:</Switch.Label>
            <Switch
              className="bg-onNeutralBg relative inline-flex h-6 w-11 items-center rounded-full"
              checked={mode === 'dark'}
              onChange={() => setMode(mode === 'light' ? 'dark' : 'light')}
            >
              <span className="bg-neutralBg ui-not-checked:translate-x-1 ui-checked:translate-x-6 inline-block h-4 w-4 transform rounded-full transition" />
            </Switch>
          </div>
        </Switch.Group>
      </div>
    </div>
  );
};

We should now be able to switch between each theme. However, once you reload the page, the theme preference will reset to the default theme (light green).

Saving the user's theme preference to local storage

We'll replace our usages of useState with a custom hook that we'll call useStickyState (inspired and adapted from Josh Comeau's useStickyState). Our hook will wrap the functionality of useState but also save the state to local storage.

function useStickyState(
  defaultValue: string | undefined,
  key: string,
): [string | undefined, (v: string) => void] {
  const [value, setValue] = useState<string | undefined>(defaultValue);

  useEffect(() => {
    const stickyValue = localStorage.getItem(key);
    if (stickyValue !== null) {
      setValue(stickyValue);
    }
  }, [key, setValue]);

  return [
    value,
    (v) => {
      localStorage.setItem(key, v);
      setValue(v);
    },
  ];
}

We use useEffect here because we need the component to mount before accessing local storage. This is useful in Next.js because it does server side rendering, and local storage is not accessible from a Node.js context.

At this point we can replace our usages of useState with our newly defined custom hook.

const Home: NextPage = () => {
  const [color, setColor] = useStickyState(colors[0], 'theme-color');
  const [mode, setMode] = useStickyState(modes[0], 'theme-mode');

  // etc...
};

Now the app should behave the same, but when you refresh the page, your theme preference should be saved.

Summary

Now you know how to

  • set up custom themes with CSS variables
  • extend the Tailwind color palette to define custom colors
  • use React state to switch between multiple themes
  • save the user's theme preference to local storage

Hope you found this article helpful. Give it a clap and a share if it was! 😁

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like