Building A Component Library With React And Emotion

Building A Component Library With React And Emotion

According to Clearleft, a component library is:

“A collection of components, organised in a meaningful manner, and often (but not necessarily) providing some way to browse and preview those components and their associated assets.”

— “On Building Component Libraries,” Clearleft

We’ll learn how to build a component library by making one that comprises four components:

  1. Button
    A wrapper around the default HTML button
  2. Box
    A container (HTML div) with custom properties
  3. Columns
    A container whose children are spaced evenly across the x-axis
  4. Stack
    A container whose children are spaced evenly across the y-axis

These components could then be used in whatever application we are working on. We’ll build the component library using React and Emotion.

At the end of this piece, you should be able to create a component library that fits whatever use case you have in mind. This knowledge will come handy when you’re working with a team that needs to make use of reusable components.

First, let’s get started by establishing what the Emotion library is. The documentation explains:

“Emotion is a library designed for writing CSS styles with JavaScript. It provides powerful and predictable style composition in addition to a great developer experience with features such as source maps, labels, and testing utilities.”

— “Introduction,” Emotion Docs

In essence, Emotion is a CSS-in-JavaScript library, and an interesting thing about CSS-in-JavaScript libraries is that they enable you to collocate components with styles. Being able to tie them up together in a scope ensures that some component styles don’t interfere with others, which is crucial to our component library.

Emotion exposes two APIs for React:

  • @emotion/core
  • @emotion/styled

Before we dive into how these APIs work, note that they both support the styling of components with template strings and objects.

The core API is actually like the regular style property we currently use today when building apps with React, with the addition of vendor prefixing, nested selectors, media queries, and more.

Using the object approach with the core API would typically look like this:

import { jsx } from '@emotion/core'

let Box = props => {
  return (
    <div
      css={{
        backgroundColor: 'grey'
      }}
      {...props}
    />
  )
}

This is a rather contrived example that shows how we could style a Box component with Emotion. It’s like swapping out the style property for a css property, and then we’re good to go.

Now, let’s see how we could use the template string approach with the same core API:

import { jsx, css } from '@emotion/core'

let Box = props => {
  return (
    <div
      css={css`
        background-color: grey
      `}
      {...props}
    />
  )
}

All we did was wrap the template string with the css tag function, and Emotion handles the rest.

The styled API, which is built on the core API, takes a slightly different approach to styling components. This API is called with a particular HTML element or React component, and that element is called with an object or a template string that contains the styles for that element.

Let’s see how we could use the object approach with the styled API:

import styled from '@emotion/styled'

const Box = styled.div({
        backgroundColor: 'grey'
});

Here is one way to use the styled API, which is an alternative to using the core API. The rendered outputs are the same.

Now, let’s see how we could use the template string approach using the styled API:

import styled from '@emotion/styled'

const Box = styled.div`
        background-color: grey
`

This achieves the same thing as the object approach, only with a template string this time.

We could use either the core API or the styled API when building components or an application. I prefer the styled approach for a component library for a couple of reasons:

  • It achieves a lot with few keystrokes.
  • It takes in an as prop, which helps with dynamically changing the HTML element from the call site. Let’s say we default to a paragraph element, and we need a header element because of semantics; we can pass the header element as a value to the as property.

Getting Started

To get started, let’s clone the setup scripts on GitHub, which we can do on the command line:

git clone git@github.com:smashingmagazine/component-library.git

This command copies the code in that repository to the component-library’s folder. It contains the code required to set up a component library, which includes Rollup to help bundle our library.

We currently have a components folder with an index.js file, which does nothing. We’ll be creating new folders under the components folder for each component we build in our library. Each component’s folder will expose the following files:

  • Component.js
    This is the component we’re building.
  • index.js
    This exports the component from Component.js and makes referencing components from a different location easier.
  • Component.story.js
    This essentially renders our component in its multiple states using Storybook.

It also ships with a utils folder, which defines certain properties that would be used in our components. The folder contains several files:

  • helpers.js
    This contains helper functions that we are going to be using across our application.
  • units.js
    This defines spacing and font-size units, which we will use later.
  • theme.js
    This defines our component library’s palette, shadows, typography, and shape.

