DEV Community

Vojtěch Vidra
Vojtěch Vidra

Posted on • Originally published at atmos.style

Theming made easy with React and Styled Components

Working with colors and creating a theme tends to get messy because colors are spread everywhere. We'll learn how to avoid common issues and organize colors better. In this article, you will learn:

  • How to create a theme with Styled Components
  • How to create Dark mode for a web app
  • How to never duplicate colors in code
  • How to take advantage of TypeScript to improve DX

We've used this approach to create Dark mode for our app Atmos. We can iterate on our color palette and change it in seconds, by sticking to these practices. Furthermore, anyone can jump into the code, thanks to the aligned naming convention of colors in design and code.

Without further ado, let's jump right in!

Using variables

Don't use colors directly. Always put them in a variable. CSS variables are better than SASS or JS variables, even if we're building a JS app with styled-components. We will show you later in the article why.

With this approach, we don't have to repeat ourselves. If we need to change one or more of our colors, it's just much easier to change the value of a variable instead of going through all those files and replacing them one by one.

.button {
  background-color: #123456;
  /* ⛔️ Not good, prefer variables */

  background-color: var(--button-background);
  /* ✅ Much better, don't repeat yourself */
}
Enter fullscreen mode Exit fullscreen mode

Using meaning

It's pretty common to use the same color for a couple of elements. Maybe the color of our primary text is the same as the background color of our primary button. Now imagine the text is too light and doesn't have enough contrast. We will need to change the color of the text while keeping the original color for our button. That is why it's better to name our variables by their use case rather than their relation to the palette. For example, it is better to name a color background rather than white, or button-background rather than primary-dark.

This approach is great when building a dark mode that requires two separate palettes (one for light and one for dark). At that point, naming colors by their use case is the only sensible option.

.button {
  background-color: var(--primary-dark);
  /* ⛔️ Not good, in dark mode, it's probably not dark anymore. */

  background-color: var(--button-background);
  /* ✅ Much better, consistent with dark mode */
}
Enter fullscreen mode Exit fullscreen mode

Naming convention

A naming convention is a must-have for larger teams, but it makes sense even for smaller teams. It's like building Rest API without any documentation. In the example below, we can see inspect in Figma with the same color key [in square brackets] that we'll use in the React component next to it. Having the colors clearly named in Figma removes any questions about which colors to use, especially for new joiners.

Inspect in Figma with description of color from React component

Sticking to one color notation

When creating your theme, it's better to stick to one notation to avoid duplicate colors. There are a lot of options. Most of the time, colors are in hexadecimal format or RBGA when we need an alpha channel.

It's better to use hexadecimal numbers because it's more concise. It can also be written in 8 character format to add an alpha channel with great browser support. In our codebase, we leverage the fact that we can append the last two characters of the alpha channel to a 6 character HEX code and share the same HEX code for non-transparent and transparent colors.

.button {
  background-color: rgba(1, 2, 3, 0.5);
  /* ℹ️ Alpha channel in % format is nicer. */

  background-color: #12345678;
  /* ℹ️ This is shorter and more flexible. */
}
Enter fullscreen mode Exit fullscreen mode

In the future, we could also use a wide gamut display-p3 or Lab color spaces. These can describe much more colors than RGB color space. Unfortunately, wide gamut colors are currently supported only in the latest Safari browser (early 2022).

Tip for converting percentages to HEX

We may need to convert percentages to hexadecimal notation. The following is a simple JS function that will make our life easier.

const percentToHex = (percent) => Math.round(percent * 255).toString(16);

// Example:
percentToHex(1); // Output: "FF"
Enter fullscreen mode Exit fullscreen mode

Putting it all together

Let's take a look at how to create a theme for your application. Using this method, we created a dark mode for Atmos with ease. At the same time, we can iterate on our palette with little to no effort.

Building a theme

First off, we will need a color palette. We can use a ready-made palette like Material UI or leverage our guide on How to create the best UI color palette to create our own. We can also jump right into Atmos to generate one in a matter of minutes.

It could be tempting to take the color palette, turn it into a JS object, and call it a day. That would work (kinda), but there's a better way!

In the code snippet below, we have two objects, our color palette, and our theme. Notice each has its purpose.

const lightPalette = {
  primary: {
    300: '#A3A4FF',
    400: '#8884FF',
    500: '#6C5EFA',
    600: '#573CFA',
  },
};

const lightTheme = {
  primary: {
    text: lightPalette.primary[600],
    background: lightPalette.primary[500],
  },
};
Enter fullscreen mode Exit fullscreen mode

Palette

The palette stores your colors. Typically, the palette has primary, neutral, success, and danger colors. It's important to note that the palette should be the only place where we store our colors. There could be some HEX codes in our SVG icons, but we can always overwrite those using colors from our theme. Other than that, you won't find a single HEX outside of the palette.

