Design System

Let’s Create a Cross-Platform Design System! (part 1)

“A Design System is the single source of truth which groups all the elements that will allow the teams to design, realize, and develop a product.” Audrey Hacq

This is the first blog post in a multi-part series. Stay tuned!

When we talk about design systems, we usually think about design and user experience; however, the implementation details for a design system are traditionally a technically-intensive affair too.

With the growth of a company comes the growth of the company’s brand. This, in turn, creates the need to create a single source of truth for different visual components. Without consistent design guidelines, each customer-facing experience will look different to the point of discombobulation, which can become a particularly notorious issue as companies grow to support multiple platforms simultaneously.

Thanks to technical advancements on web and mobile, we can reuse components across different platforms. more specifically using both react and react-native, two mature and stable technologies that have been used in production by companies big and small for years. Due to the amazing work done on the react-native-web project, we can render most of the react-native components on a web-based platform today. This allows us to share the same visual components between web and mobile, drastically reducing the amount of effort required to support both platforms and keep design language homogenous between the two. While this kind of approach might sound unorthodox, or even heresy, in nature, multiple well-renowned large organizations have adopted this approach with great success, including Twitter, Expo, Major League Soccer, Flipkart, Uber, The Times, and DataCamp.

In this guide, we’re going to explore a recipe for creating a cross-platform design system using react, react-native, react-native-web, and Storybook to visualize the design system altogether using a monorepo. At the end of this guide, you will be able to create cross-platform components with a unified design language using the following set of tools:

  • 🚀 Yarn Workspaces
  • 📘 TypeScript
  • ⚛️ React-Native App (Expo based)
  • ⚛️ ️️React Web App (Create-React-App based)
  • 📕 Storybook 6

🚀 Setting up the monorepo

Before we get started on the more interesting parts of this project, we need to set up our monorepo. To do this, we’re going to need to have yarn installed because we’re going to use yarn workspaces. If you don’t have yarn installed, please follow the steps listed here.

First, we need to create the folder for our project along with a package.jsonfile. Let’s do this now:

$ mkdir xproduct # create the folder
$ cd xproduct # cd into the folder
$ yarn init # create the package.json

We also need to modify our package.json file and add a workspace array to support yarn workspaces. The workspaces array contains the folders or folder patterns that will be treated as part of the overall application, also, the private field is a requirement to use workspaces:

{
  # ...
  "private": true,
  "workspaces": [
    "apps/*",
    "libraries/*"
  ]
}

This allows us to break the application down into a bunch of smaller reusable components.

Next, let’s create two folders to reflect our monorepo structure: apps and libraries. Libraries will contain our reusable components, and apps will contain our actual applications that use said components as dependencies. Like before, we can quickly create these folders using the terminal:

$ mkdir apps
$ mkdir libraries

Alright, now that we have our initial setup in place, we can proceed to create the two main applications that will be part of this example: a create-react-app web application and an expo mobile application.

Let’s start with the React application by scaffolding it out withcreate-react-app:

$ cd apps # go to the apps folder
$ npx create-react-app site --template typescript # initialize
$ cd ./site && rm -rf ./.git && cd .. # clean up git folder and return

Since create-react-app web applications start with a git repo by default, we're removing the .git folder. We want the principal git repository to be the root folder of our monorepo, not a repo per individual application.

Now, let’s create our react-native expo application: (Inside of apps/ )

$ npx expo-cli init # Let's initialize the app with expo-cli
# Under managed workflow, select blank (Typescript)
# Add mobile as the app name and continue
$ cd ./mobile && rm -rf ./.git && cd ../../ # Same as before, remove the git folder and return

Alright! We’re really rolling now. Next, you’ll need to do a little bit of configuration to make the two projects compatible with one another so that they can share components:

  1. Set up both applications to use the same Typescript version (Optional but highly desirable) by modifying and pinning the devDependency versions in their package.json files
  2. Set up both applications to use the same version of react by modifying and pinning the dependency versions in their package.json files. In this case, let’s use the lowest version between the two of them (In this scenario, provided by expo ), this is important, and failing to do so will cause you to end up with a runtime error.
  3. Set up the names of both applications, prefixed by the root workspace string (naming convention is optional but desirable) "name": "@xproduct/mobile". The root workspace string in this example is xproduct. You should also set each version to 0.1.0 and each private field to true to prevent accidental publication to npm.
  4. Remove the yarn.lock from the site folder.

