DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on • Edited on

Building a design system with dark mode using React, Typescript, scss, cva and Vite - Design Tokens

Introduction

This is part two of our series on building a complete design system from scratch. In the previous tutorial we bootstrapped the project. In this tutorial we will set up our design tokens for colors, fonts and spacing. Also, we will create atomic css classes for margins, paddings, backgrounds, etc. I would encourage you to play around with the deployed storybook. All the code for this series is available on GitHub.

Step One: Getting to know class-variance-authority

Our goal is to create a design system similar to chakra ui, that means our components will take in utility props -

<Box m="md" p="lg" color="white" bg="teal700">
  This is a Box component.
</Box>

<Button colorScheme="teal" variant="solid" size="xs" m="sm">
  Submit 
</Button>
Enter fullscreen mode Exit fullscreen mode

Now in our previous design system we used styled-system for these utility props m, p, color, bg and we used the variant function to create variants for the Button, Badge and Alert components. To get this similar DX we will use class-variance-authority, along with static css classes. For example -

import { cva, cx, VariantProps } from "class-variance-authority";

import "./flex.scss";

const flex = cva(["flex"], {
  variants: {
    direction: {
      row: "flex-row",
      "row-reverse": "flex-row-reverse",
      col: "flex-col",
      "col-reverse": "flex-col-reverse",
    },
  },
});

export type FlexProps = VariantProps<typeof flex>

export const Flex = React.forwardRef<HTMLDivElement, FlexProps>(
  (props, ref) => {
    const {
      direction,
      children,
      ...delegated
    } = props;

    return (
      <div ref={ref} className={flex({ direction })} {...delegated}>
        {children}
      </div>
    );
  }
);

<Flex direction="column-reverse"> 
   This is a Flex component.
</Flex>
Enter fullscreen mode Exit fullscreen mode

Let me break down the above code for you -

  1. We will first create our css classes, in a .scss / css file.
  2. Then we will create a cva function (flex) pass our main class and create variants.
  3. Then we create Typescript type for the props (FlexProps).
  4. We pass the cva function to the className prop by calling it and passing the necessary arguments from our component props, cva function will take care of assigning the necessary class names.
  5. We use our component and pass the necessary utility props.

Step Two: Creating design tokens

In our case design tokens are scss variables for colors, spacing, fonts, line-heights, etc. Create a new folder css inside the src folder. Inside the css folder create a new folder variables and a file main.scss

Inside the variables folder create a new file _spacings.scss and paste the following -

$spacing-xxs: 0.5rem;
$spacing-xs: 0.8rem;
$spacing-sm: 1rem;
$spacing-md: 1.25rem;
$spacing-lg: 1.5rem;
$spacing-xl: 2rem;
$spacing-xxl: 2.4rem;
$spacing-3xl: 3rem;
$spacing-4xl: 3.6rem;
Enter fullscreen mode Exit fullscreen mode

Similarly, create 2 new files _colors.scss & _fonts.scss, all the tokens for these are here. Take a note that we are using a map for our colors, you will get to know why in the future tutorials.

Step Three: Creating Spacing and Color classes

Let me re-iterate it again we want to accomplish the following -

<Box m="md" p="lg" color="white" bg="teal700">
  This is a Box component.
</Box>
Enter fullscreen mode Exit fullscreen mode

To get these utility props m, p, color, bg we have to use cva and create variants. To create these variants we need css classes. So in this section we are going to create atomic classes from our design tokens, it's like creating our own small tailwind.css. Because we are not using css in js we can't just pass in dynamic values, we need to create all the css classes in advance at build time.

Under css folder create a new file spacings.scss and paste -

@use "../css/variables/spacings";

@use "sass:meta";

@each $name, $value in meta.module-variables("spacings") {
  /* from $spacing-xs -> xs */
  $token: str-slice($name, 9);

  /* padding classes */
  .p-#{$token} {
    padding: $value;
  }

  .px-#{$token} {
    padding-left: $value;
    padding-right: $value;
  }

  .py-#{$token} {
    padding-top: $value;
    padding-bottom: $value;
  }

  .pt-#{$token} {
    padding-top: $value;
  }

  .pr-#{$token} {
    padding-right: $value;
  }

  .pb-#{$token} {
    padding-bottom: $value;
  }

  .pl-#{$token} {
    padding-left: $value;
  }

  /* margin classes */
  .m-#{$token} {
    margin: $value;
  }

  .mx-#{$token} {
    margin-left: $value;
    margin-right: $value;
  }

  .my-#{$token} {
    margin-top: $value;
    margin-bottom: $value;
  }

  .mt-#{$token} {
    margin-top: $value;
  }

  .mr-#{$token} {
    margin-right: $value;
  }

  .mb-#{$token} {
    margin-bottom: $value;
  }

  .ml-#{$token} {
    margin-left: $value;
  }

  .gap-#{$token} {
    gap: $value;
  }
}
Enter fullscreen mode Exit fullscreen mode

We are mapping over our spacing tokens and creating atomic classes like .pt-xs, .mb-lg, .gap-md.

Now under css folder create a new file colors.scss -

@use "./variables/colors" as *;

