Building a design system 1 - setup

Let's begin. If you like, you can review the introduction before we proceed.

In this walkthrough, we'll set up a repository and start adding to it. Our design system repo covers all the groundwork, and we'll add the first code.

The code for this post is available at:

Goals

Skills

You should know how to set up a Git repo, have a basic understanding of Typescript, and be familiar with Jest and Redux.

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

This is the first step on our way to building the engineering side of a design system. The design side will be discussed later, but for now, we're going to set up the things we need to create themes, then add tokens.

The ultimate goal of our system is the consistent application of UX via components which, as we will see, do not always have to be part of the design system, and the consistent application of style via a theming engine, which is where we're starting in this exercise.

For the designer, this means consistent appearance and functionality, and for the developer, this means style and UX come ready-made, and when the designer changes the design, there is no refactoring. We are creating an automated application of style and user experience.

Repositories

This project requires three repositories, one for the design system because it will be distributed via NPM, another for a monorepo with our test applications, and a third optional repo for Angular. If you'd like to test with Angular, having it in its own repo may not be necessary, considering how you build your monorepo. Our monorepo will use Lerna and possibly Astro, which doesn't work with Angular, so I isolate NG on an island of its own.

For now, only the design system repo is needed. We won't go into how to create a repo here, but if you are following along, create a new repo, and let's start adding some dependencies.

Dependencies

Over the course of this project, we'll add dependencies as we need them. For this exercise, install the following:

JSS

The JSS package transpiles JSS to CSS and injects a stylesheet into the head of an HTML document. We'll also install the default preset.

JSS documentation: https://cssinjs.org

Install using NPM or Yarn:

npm install jss jss-preset-default

or

yarn add jss jss-preset-default

Redux

Redux is for state management. While JSS comes with a stylesheet manager, we have more things than stylesheets in our store. Eventually, the store will have stylesheets, themes, and tokens, but we'll deal with themes and tokens later. For now, we'll build the store with placeholders.

Redux documentation: https://redux.js.org/

Install using NPM or Yarn:

npm install redux

or

yarn add redux

Typescript

Install using NPM or Yarn:

npm install --save-dev typescript

or

yarn add --dev typescript

Jest

For our tests, install jest version 28

Install using NPM or Yarn:

npm install --save-dev jest@^28.1.8

or

yarn add --dev jest@^28.1.8

For this project, we're using the jsdom test environment which is no longer included in the core Jest package.

Install using NPM or Yarn:

npm install --save-dev jest-environment-jsdom

or

yarn add --dev jest-environment-jsdom

Now add the Jest preset for Typescript.

Install using NPM or Yarn:

npm install --save-dev ts-jest

or

yarn add --dev ts-jest

Since we're using Typescript, we need some types. Types are included in Jest core; however, if we want to use those, they must be imported into every test. If you don't want to do that, add the types from Definitely Typed.

Install using NPM or Yarn:

npm install --save-dev @types/jest

or

yarn add --dev @types/jest

Optionally, if you like JUnit output for your tests, install the Jest JUnit plugin.

Install using NPM or Yarn:

npm install --save-dev jest-junit

or

yarn add --dev jest-junit

CSSType

JSS provides JSS types, but we also need CSS types.

Install using NPM or Yarn:

npm install --save-dev csstype

or

yarn add --dev csstype

Configure Typescript

Add these three files to the project root.

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "es5",
    "module": "esnext",
    "lib": ["esnext", "dom"],
    "declaration": true,
    "sourceMap": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "noImplicitAny": false,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": false,
    "noImplicitThis": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "exclude": ["**/dist", "**/*.test*", "**/testing"]
}

tsconfig.dev.json

{
  "extends": "./tsconfig.json",
  "include": ["./src/**/*"]
}

tsconfig.test.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "allowJs": true,
    "outDir": "./out-tsc/spec",
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,
    "types": ["jest", "node"]
  },
  "files": ["./src/test.ts"],
  "include": ["./src/**/*.test.ts", "./src/**/*.d.ts"]
}

Configure Jest

Add jest.config.js to the project root.

