loading...

Making your React component library meaningful in 2021

lexswed profile image Lex Swed ・6 min read

In the last article we managed to setup our project:

  • use dokz as a documentation engine
  • add stitches as a class names generator and manager of class names on components

Now, we're going to use:

  • typescript to leverage type-safe tokens and props for our component library
  • @react-aria to make our components accessible

TypeScript

I'm not going to talk about the benefits of using TypeScript in this article, but I would say that [unfortunately], when your library is super awesome already, this is the only way forward to make it even more enjoyable. And we know our library is going to be the best, so we can start with TypeScript straight away:

yarn add --dev typescript @types/react

And create a tsconfig.json (most of the stuff is added by next, the base config was copied from here)

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "types": ["react"],
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "noEmit": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "include": ["next-env.d.ts", "lib", "pages"],
  "exclude": ["node_modules"]
}

Now we rename our lib/*.js files to .ts(x) and we're done with the migration. We can now check that we get autocomplete suggesting possible values that we defined in our stitches.config.ts:

Autocomplete suggests values for Box component

Accessibility

Building accessible application is as important as having an elevator in the 9 stories building. You can skip building it, but you will hate yourself, people will hate you, and adding it to the existing building is... Well, at the very least is just expensive.

If you want to get familiar with the topic and don't really like to read specifications, I encourage you to read Accessible to all on web.dev.

But if you were ever wondering "Why do we need to do it ourselves? Why it's not built into the platform, if the standards are defined?", well, let's discuss it in the comments, I think we're just not there yet. I hope we will get some new API similar to how Date gets improved or how Intl gets new features.

Before the future comes, we can use a "library of React Hooks that provides accessible UI primitives for your design system" from react-aria. This is the simplest way you can take to make your components accessible while keeping your business satisfied with the speed of delivery.

Let's start from the Button component. First, we add simple lib/Button.tsx:

import React from 'react';

import { styled } from '../stitches.config';

const ButtonRoot = styled('button', {
  py: '$2',
  px: '$3',
  bc: '$blue500',
  color: 'white',
  fontSize: '14px',
  br: '$sm',
});

const Button: React.FC = ({ children }) => {
  return <ButtonRoot>{children}</ButtonRoot>;
};

export default Button;

One of the downsides of CSS-in-JS solutions is that you have to come up with even more variable names, like ugly ButtonRoot

Let's now create a playground for our Button to see it in action. Create pages/components/Button.mdx and add simple playground code:

---
name: Button
---

import { Playground } from 'dokz';
import Box from '../../lib/Box';
import Button from '../../lib/Button';

# Button

<Playground>
  <Box css={{ p: '$8' }}>
    <Button>Hello</Button>
  </Box>
</Playground>

Box is just for offset for now

So here's what we have:

Button initial doc page

Now let's add our first react-aria package:

yarn add @react-aria/button

And use it in our lib/Button.tsx:

import React, { useRef } from 'react';
import { useButton } from '@react-aria/button';

import { styled } from '../stitches.config';

const ButtonRoot = styled('button', {
  py: '$2',
  px: '$3',
  bc: '$blue600',
  color: 'white',
  fontSize: '14px',
  br: '$sm',
});

const Button: React.FC = (props) => {
  const ref = useRef<HTMLButtonElement>(null);
  const { buttonProps } = useButton(props, ref);
  const { children } = props;

  return (
    <ButtonRoot {...buttonProps} ref={ref}>
      {children}
    </ButtonRoot>
  );
};

export default Button;

Here, I'm just following the official instructions and I always encourage people to go to the docs directly and copy code from there than from the article. Just remember that code in the Internet isn't 100% valid unless taken from the official docs (then it's a least 90% valid)

Okay, this looks simple. What we've achieved? Actually, a lot. I'm pretty sure it's hard to buy the benefits when you don't know the context. So, if you're interested why you need all this code, why we need to handle "press management" on the button, I would suggest to read more in-depth articles by the author of the react-aria: Building a Button.

Now, let's try this in the playground:

<Button onPress={() => alert('Wow')}>Make Wow</Button>

Now let's make sense out of our CSS-in-JS solution and create few button variants. I will use Tailwind CSS as a reference:

const ButtonRoot = styled('button', {
  py: '$2',
  px: '$3',
  color: 'white',
  fontSize: '14px',
  fontWeight: 'bold',
  transition: '0.2s ease-in-out',

  variants: {
    variant: {
      default: {
        'bc': '$blue500',
        'color': 'white',
        'br': '$md',
        '&:hover': {
          bc: '$blue700',
        },
      },
      pill: {
        'bc': '$blue500',
        'color': 'white',
        'br': '$pill',
        '&:hover': {
          bc: '$blue700',
        },
      },
      outline: {
        'bc': 'transparent',
        'color': '$blue500',
        'border': '1px solid $blue500',
        'br': '$md',
        '&:hover': {
          bc: '$blue700',
          borderColor: 'transparent',
          color: 'white',
        },
      },
    },
  },
});

This will create a mapping between prop variant and set of class names to be assigned to the button component. You may notice that some styles are repeated between variants. This is where I would strongly suggest to block any thoughts about extracting common styles into separate variables to make code DRY. Allow variants to be isolated unless you feel the need to extract something.

Now, when we have our variants defined, how do we use in our Button component? Well, with some tricks:

const ButtonRoot = styled('button', {
  /* common styles */

  variants: {
    variant: {
      default: { /* styles */ },
      pill: { /* styles */ },
      outline: { /* styles */ },
    },
  },
});