Let’s look at what we’ve defined in the units.js file:

export const spacing = {
  none: 0,
  xxsmall: '4px',
  xsmall: '8px',
  small: '12px',
  medium: '20px',
  gutter: '24px',
  large: '32px',
  xlarge: '48px',
  xxlarge: '96px',
};

export const fontSizes = {
  xsmall: '0.79rem',
  small: '0.889rem',
  medium: '1rem',
  large: '1.125rem',
  xlarge: '1.266rem',
  xxlarge: '1.424rem',
};

This defines the spacing and fontSizes rules. The spacing rule was inspired by the Braid design system, which is based on multiples of four. The fontSizes are derived from the major second (1.125) type scale, which is a good scale for product websites. If you’re curious to learn more about type scale, “Exploring Responsive Type Scales” explains the value of knowing the scales appropriate for different websites.

Next, let’s through the theme.js file!

import { spacing } from './units';

const white = '#fff';
const black = '#111';

const palette = {
  common: {
    black,
    white,
  },
  primary: {
    main: '#0070F3',
    light: '#146DD6',
    contrastText: white,
  },
  error: {
    main: '#A51C30',
    light: '#A7333F',
    contrastText: white,
  },
  grey: {
    100: '#EAEAEA',
    200: '#C9C5C5',
    300: '#888',
    400: '#666',
  },
};

const shadows = {
  0: 'none',
  1: '0px 5px 10px rgba(0, 0, 0, 0.12)',
  2: '0px 8px 30px rgba(0, 0, 0, 0.24)',
};

const typography = {
  fontFamily:
    "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif",
};

const shape = {
  borderRadius: spacing['xxsmall'],
};

export const theme = {
  palette,
  shadows,
  typography,
  shape,
};

In the theme file, we’ve defined our palette, which is essentially the colors we’re going to be using across all components in our library. We also have a shadows object, where we define our box-shadow values. There’s also the typography object, which currently just defines our fontFamily. Finally, shape is used for properties such as border-radius. This theme’s structure is inspired by Material-UI.

Next, our helpers.js file!

export const isObjectEmpty = (obj) => {
  return Object.keys(obj).length === 0;
};

Here, we only expose the isObjectEmpty function, which takes in an object and returns true if the object is empty. It returns false if it has any values. We’re going to make use of this function later.

Now that we’ve gone through all of the files in the utils folder, it’s about time to start building our components!

Buttons

Buttons are one of the most used components on the web. They’re used everywhere and can take different forms, shapes, sizes, and more.

Here are the buttons we’re going to build in Figma.

These subtle variations are going to be applied as properties to our button. We would like the buttons in our component library to accept properties such as variant, size, enableElevation (i.e. box-shadow), and color.

Starting with the button component, let’s create a Button folder, where we will define everything related to buttons, as discussed earlier.

Let's create our button component:

import styled from '@emotion/styled';
import isPropValid from '@emotion/is-prop-valid';

const StyledButton = () => {};

const IGNORED_PROPS = ['color'];

const buttonConfig = {
  shouldForwardProp: (prop) =>
    isPropValid(prop) && !IGNORED_PROPS.includes(prop),
};

export const Button = styled('button', buttonConfig)(StyledButton);

Here, we’ve started off by setting up our button component with a buttonConfig. The buttonConfig contains shouldForwardProp, which is used to control the properties that should be forwarded to the DOM, because properties such as color show up on the rendered element by default.

Next, let’s define our button sizes, which we’re going to use in the button component!

const buttonSizeProps = {
  small: {
    fontSize: fontSizes['xsmall'],
    padding: `${spacing['xsmall']} ${spacing['small']}`,
  },
  medium: {
    fontSize: fontSizes['small'],
    padding: `${spacing['small']} ${spacing['medium']}`,
  },
  large: {
    fontSize: fontSizes['medium'],
    padding: `${spacing['medium']} ${spacing['large']}`,
  },
};

buttonSizeProps is a map of our size values (small, medium, and large), and it returns fontSize and padding values based on the sizes. For a small button, we’d need a small font with small padding. The same goes for the medium and large sizes to scale them appropriately.