module.exports = {
  coveragePathIgnorePatterns: ['/node_modules/', 'index.ts'],
  globals: {
    'ts-jest': {
      tsconfig: 'tsconfig.test.json'
    }
  },
  moduleFileExtensions: ['ts', 'js'],
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  testMatch: ['**/?(*.)+(test).ts?(x)'],
  transform: { '^.+\\.(js|jsx|ts|tsx)$': 'ts-jest' },
  reporters: ['default', 'jest-junit']
};

We need a way to run our tests, so add these NPM scripts to your pacakge.json scripts section:

 "test": "jest --maxWorkers=50%",
 "test:dev": "jest --clearCache && jest --coverage --no-cache --maxWorkers=50%"

The test:dev script will show test coverage in the terminal when you run it. The maxWorkers argument is a recommended performance setting.

File structure

At this point, your repo file structure should look like this:

| node_modules
| .gitignore
| jest.config.js
| LICENSE // if you created one
| package-lock.json
| package.json
| README.md
| tsconfig.dev.json
| tsconfig.json
| tsconfig.test.json

Now create a src folder and add a store folder for our Redux store, a styles folder for our theming engine functions, and a types folder for our typescript types.

| node_modules
| src
|- store
|- styles
|- types
| .gitignore
| jest.config.js
| LICENSE
| package-lock.json
| package.json
| README.md
| tsconfig.dev.json
| tsconfig.json
| tsconfig.test.json

Add types

These are the types we'll need for this exercise. Create a types.ts file in the types folder. There is some preparation for later, and some placeholder, and we'll step through it line by line, but this is what the file should look like:

import { StyleSheet as StyleSheetJSS } from 'jss';

export interface Theme {}

export const ADD_SHEET = 'ADD_SHEET';

export type StyleSheet = StyleSheetJSS;

export type Sheet<K extends keyof any, StyleSheet> = { [P in K]: StyleSheet };

interface AddSheetAction {
  type: typeof ADD_SHEET;
  sheet: Sheet<string, StyleSheet>;
}

export type ActionTypes = AddSheetAction;

export interface State {
  sheets: Sheet<string, StyleSheet>;
  theme: Theme;
}

First, import the StyleSheet type from JSS and import it as StyleSheetJSS. Assign it to a StyleSheet type. We do this in case we have to extend the type later, and, more importantly, so we can import it from our types instead of the JSS node module.

The Theme interface is a placeholder. We'll get to themes in a later walkthrough, but for now, an empty object will let us get started.

Everything else is for our store. We have one action type, ADD_SHEET, and the action, AddSheetAction. They are exported as ActionTypes. Although not obvious here, ActionTypes is a union type, and as we add actions, we'll add them to the ActionTypes type.

The State interface is our store. Again, the theme type is just a placeholder for now.

Finally, add an index to the folder.

// file: /src/types/index.ts

export * from './types';

Configure the store

Our host application may need many stylesheets. No, that's wrong. A host application WILL need many stylesheets, and we don't want to duplicate them. Only one stylesheet for any given component should be in the markup. To manage the sheets and prevent duplicates, we'll use Redux.

Actions

At this point, we need one action, an action to add sheets to the store.

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

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

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

Do you notice how the types are imported explicitly from the index file? This is because all imports from our codebase should come from a single barrel. The index files that export from the files in a folder is the barrel, and while we could import from the files themselves, it's preferable to import from the barrel, which is also our public API. Our bundle will then have a single entry point, which is helpful for the requirements of certain host applications, some bundling schemes, and Angular's AOT compiler if you have to support that.

Reducer

The reducer updates the store and holds the initial state.

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

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

export const initialState: State = {
  sheets: {},
  theme: {}
};

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 };
    default:
      return state;
  }
}

Again, we import types from the barrel, then declare the initialState. State will hold many sheets and one theme. Sheets are the stylesheets we'll soon create, and they get dispatched to the store with a key, and the sheet. When JSS creates a stylesheet, we can provide it with some options, including a meta property which is a unique identifier string. That string is what we will use as our sheet key in the state sheets object. We can check for the existence of that key and reuse the sheet instead of creating a new one. Further, we will be able to know that if the sheet is in the store, it is in the markup, and we don't have to attach it again. So if we have ten buttons on our host page, only one button stylesheet will be in the page HEAD.