type Props = React.ComponentProps<typeof ButtonRoot>;

const Button: React.FC<Props> = ({ as, variant = 'default', ...props }) => {
  const ref = useRef<HTMLButtonElement>(null);
  const { buttonProps } = useButton(props as any, ref);

  return (
    <ButtonRoot {...buttonProps} variant={variant} as={as} ref={ref}>
      {props.children}
    </ButtonRoot>
  );
};

First, we infer types generated by stitches:

type Props = React.ComponentProps<typeof ButtonRoot>;

This allows us to get access to defined variant prop and as prop that stitches provides to overwrite the HTML Element to render (BTW, you may argue whether you want this prop to be available for Button or it's better to create a new component to handle specific case, for example for <a> HTML element that looks like a button).

Second, we use this type for our Button, for the consumers of this component to see what props are available, specifically what variant one can apply:

const Button: React.FC<Props> = ({ as, variant = 'default', ...props }) => {

We also extract props that aren't default to <button> element, just to make things clear. variant prop gets default variant (you can use Button.defaultProps also for that).

Then, we shamelessly use any:

const { buttonProps } = useButton(props as any, ref);

It's not the first and not the last time we have to use it. But when you deal with types that aren't first-class citizens in the language, chances are that even describing same thing can be done in different way. In this case, onFocus prop expected by useButton doesn't match with onFocus prop that stitches has in its type definitions for button. But since we know it's <button> and we expect people to pass only button props – we can allow ourselves to use any this time.

Let's see these variants in pages/components/Button.mdx:

---
name: Button
---

import { Playground } from 'dokz';
import { Box, Button } from '../../build';

# Button

<Playground>
  <Box css={{ p: '$8', display: 'flex', gap: '$3' }}>
    <Button onPress={() => alert('Wow')}>Make Wow</Button>
    <Button variant="pill" onPress={() => alert('Wow')}>
      Make Wow
    </Button>
    <Button variant="outline" onPress={() => alert('Wow')}>
      Make Wow
    </Button>
  </Box>
</Playground>

Saving, waiting a moment and...

Button variants

Here we go!
If you want to test props autocomplete (unfortunately mdx is not supported yet), try to write simple component even inside lib/Button.tsx that uses this Button component. You'll see inferred possible variants you can pass to the component:

Button autocomplete shows variant prop options

So now we used some benefits of stitches and react-aria packages. I encourage you to check out more react-aria packages and see what else you can do with stitches, for example, how you can easily change the layouts based on the window size using responsive styles.

Next, we are going to build and deploy the docs and the library, so that our foundation for component library is complete and we can start building more components.

Posted on by:

lexswed profile

Lex Swed

@lexswed

Software Engineer in love with UI Engineering

Discussion

markdown guide