Building a design system 3 - Themes

So far, we've focused on creating styles from JSS, and the last exercise finished the project setup. In this exercise, we approach themes.

The code for this project is available at:

Goals

Difficulty level

Advanced. This is an advanced tutorial and assumes that you are a moderately experienced frontend developer. Beginning developers may find this challenging.

Overview

Until now, the project has focused on stylesheets, how to create them and manage them, as well as setup, creating a repository, and adding dependencies. In this exercise, themes enter the picture.

What is a theme?

Developers know that hard-coding string values is a bad idea. Instead, we assign the value to a constant and use that wherever it's needed. Imagine, for example, a marketing site with the company phone number prominently displayed throughout. Instead of writing the phone number in every place that it's needed, I assign it to a variable, then use it in my markup. In React JSX, it might look something like this simple example.

function PhoneContact(props) {
  return <p>Call us at {props.phone}</p>;
}

const phoneNumber = '(123) 456-7890';

const element = <PhoneContact phone={{phoneNumber}} />

Taken a step further, I can put all of the constants in a file and import them where needed.

// file: constants.js

export const phoneNumber = '(123) 456-7890';
// file components/PhoneContact.jsx

function PhoneContact(props) {
  return <p>Call us at {props.phone}</p>;
}

export default PhoneContact;
// file: myContactPage

import PhoneContact from './components/PhoneContact';
import { phoneNumber } from './constants';

const element = <PhoneContact phone={{phoneNumber}} />;

This is not new, we've been doing this forever. We all learn, at some point, the pain of dealing with hard-coded values and reach for any number of patterns for shared string values.

In this project, we've already done this with action names in our types file.

// snippet from file: /src/types/types/ts

export const ADD_SHEET = 'ADD_SHEET';

We could put those constants in a constants.ts file, or a helper, or wherever you like. Sometimes we might assign them to object properties, and sometimes they might be numbers.

// file: constants.js

const constants = {
  address: '123 main st',
  city: 'Anywhere',
  company: 'Widgets Inc.',
  phoneNumber: '(123) 456-7890',
  zip: 12345
}

export default constants;

Then use them in our element.

// file: myContactPage

import PhoneContact from './components/PhoneContact';
import constants from './constants';

const element = <PhoneContact phone={{constants.phoneNumber}} />;

So what does this have to do with themes? We've not discovered anything new, and we understand how constants can be used for strings, so we don't have to hard-code them anywhere. However, those strings don't always have to be presentational.

If we look at any CSS, there are strings galore. Strings for color, strings for media queries, drop shadows, position, display, and there are even numbers. Font weight, line height, and Z index all use numbers.

.cta {
  background: #d4d4d4;
  color: #f2f2f2;
  font-weight: 7;
  z-index: 100;
}

Since we're using JSS, it would look like this:

const styles = {
  cta: {
    background: '#d4d4d4',
    color: '#f2f2f2',
    fontWeight: 7,
    zIndex: 100
  }
};

See the strings and numbers? Instead of hard-coding them, we can put them in constants. When we put those style values in constants, they are called tokens. A theme, then, is a collection of tokens. Therefore, the theme is the set of constants that hold reusable style values.

The tokens are in the theme, the styles are everywhere we need them.

// file: theme.js

const theme = {
  fontWeight: 7,
  fgColor: '#f2f2f2',
  bgColor: '#d4d4d4',
  zIndex: 100
};

export default theme;
// file: styles.js

import theme from './theme';

const styles = {
  cta: {
    background: theme.bgColor,
    color: theme.fgColor,
    fontWeight: theme.fontWeight,
    zIndex: theme.zIndex
  }
};

Typically, the theme is a more general representation of style values. We might group our colors by their role in the design instead of their role in the JSS.

// file theme.js

const theme = {
  colors: {
    background: {
      primary: '#f2f2f2',
      secondary: '#4d4d4d'
    }
    primary: '#ff0000',
    secondary: '#0000ff',
    text: {
      dark: '#4d4d4d',
      light: '#f2f2f2'
    }
  },
  fontWeights: {
    bold: 7,
    light: 3,
    regular: 5
  },
  zIndex: 100
};

export default theme;
// file: styles.js

import theme from './theme';

const styles = {
  cta: {
    background: theme.colors.background.secondary,
    color: theme.colors.text.light,
    fontWeight: theme.fontWeights.bold,
    zIndex: theme.zIndex
  }
};

The styles will transpile to the CSS we want.

