DEV Community

Laurence Davies
Laurence Davies

Posted on

Creating dynamic themes with React & TailwindCSS

Adding themes is not usually the first thing you'd think about when starting a new project, but what if it was really simple to set up? Imagine living in a world where you were limited to the default VSCode or Slack colour scheme 😱

We're going to look at a way of creating your own dynamic themes using the awesome CSS Variables, which we will be able to update dynamically from within our app to change our whole colour scheme. I'm using TailwindCSS as a css framework here, as it provides everything you need to easily build beautiful, custom user interfaces. Just know that CSS variables are a universal web concept, and you can apply the theming techniques in this article to any javascript project, not just with react and tailwind.

Table Of Contents

Project Setup

We are going to make use of create-react-app as an easy starting point for our themed app. Just run the npx command below to get your project going.



npx create-react-app my-themed-react-app --template typescript


Enter fullscreen mode Exit fullscreen mode

Note: We are going using react with typescript in this tutorial. Feel free to go with the standard template, it won't make too much difference in what we're trying to cover here.

Adding and configuring TailwindCSS

Now, we're going to add tailwind as a devDependency to our project. Run the below code:



yarn add tailwindcss -D


Enter fullscreen mode Exit fullscreen mode

Then we're going to generate a config file to allow us to customise our tailwind installation. This step is important as this config file will act as a bridge between our theme colours and our tailwind classes.



npx tailwind init tailwind.js


Enter fullscreen mode Exit fullscreen mode

We're going to add tailwind as a PostCSS plugin, and also add autoprefixer to parse our CSS and add vendor prefixes to CSS rules using values from Can I Use. We'll also add the postcss-import plugin, to allow us to break up our css across multiple files.



yarn add postcss-cli autoprefixer postcss-import -D


Enter fullscreen mode Exit fullscreen mode

Then we configure PostCSS by creating a postcss.config.js file in our root directory:



// postcss.config.js
const tailwindcss = require('tailwindcss');

module.exports = {
  plugins: [
    require('postcss-import'),
    tailwindcss('./tailwind.js'),
    require('autoprefixer'),
  ],
};


Enter fullscreen mode Exit fullscreen mode

Now here is where things get interesting. We are going to have PostCSS process our css and generate a new css file. This new auto-generated file will have all our app's css, as well as all the tailwind classes.

So how we are going to do this is:

  1. We are going to move the current src/App.css to a new directory: src/css/app.css.
  2. We will have PostCSS read in src/css/app.css and output a new css file to the original src/App.css.
  3. We will create a new css file for our tailwind imports, and import that file into src/css/app.css.
  4. We will create a script to run PostCSS before our app starts.
  5. For good measure, we will add src/App.css to .gitignore, as it will be recreated every time we run the project.


/* src/css/tailwind.css */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';


Enter fullscreen mode Exit fullscreen mode

And then at the top of src/css/app.css:



/* src/css/app.css */
@import './tailwind.css';


Enter fullscreen mode Exit fullscreen mode

Now we'll add a script to our package.json to run before we start our app. This will tell PostCSS to generate the css file to be used by our app.



  "scripts": {
    ...,
    "prestart": "postcss src/css/app.css -o src/App.css"
  },


Enter fullscreen mode Exit fullscreen mode

Be sure that your main App.tsx file is importing the auto-generated css src/App.css. If you followed all the naming conventions, it should be doing so by default.

And that's it! Now when we start our app, we will be able to make us of all the tailwind goodies. Let's test it out by adding a tailwind class to change the background colour of the default app screen.



// src/App.tsx
<div className="App bg-red-900">


Enter fullscreen mode Exit fullscreen mode

background-colour-react

Et Voilà!

Setting up our Themes

I just want to take a second to think about what we are trying to achieve here. We want to create a theme, configured in a central location and applied across the whole app. We want to be able to create many different themes, and dynamically apply them. As a bonus, it would be great to be able to extend an existing theme (For example, to create a Dark Mode).

So I'm going to start off by creating a new folder src/themes, and in it create a file called base.ts. In this file I'm going to store some variables for our theme.