Next, let’s define a function that provides valid CSS properties based on the passed variant:

const getPropsByVariant = ({ variant, color, theme }) => {

  const colorInPalette = theme.palette[color];

  const variants = {
    outline: colorInPalette
      ? outlineVariantPropsByPalette
      : defaultOutlineVariantProps,
    solid: colorInPalette
      ? solidVariantPropsByPalette
      : defaultSolidVariantProps,
  };

  return variants[variant] || variants.solid;
};

Here, the getPropsByVariant function takes in variant, color, and theme properties and returns the properties of the specified variant; if no variant is specified, it defaults to solid. colorInPalette retrieves the palette assigned to the specified color if found, and undefined if not found in our theme object.

In each variant, we check whether a palette actually exists for the color specified; if we don’t, then we use colors from the common and grey objects of our theme, which we will apply in defaultOutlineVariantProps and defaultSolidVariantProps.

Next, let’s define our variant properties!

const defaultSolidVariantProps = {
  main: {
    border: `1px solid ${theme.palette.grey[100]}`,
    backgroundColor: theme.palette.grey[100],
    color: theme.palette.common.black,
  },
  hover: {
    border: `1px solid ${theme.palette.grey[200]}`,
    backgroundColor: theme.palette.grey[200],
  },
};

const defaultOutlineVariantProps = {
  main: {
    border: `1px solid ${theme.palette.common.black}`,
    backgroundColor: theme.palette.common.white,
    color: theme.palette.common.black,
  },
  hover: {
    border: `1px solid ${theme.palette.common.black}`,
    backgroundColor: theme.palette.common.white,
    color: theme.palette.common.black,
  },
};

const solidVariantPropsByPalette = colorInPalette && {
  main: {
    border: `1px solid ${colorInPalette.main}`,
    backgroundColor: colorInPalette.main,
    color: colorInPalette.contrastText,
  },
  hover: {
    border: `1px solid ${colorInPalette.light}`,
    backgroundColor: colorInPalette.light,
  },
};

const outlineVariantPropsByPalette = colorInPalette && {
  main: {
    border: `1px solid ${colorInPalette.main}`,
    backgroundColor: theme.palette.common.white,
    color: colorInPalette.main,
  },
  hover: {
    border: `1px solid ${colorInPalette.light}`,
    backgroundColor: theme.palette.common.white,
    color: colorInPalette.light,
  },
};

Here, we define the properties that are going to be applied to our button based on the selected variants. And, as discussed earlier, defaultSolidVariantProps and defaultOutlineVariantProps use colors from our common and grey objects as fallbacks for when the color specified isn’t in our palette or when no color is specified for what we put in place.

By the way, the solidVariantPropsByPalette and outlineVariantPropsByPalette objects use the color from our palette as specified by the button. They both have main and hover properties that differentiate the button’s default and hover styles, respectively.

The button design we’ve used accounts for two variants, which we can check out in our component library design.

Next, let’s create our StyledButton function, which combines all we’ve done so far.

const StyledButton = ({
  color,
  size,
  variant,
  enableElevation,
  disabled,
  theme,
}) => {
  if (isObjectEmpty(theme)) {
    theme = defaultTheme;
  }

  const fontSizeBySize = buttonSizeProps[size]?.fontSize;
  const paddingBySize = buttonSizeProps[size]?.padding;
  const propsByVariant = getPropsByVariant({ variant, theme, color });

  return {
    fontWeight: 500,
    cursor: 'pointer',
    opacity: disabled && 0.7,
    transition: 'all 0.3s linear',
    padding: buttonSizeProps.medium.padding,
    fontSize: buttonSizeProps.medium.fontSize,
    borderRadius: theme.shape.borderRadius,
    fontFamily: theme.typography.fontFamily,
    boxShadow: enableElevation && theme.shadows[1],
    ...(propsByVariant && propsByVariant.main),
    ...(paddingBySize && { padding: paddingBySize }),
    ...(fontSizeBySize && { fontSize: fontSizeBySize }),
    '&:hover': !disabled && {
      boxShadow: enableElevation && theme.shadows[2],
      ...(propsByVariant && propsByVariant.hover),
    },
  };
};