⚛️ Setting up our Expo app to work correctly with Yarn Workspaces

To get the Expo application to work correctly with yarn workspaces, we need to add expo-yarn-workspaces as a devDependency to our mobile app:

Next up, we’re going to need to tweak the package.json file for the mobile app again, this time we’re going to follow these steps:

  1. Modify the main field to point to __generated__/AppEntry.js .
  2. Add the following postinstall script: “postinstall”: “expo-yarn-workspaces postinstall” .
  3. Create a metro.config.js file with the following contents:
const { createMetroConfiguration } = require("expo-yarn-workspaces");
module.exports = createMetroConfiguration(__dirname);

With all of that finished, we should be ready to start setting up our design system!

📕 Setting up our Design System library with Storybook 6 and Typescript

Finally, the time has come to talk about the good stuff! To start with, we’re going to create the ui folder inside of our libraries folder:

$ mkdir libraries/ui && cd libraries/ui

The ui folder will contain our shared react-components and will help to create a solid foundation to build our design system. Let’s go ahead and generate our package.json for the ui folder:

$ yarn init
# Name it @xproduct/ui
# Set the entrypoint (main) to dist/index.js
# Set the version to 0.1.0
# Make it private: true

We also need to set up the typescript typings so that our two applications know where to find them when they install the ui package. To do this, edit the package.json file and add a typings field with the value of dist/index.d.ts. This typings file will be generated when we compile the ui package, allowing our applications to consume the type definitions and use them during their own compile steps.

To automate some of the more tedious tasks, let’s create a few scripts entries under package.json:

{
  # ...
  "scripts": {
    "dev": "tsc -p tsconfig.build.json --watch",
    "dev:docs": "start-storybook -p 4000",
    "clean": "rm -rf ./dist && rm -rf ./build",
    "build": "tsc -p tsconfig.build.json && build-storybook -c .storybook -o build"
  }
}

Now that we’ve taken care of much of the project boilerplate, it’s time to add the dependencies for our ui package:

# Inside of the ui folder, otherwise you'll need to prefix it by yarn workspace @xproduct/ui add ...
$ yarn add react-native-web@~0.11.7
$ yarn add --peer react@~16.11.0\
react-dom@~16.11.0\
react-native@~0.62.2
$ yarn add --dev @storybook/react\
react@~16.11.0\
react-dom@~16.11.0\
react-native@~0.62.2\
@types/react-native@~0.62.2\
babel-loader\
babel-plugin-react-native-web@~0.11.7\
typescript@~3.9.5

Let’s pause to explain the dependencies listed above a little bit:

  • The typescript, react, react-dom, react-native-web, and react-native versions should be the exact same across all modules in the monorepo. This is why we are installing a specific version for these dependencies
  • We’re adding @storybook/react (latest) version as a development dependency since we don't want to include Storybook as a part of our bundled component library.
  • We’re adding the samereact-native-web version we're using on the apps as a direct dependency, so components behave uniformly.
  • The babel-loader and babel-plugin-react-native-web dependencies allow the react-native-web components to be transformed into their proper mobile or web versions.

Let’s set up the Typescript configuration files

We are going to need multiple typescript config files. First, let’s create a tsconfig.base.json file with the following contents:

{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "jsx": "react",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "ES5"
  }
}

Now, let’s extend it with two versions, one tsconfig.json for Storybook and one tsconfig.build.json for typescript watch mode and to generate the final output.

tsconfig.json

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true,
    "baseUrl": "src",
    "paths": {
      "@xproduct/ui": ["index.ts"]
    }
  },
  "include": ["src", "docs"]
}

Since we’re using this configuration for Storybook, we’re going to point our library to the index file under source; we’re also leaving the module specification as ESNext since Webpack will bundle it and we’re setting up the “noEmit” flag to true so that the Storybook process doesn't generate any output files.

tsconfig.build.json

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "declaration": true,
    "module": "CommonJS",
    "outDir": "dist"
  },
  "include": ["src"]
}

This file will be responsible for generating the actual build output. We generate it under dist, using the CommonJS module specification, and include the typings as part of the output.

Next, we need to create a babel configuration file that will be automatically picked up by Storybook. This file will register a plugin that will convert the react-native components to their proper versions (either web or native).

