Adding dark theme support

We recently added dark theme support to PushOwl dashboard. PushOwl dashboard is built using Nextjs and uses Styled components for styling. This is how it looks in dark theme:

There are different ways to implement theme support with Styled components. Let’s go through those!

Theme support in Styled components

Styled components provide a ThemeProvider that you could use to send theme values to the rest of the Styled components in the app. These theme values could be switched between light and dark tokens depending on the theme that is being selected.

so in our root page _app.tsx we can do

// _app.tsx
import { ThemeProvider } from 'styled-components';

const App = ({ Component, pageProps })=> {
	// `appTheme` could be either of "light" or "dark"
  return (<ThemeProvider theme={theme[appTheme]}>
           <Component {...pageProps} />
         )
}

Structuring the tokens

  • Instead of directly using color values we store them in tokens as follows
const colors = {
  reds: [
     '#26080F',
     '#591223'
    ...
	],
  greens: [
   '#07140E',
   '#102F20'
    ...
  ],
...
}
  • Then we can use the tokens in the app using their index to avoid naming them (since naming things is hard)
  • This allows us to see all the color values used in the app in a single place and change them easily
  • We could define separate tokens for light and dark themes but the number of color tokens in one light theme may not be the same as the dark theme. For example, the number of greys might be less in the light theme compared to the dark theme
  • To avoid this issue we can define separate theme objects for both light and dark theme where these tokens get used.

Structuring theme

Approach 1

  • We can store all the tokens used in the app separately and then use strings for defining styles using light and dark theme for each component
  • We can then interpolate those strings directly in the Styled components
  • So our theme file will look as follows
// Inside button/theme.ts

const buttonDark = `
  background-color: ${greens[3]}
  border: 1px solid ${greens[4]}
`;

const buttonLight = `
  background-color: ${greens[9]}
  border: 1px solid ${greens[7]}
`;

Then in our common theme file

const light = {
  button: buttonLight
}

const dark = {
  button: buttonDark
}

our styled component will be

const Button = styled.button`
  ${({theme})=> theme.button} 
   ....
`;

Downsides

  • Strings are harder to manipulate compared to simple objects
  • If you look at the button component, it is harder to say what styles are applied straightaway without referring theme file. This can become tiresome when debugging some style issues.

Approach 2

We can make everything object.

  • So our theme file will look as follows
// Inside button/theme.ts

const buttonDark = {
  backgroundColor: greens[3],
  borderColor: greens[4]
};

const buttonLight = {
  backgroundColor: greens[9],
  borderColor: greens[7]
};

our styled component will be

import { withTheme } from 'styled-components';

const StyledButton = styled.button``;

const Button = withTheme({ theme, ... }) => {
  return (
    <StyledButton style={{ ...theme.components.button }}>
      ...
    </StyledButton>
  );
};

Downsides

  • Even-though everything is object it is still harder to track down styles since you have to refer to the theme file and styled components
  • Complex selectors (like nth selector, child selector) are harder to define and you can’t refer to another styled component within the styles
  • Style is divided between two types of styling - styled components and object-based styling

Approach 3

  • We can improve upon the previous approach and define properties directly in the object and then interpolate those properties directly
  • So our theme would mostly remain the same (but it won’t have complex selectors but just properties)
// Inside button/theme.ts

const buttonDark = {
  backgroundColor: greens[3],
  borderColor: greens[4]
};

const buttonLight = {
  backgroundColor: greens[9],
  borderColor: greens[7]
};

and in our styled components we will interpolate those properties separately

const Button = styled.button`
  background-color: ${({theme}) => theme.button.backgroundColor}
  border-color: ${({theme}) => theme.button.borderColor}
   ....
`;
  • We could group all those interpolations inside a single function to avoid defining the theme multiple times
  • With this approach, our theme is decoupled from the styling meaning if a property is the same for different variations of the component or different parts of the component it can be defined in a common place in the theme instead of redefining it multiple times ensuring consistency across different themes
  • For example, if we do have a different variation of alerts we can do the following
export const alertLight = {
  bg: colors.greys[5],
  color: colors.greys[1],
  iconColor: colors.greys[2],
  info: {
   bg: colors.blues[6]
  },
  warning: {
    bg: colors.yellows[6],
  },
  error: {
    bg: colors.reds[7],
  },
};

export const alertDark = {
  ...alertLight,
  warning: {
    bg: colors.yellows[4],
  },
};
  • Here, background, color, and icon color are shared between different variants, and only the styles that vary for that variant are redefined

Things we could be improved

  • The theme only consists of color tokens but if our theme needs to include other things like border radius, spacing etc then we may need to add those tokens too
  • Right now we use an array for color tokens this helps us avoid naming colors but if a new color token were to be introduced and we wanted to keep the color tokens sorted based on lightness it would not be easy. Since adding a color token at the beginning will change the index of other tokens
  • When we use color tokens by index having a tool to visually see the color value referred by the index would help (possibly a VSCode extension)

Until next time! 👋🏼

Rafi

Rafi