.cta {
  background: #d4d4d4;
  color: #f2f2f2;
  font-weight: 7;
  z-index: 100;
}

In this exercise, we introduce themes to our project.

Dependencies

Javascript lacks the functionality to easily deep merge two or more objects. We could write a helper, or use a library. Deep merge is complicated, so a library it is. For this exercise, we're using deepmerge, however, use whatever library or function you like.

npm install deepmerge

Add types

The typing strategy for themes is to keep them fairly narrow and fairly generic. Color, for example, is an interface for all colors, and the palette is an interface with colors in it. We'll keep it simple for now and include some things we'll use to style a button in a future exercise. This includes color and shape.

// snippet from file: /src/styles/types/types.ts

export interface PaletteColor {
  alternate: string;
  color: string;
  hover: string;
}

export interface Palette {
  primary: PaletteColor;
  secondary: PaletteColor;
}

export interface PaletteOptions {
  primary?: Partial<PaletteColor>;
  secondary?: Partial<PaletteColor>;
}

export interface Shape {
  borderRadius: string | number;
}

export interface Theme {
  palette: Palette;
  shape: Shape;
}

export interface ThemeOptions {
  palette?: PaletteOptions;
  shape?: Shape;
}

Notice the interfaces PaletteOptions and ThemeOptions. These are the interfaces a developer can use to override theme values, which is why they are partials. A developer might do this, for example, if the designer has a special holiday theme they want to use for a certain time or if you have a customer that wants to use their brand identity on a private label implementation of the host application.

Next, add some types for a new Redux action to keep the theme in state.

// snipped from file: /src/styles/types/types.ts

export const SET_THEME = 'SET_THEME';

interface SetThemeAction {
  type: typeof SET_THEME;
  theme: Theme;
}

export type ActionTypes = AddSheetAction | SetThemeAction;

Update the store

Our project supports one theme at a time, and we track that theme in the Redux store. Even though we only support one theme, multi-theme support might be used by the host application. React context, for example, may conditionally provide themes for light or dark mode. There might also be a context for private label themes, personal themes, or any use case that requires more than one theme. Knowing what theme is the desired theme is why we keep the current theme in the store. We'll also need an initial, default theme, so the host application doesn't have to provide a theme at all.

Action

Create an action to add a theme to the store by adding the action creator to our actions file, which should now look like this.

// file: /src/store/actions.ts

import { ActionTypes, ADD_SHEET, SET_THEME, Sheet, StyleSheet, Theme } from '../types/index';

export function addSheet(sheet: Sheet<string, StyleSheet>): ActionTypes {
  return { type: ADD_SHEET, sheet };
}

export function setTheme(theme: Theme): ActionTypes {
  return { type: SET_THEME, theme };
}

Reducer

Update the reducer with a new case in the reducer function, and add a theme to the initial state. The theme in the initial state is the default theme.

// file: /src/store/reducer.ts

import { ActionTypes, ADD_SHEET, SET_THEME, State } from '../types/index';

export const initialState: State = {
  sheets: {},
  theme: {
    palette: {
      primary: {
        alternate: '#397B91',
        color: '#1b3b46',
        hover: '#1C2852'
      },
      secondary: {
        alternate: '#F7E3CD',
        color: '#AB9D8E',
        hover: '#B98F62'
      }
    },
    shape: {
      borderRadius: '16px'
    }
  }
};

export function reducer(state: State = initialState, action: ActionTypes): State {
  switch (action.type) {
    case ADD_SHEET:
      const sheets = { ...state.sheets, ...action.sheet };
      return { ...state, sheets: sheets };
    case SET_THEME:
      return { ...state, theme: action.theme };
    default:
      return state;
  }
}

Theme creator

The theme creator has one job: take a developer-provided theme, themeOptions, if any, and use it to update the internal default theme.

// file: /src/styles/createTheme/createTheme.ts

import defaultTheme from '../defaultTheme/index';
import { Theme, ThemeOptions } from '../../types/index';

const deepmerge = require('deepmerge');

export default function createTheme(userTheme: ThemeOptions = {}): Theme {
  const palette = deepmerge(defaultTheme.palette, userTheme.palette);
  const shape = userTheme.shape ? { ...userTheme.shape } : { ...defaultTheme.shape };

  return { palette, shape };
}

Looking closely, you'll see two constants, palette, and shape, in the createTheme function. While we could deepmerge the themeOptions directly into the default theme, it's advantageous to create each root property on its own. In time, the theme will grow, there will be much more in it than palette and shape, and each of those root properties will have a creator of its own. We're working in that direction because each of those creators will connect design tokens from an application, such as Figma, to our theme.