// src/themes/base.ts
export default {
  primary: '#61DAFB',
  secondary: '#254E70',
  negative: '#e45b78',
  positive: '#A3D9B1',
  textPrimary: '#333',
  backgroundPrimary: '#efefef',
  backgroundSecondary: '#F6F9FC',
};


Enter fullscreen mode Exit fullscreen mode

Now we are going to need a way to take these variables, and map them to css variables to be used by our app. Let's create a new file in the src/themes called utils.ts. Here we will create a function to map our theme variables.



// src/themes/utils.ts
export interface ITheme {
  [key: string]: string;
}

export interface IThemes {
  [key: string]: ITheme;
}

export interface IMappedTheme {
  [key: string]: string | null;
}

export const mapTheme = (variables: ITheme): IMappedTheme => {
  return {
    '--color-primary': variables.primary || '',
    '--color-secondary': variables.secondary || '',
    '--color-positive': variables.positive || '',
    '--color-negative': variables.negative || '',
    '--color-text-primary': variables.textPrimary || '',
    '--background-primary': variables.backgroundPrimary || '',
    '--background-sec': variables.backgroundSecondary || '',
  };
};


Enter fullscreen mode Exit fullscreen mode

Now we are going to need to create a new function to take this theme, and apply the css variables to the :root element of our document. This function, applyTheme, is going to take the string name of our theme, map the variables, then apply it to the :root element.

First, let's create a way to export all our themes in one place, src/themes/index.ts.



// src/themes/index.ts
import base from './base';
import { IThemes } from './utils';

/**
 * The default theme to load
 */
export const DEFAULT_THEME: string = 'base';

export const themes: IThemes = {
  base,
};


Enter fullscreen mode Exit fullscreen mode

Now we can import the list of themes into our new applyTheme function in utils.ts. This function will take the name of our theme, look for it in our list of exported themes, map the css variables, then loop over the mapped object and apply each style to the :root element.



// src/themes/utils.ts
import { themes } from './index';

...

export const applyTheme = (theme: string): void => {
  const themeObject: IMappedTheme = mapTheme(themes[theme]);
  if (!themeObject) return;

  const root = document.documentElement;

  Object.keys(themeObject).forEach((property) => {
    if (property === 'name') {
      return;
    }

    root.style.setProperty(property, themeObject[property]);
  });
};


Enter fullscreen mode Exit fullscreen mode

If you are wondering, the reason why we map our variables instead of just starting with a mapped object, is so that we can make use of the original javascript theme variables anywhere in our components later on if we need.

Now we can call applyTheme anywhere in our app, and it will dynamically apply our new themes variables. As a finishing touch, let's add a function to utils.ts that will allow us to extend an existing theme, and then create a dark theme that we can switch to.

Our extend function will take an existing theme, and then make use of the ES6 spread operator to clone the existing theme and then override it with any new variables that we pass it.



// src/themes/utils.ts

...

export const extend = (
  extending: ITheme,
  newTheme: ITheme
): ITheme => {
  return { ...extending, ...newTheme };
};


Enter fullscreen mode Exit fullscreen mode

Now we can create our dark theme, and export it.



// src/themes/dark.ts
import { extend } from './utils';
import base from './base';

export default extend(base, {
  backgroundPrimary: '#444444',
  backgroundSecondary: '#7b7b7b',
  textPrimary: '#fff',
});



Enter fullscreen mode Exit fullscreen mode

Getting Tailwind to use our theme

Now we need to tell Tailwind to make use of our css variables, so that when we make use of a tailwind class like text-primary, it uses the colour we supplied in our active theme. Tailwind makes this pretty easy for us to do; all we need is to add the variables that we have created into the root tailwind.js file.



// tailwind.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        secondary: 'var(--color-secondary)',
        negative: 'var(--color-negative)',
        positive: 'var(--color-positive)',
        'primary-background': 'var(--background-primary)',
        'sec-background': 'var(--background-sec)',
        'primary-text': 'var(--color-text-primary)',
      },
    },
    backgroundColor: (theme) => ({
      ...theme('colors'),
    }),
  },
  variants: {
    backgroundColor: ['active'],
  },
  plugins: [],
};