Initialize

Now we need a way to initialize the store and make it available to the theming engine.

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

import { createStore, Store } from 'redux';
import { reducer } from './reducer';

export const store: Store = createStore(reducer);

We pass the reducer to the store constructor. The reducer is imported from the reducer file because it is in the same folder as our working file. If we import from the barrel in the same folder as the file we're importing from, we'll have a circular dependency error in both Rollup and the optional Angular compiler when we create our NPM package.

Export

Export everything.

// file: /src/store/index.ts

export * from './store';
export * from './actions';
export * from './reducer';

Defaults

Next, we should set up some defaults. We'll create a file called defaulTheme, which gives us a default theme, which is an empty object for now (we'll construct the theme later), and our default JSS options. For the JSS options, the defaults from the node package are all we need, but if at some point you need more options, they can be done here.

// file: /src/styles/defaultTheme/defaultTheme.ts

import { JssOptions } from 'jss';
import preset from 'jss-preset-default';
import { initialState } from '../../store/reducer';
import { Theme } from '../../types/types';

const defaultJssPlugins = preset();

export const defaultJssOptions: JssOptions = {
  ...defaultJssPlugins
};

export const defaultTheme: Theme = initialState.theme;

export default defaultTheme;

Notice how the theme is derived from intialState. That's what makes it the default theme. In later exercises, we'll add the mechanism to construct a custom theme; however, the default, in many cases the default look of an application or the brand identity, is where design starts.

It's a good time to mention the export scheme. Our theming engine, everything in the styles folder, is a collection of functions, not all of which may be needed in a host application. Each of these functions is its own file, and each file has a default export.

Default exports enable path imports, and that enables tree-shaking. Our final bundle will include ES modules and CommonJS modules, plus accommodation for name imports and, optionally, a bundle for Angular Ivy, the AOT compiler used in newer versions of Angular.

The Angular part only matters if you have to support Ivy, but everything else, which we will examine when we add Rollup and configure bundling, is designed to support different bundling strategies and tree-shaking, as well as CJS require statements.

The first creator

The theming engine relies on two types of functions: creators and providers. Creators create a thing, and providers provide that thing to the host application. We'll see our first provider in the next exercise, but our first creator happens now.

This is a stylesheet creator. All it does is create a stylesheet. In the next exercise, we'll add a stylesheet provider which will take the created stylesheet, dispatch it to the store, and attach it to the HEAD of the host view. Before we can do that, however, we need a creator.

// file: /src/styles/createStylesheet/createStylesheet.ts

import jss, { Styles, StyleSheetFactoryOptions } from 'jss';
import { StyleSheet } from '../../types/index';
import { defaultJssOptions } from '../defaultTheme/index';

export default function createStylesheet(
  styles: Styles,
  options: StyleSheetFactoryOptions = {}
): StyleSheet {
  jss.setup(defaultJssOptions);
  return jss.createStyleSheet(styles, options);
}

The styles property is a JSS styles object or CSS written as JSON. The options property is JSS factory options for creating the stylesheet, which may include a meta property (the stylesheet identifier) and an index, which is the order of the stylesheets attached to the head. A sheet with an index of 1 will come before a sheet with an index of 3 in the host markup. The function then returns a JSS stylesheet object.

Now that we can create a stylesheet, it's a good place to end the coding of this exercise and write some tests. So far, all we've done is prepare. In the next part, we'll start using what we've created, but let's not get ahead of ourselves. We don't want to use anything untested.

Testing the store

Now that we've completed the code for this setup exercise, we'll write some tests beginning with the store.

Actions

Create a test file and add it to the store folder.

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

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

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 });
  });
});

Our setup includes an options object. We've discussed JSS factory options, but here we actually see it in use.

const options: StyleSheetFactoryOptions = {
  meta: 'Styles'
};

These options are passed to the stylesheet creator. We don't need any styles, just a stylesheet to test our action creator so our styles can be an empty object.

const stylesheet: StyleSheet = createStylesheet({}, options);

There is only one test because there is only one action creator, so we test that it returns a proper action. When we assign a value to the key constant, Typescript knows that it could be undefined or null, so we account for it conditionally.

const key = options.meta || '';