The default theme comes from the defaultTheme function we built in exercise 1.

Remember to add an index...

// file: /src/styles/createTheme/index.ts

export { default } from './createTheme';
export * from './createTheme';

...and add it to the barrel.

// snippet from file: /src/styles/index.ts

export { default as createTheme } from './createTheme/index';
export * from './createTheme/index';

Theme provider

The provider is a function the host application developer will call when a theme other than the default is required. The default theme is already in state, so there is no need to call a provider for what already exists. However, there are many cases where a custom theme is useful.

// file: /src/styles/provideTheme/provideTheme.ts

import createTheme from '../createTheme/index';
import { Theme, ThemeOptions } from '../../types/index';
import { store, setTheme } from '../../store/index';

const deepmerge = require('deepmerge');

export default function provideTheme(themeOptions: ThemeOptions | ThemeOptions[] = {}) {
  const composedThemeOptions: ThemeOptions = Array.isArray(themeOptions) ? deepmerge.all(themeOptions) : themeOptions;
  const theme: Theme = createTheme(composedThemeOptions);
  store.dispatch(setTheme(theme));
}

Taking a closer look, we first import the theme creator, then some types, then the stuff we need from the store, the correct action, and the store object so we can dispatch the action.

The function has one prop, themeOptions, and can accept an argument of a single themeOptions object, or an array of themeOptions objects. If an array is provided, they are used in array order.

The constant, composedThemeOptions is either the themeOptions argument, or the themeOptions array argument deepmerged into a single object.

The composedThemeOptions is passed to the theme creator, and the result is dispatched to the store.

Once again, we add an index...

// file: /src/styles/provideTheme/index.ts

export { default } from './provideTheme';
export * from './provideTheme';

...and add it to the barrel.

// snippet from file: /src/styles/index.ts

export { default as provideTheme } from './provideTheme/index';
export * from './provideTheme/index';

Using the theme

Finally, we need a way to access the theme, so let's create a function to return the theme from the store.

// file: /src/styles/useTheme/useTheme.ts

import { Theme } from '../../types/index';
import { store } from '../../store/index';

export default function useTheme(): Theme {
  return store.getState().theme;
}

There's not much happening here except returning the theme from state.

Once again, add an index...

// file: /src/styles/useTheme/index.ts

export { default } from './useTheme';
export * from './useTheme';

...and add it to the barrel.

// snippet from file: /src/styles/index.ts

export { default as useTheme } from './useTheme/index';
export * from './useTheme/index';

Testing

Before we march into the next exercise and begin creating and using themes, let's test what we did so far.

The store

Add a test for the action we created.

// snippet from file: /src/store/actions.test.ts

it(`should create a ${SET_THEME} action`, () => {
  const action = setTheme(theme);
  expect(action).toEqual({ type: SET_THEME, theme: theme });
});

The full actions test should now look like this.

// file: /src/store/actions.test.ts

import { StyleSheetFactoryOptions } from 'jss';
import { addSheet, setTheme } from './actions';
import { initialState } from './reducer';
import { ADD_SHEET, SET_THEME, StyleSheet } from '../types/index';
import createStylesheet from '../styles/createStylesheet/index';

const { theme } = initialState;
const options: StyleSheetFactoryOptions = {
  meta: 'Styles'
};
const stylesheet: StyleSheet = createStylesheet({}, options);

describe('Actions', () => {
  it(`should create an ${ADD_SHEET} action`, () => {
    const key = options.meta || '';
    const sheet = { [key]: stylesheet };
    const action = addSheet(sheet);
    expect(action).toEqual({ type: ADD_SHEET, sheet });
  });

  it(`should create a ${SET_THEME} action`, () => {
    const action = setTheme(theme);
    expect(action).toEqual({ type: SET_THEME, theme: theme });
  });
});

Next, add a test for changes to the reducer.

// snippet from file: /src/store/reducer.test.ts

it(`it should add a theme to state from a ${SET_THEME} action`, () => {
  const { theme } = initialState;
  theme.palette = { ...theme.palette };
  const action: ActionTypes = {
    type: SET_THEME,
    theme: theme
  };
  const newState = reducer(initialState, action);
  expect(newState).toEqual({ ...initialState, theme: theme });
});

The reducer test should now look like this.

// file: /src/store/reducer.test.ts