In the StyledButton function, we’re assigning defaultTheme to the theme if the theme object is empty which makes it optional for the consumers of our library to use Emotion’s ThemeProvider in order to make use of the library. We assigned fontSize and padding based on the buttonSizeProps object. We defined several default button properties, such as fontWeight and cursor, which aren’t tied to any property, and we also derived color, backgroundColor, and border values based on the result of propsByVariant.

Now that we’ve created our Button component, let’s see how we can use it:

<Button
    variant="solid"
    color="primary"
    size="small"
    enableElevation
    disabled
>
    Small Outline Elevated Button
</Button>

We can check what that looks like on CodeSandbox:

That’s how to use the Button component. We define the following properties:

  • We define a variant with a solid value. We could have specified outline instead. If the variant prop isn’t provided, we would also default to solid.
  • We define color, with a value of primary. We also support error as a color value or a color from a theme object. If the color property isn’t specified, we would fall back to our default color state.
  • We define size, with a value of small. It could be medium (the default) or large.
  • We define EnableElevation because we want some box-shadow on our button. We could have chosen not to use it.
  • Finally, we define disabled because we want our button to be disabled. The additional thing we do to a disabled button is reduce its opacity.

The button doesn’t need to take any property. It defaults to a solid medium-sized button.

Box Component

A box component is a container that can hold any component or HTML element. It accepts but is not limited to properties such as padding, margin, display, and width. It can also be used as a base component for some of the other components we’ll get into later.

Here’s what it looks like on Figma:

Before diving into the code, let’s not forget to create a new folder for this component.

Now, let’s create our Box component:


import styled from '@emotion/styled';
import isPropValid from '@emotion/is-prop-valid';
import { spacing, theme as defaultTheme } from '../../utils';

const StyledBox = ({
  paddingX,
  paddingY,
  marginX,
  marginY,
  width,
  display,
  theme,
  ...props
}) => {

  if (isObjectEmpty(theme)) {
    theme = defaultTheme;
  }

  const padding = spacing[props.padding];
  let paddingTop = spacing[props.paddingTop];
  let paddingRight = spacing[props.paddingRight];
  let paddingBottom = spacing[props.paddingBottom];
  let paddingLeft = spacing[props.paddingLeft];
  if (paddingX) {
    paddingLeft = spacing[paddingX];
    paddingRight = spacing[paddingX];
  }
  if (paddingY) {
    paddingTop = spacing[paddingY];
    paddingBottom = spacing[paddingY];
  }
  let margin = spacing[props.margin];
  let marginTop = spacing[props.marginTop];
  let marginRight = spacing[props.marginRight];
  let marginBottom = spacing[props.marginBottom];
  let marginLeft = spacing[props.marginLeft];
  if (marginX) {
    marginLeft = spacing[marginX];
    marginRight = spacing[marginX];
  }
  if (marginY) {
    marginTop = spacing[marginY];
    marginBottom = spacing[marginY];
  }
  return {
    padding,
    paddingTop,
    paddingRight,
    paddingBottom,
    paddingLeft,
    margin,
    marginTop,
    marginRight,
    marginBottom,
    marginLeft,
    width,
    display,
    fontFamily: theme.typography.fontFamily,
  };
};

const IGNORED_PROPS = ['display', 'width'];

const boxConfig = {
  shouldForwardProp: (prop) =>
    isPropValid(prop) && !IGNORED_PROPS.includes(prop),
};

export const Box = styled('div', boxConfig)(StyledBox);

The spacing rule we defined earlier is being applied to both padding and margin, as we can see in the Box component. We receive contextual values for padding and margin, and we look up their actual values from the spacing object.

We accept paddingX and paddingY props to update padding across the horizontal and vertical axis, respectively. We do the same for marginX and marginY as well.

Also, we don’t want the display and width props to get forwarded to the DOM because we only need them in CSS. So, we add them to our list of props to ignore, and pass that on to our config.

Here’s how we could use the Box component:

<Box
  padding="small"
  paddingTop="medium"
  paddingBottom="medium"
>
  Simple Box Component
</Box>

We can see what this looks like on CodeSandbox.

In this Box component, we’ve assigned small as a value to our padding property, and medium to the paddingTop and paddingBottom properties. When rendered, the Box component will have its padding-left and padding-right properties set to 12px each, and its padding-top and padding-bottom properties set to 20px. We could have replaced paddingTop and paddingBottom with paddingY and gotten the same result.

Columns Component

The Columns component is a variation of our Box component, with a display type of flex and with children spaced evenly across the x-axis.

Here is a representation of the Columns component in Figma:

Let’s build our Columns component!

import React from 'react';
import { Box } from '../Box';

export const Columns = ({ children, space, ...props }) => {
  return (
    <Box display="flex" {...props}>
      {React.Children.map(children, (child, index) => {
        if (child.type !== Box) {
          console.warn(
            'Each child in a Columns component should be a Box component'
          );
        }

        if (index > 0) {
          return React.cloneElement(child, {
            marginLeft: space,
            width: '100%',
          });
        }

        return React.cloneElement(child, { width: '100%' });
      })}
    </Box>
  );
};

We’re using React.Children to map over the Columns component’s children. And we’re adding marginLeft and width properties to each of the children, except the first child, which doesn’t need a marginLeft property because it’s the leftmost child in the column. We expect each child to be a Box element to ensure that the necessary styles are applied to it.

Here’s how we could use the Columns component:

<Columns space="small">
  <Box> Item 1</Box>
  <Box> Item 2</Box>
  <Box> Item 3</Box>
</Columns>

We can see what that looks like on CodeSandbox.

The Columns children here are spaced evenly across the x-axis by 12 pixels because that’s what the value of small resolves to, as we’ve defined earlier. Because the Columns component is literally a Box component, it can take in other Box component properties, and we can customize it as much as we want.

Stack Component

This is also a variation of our Box component that takes the full width of the parent element and whose children are spaced evenly across the y-axis.

Here is a representation of the Stack component in Figma:

Let’s build our Stack component:

import React from 'react';
import { Box } from '../Box';
import { Columns } from '../Columns';

const StackChildrenTypes = [Box, Columns];
const UnsupportedChildTypeWarning =
  'Each child in a Stack component should be one of the types: Box, Columns';

export const Stack = ({ children, space, ...props }) => {
  return (
    <Box {...props}>
      {React.Children.map(children, (child, index) => {
        if (!StackChildrenTypes.includes(child.type)) {
          console.warn(UnsupportedChildTypeWarning);
        }

        if (index > 0) {
          return React.cloneElement(child, { marginTop: space });
        }

        return child;
      })}
    </Box>
  );
};

Here, we map over each child with React.Children and apply a paddingTop property to it with the value of the space argument. As for the first child, we need it to take its original position, so we skip adding a marginTop property to it. We also accept each child to be a Box so that we can apply the necessary properties to it.

Here’s how we could use the Stack component:

<Stack space="small">
  <Box marginTop="medium"> Item 1</Box>
  <Box> Item 2</Box>
  <Box> Item 3</Box>
</Stack>

We can see what that looks like on CodeSandbox.

Here, the Box elements are spaced evenly with the small unit, and the first Box takes a separate marginTop property. This shows that you can customize components however you wish.

Conclusion

We’ve gone through the basics of using Emotion to create components in React using the APIs that it provides. This is just one of many ways to go about building a component library. There are some nuances to building it for a brand because you might not have to take theming and some other things into consideration. But if you plan to release the library to the public one day, then you’ll have to deal with requests for those missing pieces, so consider that possibility and make the library a little flexible ahead of time.

If you have any questions, feel free to drop them as comments.

The repository for this article is on GitHub, and the button designs we’ve used are on Figma.

References

  • “On Building Component Libraries”, Mark Perkins, Clearleft
  • “Exploring Responsive Type Scales”, Joseph Mueller
  • “Design Systems With React and Storybook”, Emma Bostian, Frontend Masters
  • Emotion official documentation