Theme

The theme gives meaning to our palette. For example background, text, text subtle, primary text, card background, icon, etc. As a rule of thumb, the theme is the only place, where the palette is used. If you need another color for your new component, don't use the palette directly, instead create a new item in your theme, and you're good to go. By sticking to this approach, the setup is very flexible and scalable.

Avoid flicker with CSS variables

We have created a theme now we would like to use it. If you're using any CSS-in-JS tool, the most straightforward way is to pass the theme object into a Theme provider. That would work, but it has one major flaw you should consider if you're generating HTML during a build or request on the server, with frameworks like Next.js and Gatsby.

Consider this scenario: You build your app for production, and by default, it is in light mode. The user enters your app and has dark mode selected. Because you have baked your colors into the generated JS classes, all your classes have to regenerate into the dark mode. That results in a brief flicker of light mode before the app regenerates the classes.

Flickering street lamp

CSS variables to the rescue. Since you can create CSS variables for both light and dark themes at the build phase. All you need to do is apply the correct theme when a user enters your app. Do this by reading the user's preferred theme and setting the corresponding class name to the html element. Because the CSS variables are still the same, your generated classes don't have to be regenerated.

Turning the theme into CSS variables

With our theme ready, we need to turn it into CSS variables. We will use a recursive function that turns each atomic value into a CSS variable with the name from its object keys. The string then can be assigned directly to :root.

const createCssVar = (items, prefix = '-'): string[] =>
  Object.entries(items).flatMap(([key, value]) => {
    const varName = `${prefix}-${key}`;
    if (typeof value === 'object')
      return createCssVar(value as ColorsItem, varName);
    return `${varName}:${value}`;
  });

export const createCssVars = (themeColors) =>
  createCssVar(colors).join(';');

// We're using lightTheme object from previous example
createCssVars(lightTheme)
// This will turn into:
css`
--primary-text: #573CFA;
--primary-background: #6C5EFA;
`

import { createGlobalStyle } from "styled-components";
const GlobalStyle = createGlobalStyle`
  :root {
    /* We assign variables to root element */
    ${createCssVars(lightTheme)}
  }
`
Enter fullscreen mode Exit fullscreen mode

Tip for Dark mode

When building both light and dark modes, we will also need a way to assign the correct theme to the user based on their preferences. An easier option is to stick to the system settings, then all we need is this simple media query, and that's it.

But we may want to allow users to choose between light and dark modes within the app UI and save the preferences. We can achieve this by injecting a simple script right after <head>, reading the local storage, and setting the class name for light/dark mode on the HTML element. We could try to come up with something ourselves, or we can use this React hook that will do it for us.

Our awesome dark theme is almost ready. There's one last thing to do our scrollbars are probably still white. Not in Firefox, because Firefox uses system settings. To fix our scrollbars and also make them dark, there is a simple css property or meta html tag to tell the browser that the scrollbar should be dark.

Using CSS variables

We have created a palette, light, maybe even dark theme. Now it's time to use our CSS variables. We can use it directly by referencing its value with standard CSS syntax.

.link {
  color: var(--primary-text);
}
Enter fullscreen mode Exit fullscreen mode

Or we can create a simple (type-safe) function to help us with this. A great benefit is that the function doesn't need the theme reference (unlike the Theme Provider approach). From the code snippet below, we can see that the function can be used anywhere.

// If you're using TypeScript, see tip below for ColorKey type
const color = (colorKey: ColorKey) => {
  const cssVar = colorKey.split('.').reduce((acc, key) => acc + '-' + key, '-');
  return `var(${cssVar})`;
};

const StyledButton = styled.button`
  background-color: ${color('primary.background')};
`;
const svgRender = <MySvg color={color('primary.icon')} />;
Enter fullscreen mode Exit fullscreen mode

Tip for TypeScript

We can leverage TypeScript and create a type that will help us when referencing our colors. RecursiveKeyOf is a custom type that will take an object, chain its keys recursively, and create a string type joined by .. This may sound complicated, but we don't need to understand it to use it.

// lightPalette is reference of our theme from earlier
type ColorKey = RecursiveKeyOf<typeof lightTheme>;
// ColorKey is now union of colors from our theme.
// ColorKey is now basically this:
type ColorKey = 'primary.text' | 'primary.background';
Enter fullscreen mode Exit fullscreen mode

In conclusion (TLDR)

  • Use variables, preferably CSS variables
  • Name colors by their usage rather than how they look
  • Create and stick to a naming convention
  • Stick to one color notation, HEX or RGB, it doesn't matter

If you've enjoyed this article, I'm sure you will find Atmos helpful. Whether you are just starting with a new color palette, or your current palette could use some tweaking, then you should give Atmos a shot! Hey, it's free 🚀

Latest comments (0)