Then create the sheet, call the action creator, and expect it to be correct.

const sheet = { [key]: stylesheet };
const action = addSheet(sheet);
expect(action).toEqual({ type: ADD_SHEET, sheet });

Reducer

Create a reducer test file and add it to the store folder.

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

import { StyleSheet, StyleSheetFactoryOptions } from 'jss';
import { reducer, initialState } from './reducer';
import { ActionTypes, ADD_SHEET } 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);
  });
});

There is only one test because there is only one action, but the setup is the same, except we don't call the action creator.

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 };

Now we can call the reducer with the addSheet action and expect to see the sheet in the store.

const newState = reducer(initialState, action);
const sheetInStore = newState.sheets[key];
expect(sheetInStore).toEqual(stylesheet);

Store

The only thing the store file does is initialize the store, so we only need to check that it does what it claims.

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

import { store } from './store';

describe('Store', () => {
  it('should create', () => {
    expect(store).toBeTruthy();
  });
});

Testing the theming engine

So far, the theming engine only has two things in it, a defaultTheme and a stylesheet creator. Let's finish this step with two more test files.

defaultTheme

Add a default theme test to the defaultTheme folder.

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

import { defaultJssOptions, defaultTheme } from './defaultTheme';

describe('defaultTheme', () => {
  it('should return default JSS options', () => {
    expect(defaultJssOptions).toBeTruthy();
  });

  it('should return a default theme', () => {
    expect(defaultTheme).toBeTruthy();
  });
});

The default theme exports two constants. Our tests should check that they exist.

Stylesheet creator

Add a test for the stylesheet creator.

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

import createStylesheet from './createStylesheet';

const styles = {
  body: {
    margin: 'auto'
  }
};

const stylesheet = createStylesheet(styles);

const styleRule = stylesheet.getRule('body');

describe('createStylesheet', () => {
  it('should create', () => {
    expect(stylesheet).toBeTruthy();
  });

  it('should create valid CSS from JSS', () => {
    expect(styleRule.toString()).toMatch('margin: auto;');
  });
});

This test checks that we have a valid JSS stylesheet object as the return value. We don't have to test the JSS stylesheet object itself because it is tested by the tests in the JSS NPM package. Assuming we can trust them (if you don't, you can run them and check the coverage), it is enough for us to check that a rule we add is in the object.

The setup is creating a styles object, then creating a stylesheet, then selecting our rule from the created stylesheet. The getRule function is a method of the JSS stylesheet object.

const styles = {
  body: {
    margin: 'auto'
  }
};

const stylesheet = createStylesheet(styles);

const styleRule = stylesheet.getRule('body');

We then test that the stylesheet has been created.

it('should create', () => {
  expect(stylesheet).toBeTruthy();
});

Then we test that it is what we expect. Remember, we put JSS in, and we get CSS out, so our body rule should be CSS in the stylesheet object.

it('should create valid CSS from JSS', () => {
  expect(styleRule.toString()).toMatch('margin: auto;');
});

Create a public API

So far, as we've written our code, we've exported from the files and folders we've created. Let's create our public API by adding index files to containing folders and the root. This is already done for the store because there are no subfolders, but we haven't done so for the styles folder.

Add contents of the styles folder to the index.

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

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

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

Next, create a file called public-api.ts and add it to src.

// file: /src/public-api.ts

export * from './store/index';
export * from './styles/index';
export * from './types/index';

Lastly, an index that exports from public-api. The public-api file is for Angular. Angular default is an export from a public-api.ts file, but if you are not supporting Angular, there is no need for public-api.ts, and you can use whatever filename you like. Typically it would be index, which is what we add next.

// file: /src/index.ts

export * from './public-api';

Finish

At this point, we can run the tests. We'll use test:dev to see the coverage report.

npm run test:dev

If we did it right, our code is covered. You should see something like this in your terminal.

undefined

That's it! Confusing as it may be at this point, things will clear up once we start using what we've done and adding more functionality. That will happen next time, even if all we've done so far is setup. Hopefully, the tests give some idea of what we intend and where we're going. Next time, we'll start using what we've built by adding a provider, attaching the stylesheet to the host markup, and dispatching it to the store.

Continue with part 2