babel.config.js

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ["module:metro-react-native-babel-preset"],
    plugins: ["react-native-web"],
  };
};

Let’s continue with a couple more configuration files. We’re almost there!

First, create a .storybook folder and, inside of it, an empty main.js file:


$ mkdir .storybook # Create storybook folder
$ touch .storybook/main.js # Create main entry point for storybook

Populate .storybook/main.js file with the following content:

module.exports = {
  stories: ["../docs/**/*.stories.tsx"],
};

Alright, now let’s create a webpack.config.js that will contain some configuration pointing react-native components to their proper react-native-web versions.

const path = require("path");
module.exports = {
  resolve: {
    alias: {
      "react-native": "react-native-web",
      "@xproduct/ui": path.resolve(__dirname, "../src/index.ts"),
    },
  },
};

Alright alright alright! We should be on track to create our stories! 🎉

Let’s create both our docs and src folders to start working on our first components, as well as index.ts under src to serve as an entry point for exporting our components, helpers, and other tools:

$ mkdir docs # Create folder for storybook stories
$ mkdir src # Create folder for component library
$ touch src/index.ts # Create entry point for component library

Finally, let’s create our first component! Create a folder under src named components and inside of it create a file named XPButton.tsx:

$ mkdir src/components # Create components folder
$ touch src/components/XPButton.tsx # Create our first component file: XPButton

Next, let’s edit XPButton.tsx and bring it all together:

import React, { FC } from "react";
import {
  StyleSheet,
  TouchableOpacity,
  Text,
  TouchableOpacityProps,
} from "react-native";

const styles = StyleSheet.create({
  button: {
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 5,
    height: 52,
    padding: 16,
    backgroundColor: "#3300aa",
  },
  text: {
    color: "#fff",
  },
});

export type XPButtonProps = TouchableOpacityProps & {
  title: string;
};

export const XPButton: FC<XPButtonProps> = ({ title, ...props }) => {
  return (
    <TouchableOpacity style={styles.button} {...props}>
      <Text style={styles.text}>{title}</Text>
    </TouchableOpacity>
  );
};

This component will be a simple button.

Next, we need to export it in the root index.ts file:

export * from "./components/XPButton";

Now, under docs, let’s create a XPButton.stories.tsx storybook file:

import React from "react";
import { XPButton } from "@xproduct/ui";

export default {
  title: "XPButton",
  component: XPButton,
};

const runAlert = () => {
  alert("Hello!");
};

export const Regular = () => <XPButton title="Welcome" onPress={runAlert} />;

Before we do anything else, due to a little versioning glitch related to Storybook 6, we need to patch our monorepo’s root package.json file (NOT the ui module’s package.json file) and add the following fields to force the correct versions to be used everywhere:

{
  ...
  "resolutions": {
    "react": "~16.11.0",
    "react-dom": "~16.11.0"
  }
}

Now let’s run $ yarn so we can get our packages updated.

Great! Finally, in two separate console windows, in the root of the monorepo run:

$ yarn workspace @xproduct/ui run dev # Run the typescript compiler
$ yarn workspace @xproduct/ui run dev:docs # Run the storybook

Alternatively, you can install the concurrently dependency and merge both commands into one IE:

{
  // xproduct/ui package.json
  "scripts": {
    // ...
    "dev:all": "concurrently \"yarn dev\" \"yarn dev:docs\""
  }
}

And run it with:

$ yarn workspace @xproduct/ui run dev:all

You should see the following result on the web browser:

StoryBook

That’s great!

Now, you can go ahead and use the new XPButton component in the two applications by importing them:

import { XPButton } from '@xproduct/ui';
// Use it here

It should look like this:

React

Same for the mobile app:

App

This is great! We already achieved Cross-Platform (Android/iOS/Web) UI code sharing with react-native-web and set up an excellent visualization/edition system with Storybook 6.

🎨 Next steps on creating a Design System

A design system should contain a color palette, typographies, and more complex components, all of those are already added on the template repository.

I recommend the pigment library because It’s beneficial to generate color palettes, schemes and combinations programmatically, it features some other goodies like contrast generation and a Flat, Hand-Picked Color Palette, features very helpful for design-systems.

In the next part of this series, we will set up a proper design system. Stay tuned!

You can check out the whole template setup here

Have questions about our content?

Join our developer community and ask a BoltSource Engineer today