import { StyleSheet, StyleSheetFactoryOptions } from 'jss';
import { reducer, initialState } from './reducer';
import { ActionTypes, ADD_SHEET, SET_THEME } from '../types/index';
import createStylesheet from '../styles/createStylesheet/index';

describe('Reducer', () => {
  it(`should add a sheet to state from an ${ADD_SHEET} action`, () => {
    const options: StyleSheetFactoryOptions = {
      meta: 'Styles'
    };
    const stylesheet: StyleSheet = createStylesheet({}, options);
    const key = options.meta || '';
    const sheet = { [key]: stylesheet };
    const action: ActionTypes = { type: ADD_SHEET, sheet };
    const newState = reducer(initialState, action);
    const sheetInStore = newState.sheets[key];
    expect(sheetInStore).toEqual(stylesheet);
  });

  it(`it should add a theme to state from a ${SET_THEME} action`, () => {
    const { theme } = initialState;
    theme.palette = { ...theme.palette };
    const action: ActionTypes = {
      type: SET_THEME,
      theme: theme
    };
    const newState = reducer(initialState, action);
    expect(newState).toEqual({ ...initialState, theme: theme });
  });
});

Theme creator

Add a test for the theme creator. Notice how there is a test for each root property.

// file: /src/styles/createTheme/createTheme.test.ts

import createTheme from './createTheme';
import { ThemeOptions } from '../../types/index';

describe('createTheme', () => {
  it('should return the default theme if no options provided', () => {
    const theme = createTheme();
    const expectedTheme = createTheme({});
    expect(theme).toEqual(expectedTheme);
  });

  it('should return a theme with updated palette', () => {
    const themeOptions: ThemeOptions = {
      palette: {
        primary: {
          color: '#9b9b9b'
        }
      }
    };
    const theme = createTheme(themeOptions);
    const localTheme = createTheme();
    localTheme.palette.primary.color = '#9b9b9b';
    expect(theme).toEqual(localTheme);
  });

  it('should return a theme with an updated shape', () => {
    const themeOptions: ThemeOptions = {
      shape: {
        borderRadius: 6
      }
    };
    const theme = createTheme(themeOptions);
    expect(theme.shape).toEqual(themeOptions.shape);
  });
});

Theme provider

Add a test for the theme provider.

// file: /src/styles/provideTheme/provideTheme.test.ts

import provideTheme from './provideTheme';
import useTheme from '../useTheme/index';
import { ThemeOptions } from '../../types/index';
import createTheme from '../createTheme/index';

describe('provideTheme', () => {
  it('should provide a theme', () => {
    provideTheme();
    const providedTheme = useTheme();
    const expectedTheme = createTheme();
    expect(providedTheme).toEqual(expectedTheme);
  });

  it('should provide a theme from theme options', () => {
    const themeOptions: ThemeOptions = {
      shape: {
        borderRadius: 6
      }
    };
    provideTheme(themeOptions);
    const providedTheme = useTheme();
    const expectedTheme = createTheme(themeOptions);
    expect(providedTheme).toEqual(expectedTheme);
  });

  it('should provide a theme from an array of theme options', () => {
    const themeOptions: ThemeOptions[] = [];
    themeOptions.push({
      shape: {
        borderRadius: 12
      }
    });
    themeOptions.push({
      shape: {
        borderRadius: 6
      }
    });
    const mergedThemeOptions: ThemeOptions = {
      shape: {
        borderRadius: 6
      }
    };
    provideTheme(themeOptions);
    const providedTheme = useTheme();
    const expectedTheme = createTheme(mergedThemeOptions);
    expect(providedTheme).toEqual(expectedTheme);
  });
});

In this test, we test both a single themeOptions object and an array. In case you are wondering about the numbers for borderRadius, JSS, when using the default preset, will convert numbers to pixel strings. We use numbers, but the number 6 becomes "6px" when transpiled.

useTheme

Finally, a test for the useTheme function.

// file: /src/styles/useTheme/useTheme.test.ts

import useTheme from './useTheme';
import { initialState } from '../../store/index';

describe('useTheme', () => {
  it('should return a theme', () => {
    const theme = useTheme();
    expect(theme).toEqual(initialState.theme);
  });
});

Finish

We're making progress in building out the theming engine of our design system project. In the first few exercises, we built functionality to deal with JSS and stylesheets. In this exercise, we set up themes. In the next exercise, we'll start putting it all together by adding one more piece, a classes provider which lets us access the CSS classes once they've been themed and transpiled, after which we can begin using what we've created in a host application.