@each $color, $value in $colors-map {
  /* 
    generate color classes for each color
    .color-red500 { color: #E53E3E; }
  */
  .color-#{$color} {
    color: $value;
  }

  /* 
    generate bg color classes for each color
    .bg-red500 { background-color: #E53E3E; }
  */
  .bg-#{$color} {
    background-color: $value;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above snippet we have created atomic classes for our color tokens. Finally import these 2 files in the main.scss -

@use "./colors.scss";
@use "./spacings.scss"
Enter fullscreen mode Exit fullscreen mode

And import our main.scss file inside index.ts -

import "./css/main.scss";

export * from "./components/atoms";
Enter fullscreen mode Exit fullscreen mode

This step is important, it tells vite our bundler to transpile main.scss into style.css in the dist folder, also we have added some extra config in the vite.config.ts that will give us a main.css file inside dist/css folder.

Step Four: Creating cva functions

Now that we have created our atomic classes, let us now create our cva functions. Utility props like margins, paddings will be consumed by many components like Box, Badge, Button so we should create the cva functions at one place and use them wherever we need them. Now for padding we need px, py, pt, pr, pb, pl props, similar is the case for margin. Each utility prop can take several values, in case of spacing they can be - xxs, xs, sm, md, lg, xl, xxl, 3xl, 4xl, which correspond to our design tokens. For padding the cva function will look like -

const paddingVariants = cva([], {
  variants: {
    p: {
      xxs: 'p-xxs',
      ....
    },
    pt: {
      xxs: 'pt-xxs',
      ....
    }
    ...
  }
})
Enter fullscreen mode Exit fullscreen mode

Instead of manually writing all the spacing variants we will create a generic function. But first create a new folder cva-utils under src and create 3 files namely - index.ts, spacing.ts and colors.ts. Under spacing.ts paste -

import { cva, VariantProps } from "class-variance-authority";

const spacing = {
  xxs: "0.6rem",
  xs: "0.8rem",
  sm: "1rem",
  md: "1.2rem",
  lg: "1.5rem",
  xl: "2rem",
  xxl: "2.4rem",
  "3xl": "3rem",
  "4xl": "3.6rem",
};

type SpacingMap = Record<keyof typeof spacing, string>;

/**
 * generate cva variants for paddings, margins
 * @classPrefix property eg. p, pt, m, mt, gap, etc.
 * @returns a map of spacing variants
 * eg -{ xs: pt-xs } or { lg: gap-lg }
 */
function generateSpacingMap(classPrefix: string) {
  return Object.keys(spacing).reduce(
    (accumulator, token) => ({
      ...accumulator,
      [token]: `${classPrefix}-${token}`, // xs : p-xs
    }),
    {} as SpacingMap
  );
}

/**
 * Will contain all padding variants
 * eg: p: { xxs: p-xxs, xs: p-xs, ... } &
 * pt: { xxs: pt-xxs, xs: pt-xxs, ... }
 */
export const padding = cva([], {
  variants: {
    p: generateSpacingMap("p"),
    pt: generateSpacingMap("pt"),
    pr: generateSpacingMap("pr"),
    pb: generateSpacingMap("pb"),
    pl: generateSpacingMap("pl"),
  },
});

export type PaddingVariants = VariantProps<typeof padding>;

/**
 * Will contain all margin variants
 * eg: m: { xxs: m-xxs, xs: m-xs, ... }
 * mt: { xxs: mt-xxs, xs: mt-xxs, ... }
 */
export const margin = cva([], {
  variants: {
    m: generateSpacingMap("m"),
    mt: generateSpacingMap("mt"),
    mr: generateSpacingMap("mr"),
    mb: generateSpacingMap("mb"),
    ml: generateSpacingMap("ml"),
  },
});

export type MarginVariants = VariantProps<typeof margin>;

/**
 * Will contain all margin variants
 * eg: gap: { xxs: gap-xxs, xs: gap-xs, ... }
 */
export const flexGap = cva([], {
  variants: {
    gap: generateSpacingMap("gap"),
  },
});

export type FlexGapVariants = VariantProps<typeof flexGap>;

/**
 * Used for storybook controls returns -
 * options: ['xxs', 'xs', 'sm', ...]
 * labels: { xxs: xxs (0.6rem), xs: xs (0.8rem), ... }
 */
export function spacingControls() {
  const spacingOptions = Object.keys(spacing);
  const spacingLabels = Object.entries(spacing).reduce(
    (acc, [key, value]) => ({
      ...acc,
      [key]: `${key} (${value})`,
    }),
    {}
  );

  return { spacingOptions, spacingLabels };
}
Enter fullscreen mode Exit fullscreen mode

We are mapping over our spacing tokens and creating the necessary variants. We will follow a similar approach for the color atomic classes we map over our color-palette and create the necessary variants, check the code here. Finally, under cva-utils/index.ts paste the following -

export * from "./colors";
export * from "./spacings";
Enter fullscreen mode Exit fullscreen mode

And finally import the main.scss in the src/index.ts file -

import './scss/main.scss'

export * from './components/atoms'
Enter fullscreen mode Exit fullscreen mode

Now go ahead and build the project, by running yarn build from your terminal, check the generated css file, you can find it under dist/css/main.css.

Lastly, import the main scss files in .storybook/preview.tsx -

import "../src/scss/main.scss";
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this tutorial we created the required design tokens, we created atomic classes and the necessary cva variants for our design system library. All the code for this tutorial can be found here. In the next tutorial we will create our first components Box and Flex. Until next time PEACE.

Top comments (0)