Enter fullscreen mode Exit fullscreen mode

And that's it! Now we can make use of the tailwind classes, and those classes should make use of our active theme. Let's test it out by changing the background colour of our app to our primary-background colour.

First we need to apply our default theme when the app loads. To do this we will make use of the useEffect hook to run our applyTheme function the very first time the app loads, and every time we change the theme state. We'll create a component state variable to track the active theme, and set the initial value to our default theme.



// src/App.tsx
import React, { useEffect, useState } from 'react';
import { DEFAULT_THEME } from './themes';
import { applyTheme } from './themes/utils';
import logo from './logo.svg';
import './App.css';

function App() {
  const [theme, setTheme ] = useState(DEFAULT_THEME);

  /**
   * Run the applyTheme function every time the theme state changes
   */
  useEffect(() => {
    applyTheme(theme);
  }, [theme]);

  return (
    <div className="App bg-primary-background">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

If we start our app and examine our root element, we should be able to see all the css variables that we set.

Alt Text

Now let's change the background class to use our primary background colour.



// src/App.tsx
<div className="App bg-primary-background">


Enter fullscreen mode Exit fullscreen mode

background-app-primary

Awesome right?! Now we can make use of tailwind's classes with our custom colours, and if the css variables in our document root ever change, so will all the colours across our app.

Implementing our theme switcher

Now that we have our theme set up, let's create a way to switch between different themes. What I'm going to do is create a simple button component that we can use to demonstrate our theme switching. This button will make use of the tailwind colour classes, so we can better see how our theme changes as we click the button. Let's create a Button.tsx component in a new folder src/components.



// src/components/Button.tsx
import React from 'react';

type ButtonProps = {
  children?: React.ReactNode;
  onClick?: () => void;
};

export const Button: React.FunctionComponent<ButtonProps> = ({
  children,
  onClick = () => {},
}: ButtonProps) => {
  const baseClasses: string =
    'border-2 outline-none focus:outline-none normal-case tracking-wide font-semibold rounded shadow-xl text-xs px-4 py-2';

  const colourClasses: string =
    'border-primary active:bg-primary-background text-primary bg-sec-background';

  /**
   * Render the button
   */
  return (
    <button className={`${baseClasses} ${colourClasses}`} type="button" onClick={() => onClick()}>
      {children}
    </button>
  );
};


Enter fullscreen mode Exit fullscreen mode

We can now import our button into our main App.tsx component. Let's use some conditional rendering to show one button for our base theme, and another for our dark theme. In this example we are just going to assume that only these two themes exist. Each button will execute our setTheme function, which which update our state variable and in turn execute the applyTheme function in the useEffect hook.



// src/App.tsx
import React, { useEffect, useState } from 'react';
import { DEFAULT_THEME } from './themes';
import { applyTheme } from './themes/utils';
import { Button } from './components/Button';
import logo from './logo.svg';
import './App.css';

function App() {
  const [theme, setTheme] = useState(DEFAULT_THEME);

  /**
   * Run the applyTheme function every time the theme state changes
   */
  useEffect(() => {
    applyTheme(theme);
  }, [theme]);

  return (
    <div className="App bg-primary-background">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p className="text-primary-text">
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <div className="mt-4">
          {theme === 'base' ? (
            <Button onClick={() => setTheme('dark')}>Apply Dark Theme</Button>
          ) : (
            <Button onClick={() => setTheme('base')}>Apply Light Theme</Button>
          )}
        </div>
      </header>
    </div>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

And now we can finally see our theme switching in action!

Alt Text

Conclusions

And that's a wrap! Thanks for getting to the end, I hope you found the techniques in this article useful. I just want to reiterate that the main concepts that we used here are not specific to react projects or tailwind. You can create your own classes/styles using the css variables that we set in our themes - that's the awesome thing about css variables!

This post got pretty long, so I'm going to follow it up with another one which will cover providing theme context to your whole app using the Context API, optimising your css build for production, handling dynamic css classes and writing unit tests for everything.

Source code: https://github.com/ohitslaurence/react-dynamic-theming

Top comments (17)

Collapse
 
fractal profile image
Fractal

Great article!! This helped me implement a similar system in Svelte. However, I noticed that any box-shadows are removed when applyTheme() runs. Do you have any idea how I might get around this issue?

Collapse
 
kevinast profile image
Kevin Bridges

Thanks for your article and insight ... very good information!

I am a new tailwind user. In my learning quest, I just published a color theming utility called tw-themes that is in-line with your article.

You can find the docs here: tw-themes.js.org/

It promotes dynamic color themes that are selectable at run-time. The best part is it automates your dark-mode through "shade inversion"!

When you have time, I would be curious of your thoughts.

Collapse
 
sahildhimandesigner profile image
Sahil Dhiman

Hello Laurence
I have implemented the code as per above instruction. But, when I am clicking on button to change the dark mode getting some error. Like : TypeError: Cannot read property 'primary' of undefined
Can you please help me that what are the reason behind of this?

Collapse
 
ohitslaurence profile image
Laurence Davies

Hi Sahil,
So I was just looking at the above code, and I can see where it might be going wrong. So if you look for the line where I say Now we can create our dark theme, and export it., I don't say explicitly where to export that file to. So what you need to do is go to themes/index.ts, and add the dark theme in.

// src/themes/index.ts
import base from './base';
import dark from './dark'; // add this
import { IThemes } from './utils';

/**
 * The default theme to load
 */
export const DEFAULT_THEME: string = 'base';

export const themes: IThemes = {
  base,
  dark, // add this
};

Now, when the applyTheme function looks at your exported themes, it will be able to find your dark theme and apply it. Hope that helps!

Collapse
 
javierriveros profile image
Javier Riveros

Great article, I have a question, It is necessary to apply the theme every time the state changes?, Wouldn't it lead to performance issues?

Collapse
 
ohitslaurence profile image
Laurence Davies • Edited

Thanks man! Great question, so the short answer is no. It's not running every time the state updates, but rather every time the theme state variable updates. If you take a look our useEffect hook, we have the theme variable as an effect dependency. This means it will only run the first time we set that variable (in the useState hook), and then when we make changes to it (through the setTheme function). It's why hooks are so awesome, no hidden side-effects.

If this were not just an example app, I would create a theme context provider and use a hook to run the applyTheme the very first time the app loads only. All subsequent changes would be done directly through an action of some sorts. I'm hoping to show that in a follow-up post :)

Collapse
 
javierriveros profile image
Javier Riveros

Oh, thank you for the explanation, I wasn't looking at the dependency array. Now it is clearer.

Collapse
 
dsidev profile image
DSI Developer

Excellent article

Collapse
 
pedromiotti profile image
Pedro Miotti Arribamar

You helped me a lot! Thanks for taking the time to write the article

Collapse
 
laurenmwright profile image
laurenmwright • Edited

Extremely helpful - the only issue I have encountered is that when my postcss script runs and generates App.css, the script is not generating my theme styles (--color-primary) for most prefixes.

For example, it builds text-primary but does not build bg-primary.

When I downgrade my postcss/tailwind dependencies to the version used in this tutorial, i get the bg-primary classes in the App.css.

Why would the latest postcss not build all my themed styles?

Collapse
 
laurenmwright profile image
laurenmwright

I figured it out - Tailwind v2+ has a Just In Time mode so it will only generate styles if the builder reads them ahead of time. I was using a value loaded from state in the classNames so it couldn't read it.

From what I can tell, you have a couple of options if you want to references your style themes from variables :

  1. inject a bunch of the tailwind classnames into a static field and just not display it
  2. avoid using tailwind for any styles you want to apply based on a state value but using inline styles or some sort of styled component.

Not great IMO

Collapse
 
neginbasiri profile image
Negin Basiri

How do you support live css watch in this setup?

Collapse
 
tramsi profile image
Tramsi

Great article

Collapse
 
andygr1n1 profile image
Andrew

This is powerfull, my respect