The Ultimate Guide to building a UI Library
Tags: webdev, react, ui, tutorial
Published: 2023-08-08 22:48:08

## Introduction
We are always looking to create amazing experiences for our users. In this tutorial, you will learn how to create a fully featured accessible UI library.

Why build a custom UI library over pre-existing ones? Although libraries like Material UI come with a host of built-in components and styles, they may not always cater to your application’s specific needs. By building your own UI library, you gain the autonomy to create elements tailored to your application, enhancing the overall user experience.

## The tools
Before we dive into this guide, let’s take a moment to explore the tools:

- Radix: Radix is a low-level UI library that offers full control over styling and functionality. It provides a system of primitives to develop complex accessible UI components, making it an ideal choice for building a custom UI library.

- Storybook: Storybook allows you to build your components separate from the rest of your application. This makes building UI component libraries way easier. You can also connect it to Chromatic to test the UI of your components.

- Next.js: Next is a react meta framework. Its features such as server-side rendering make it great for building modern React applications.

- Tailwind: Tailwind is an amazing utility-first CSS framework that lets you write styles in your HTML. It can take some time to get used to but once you do it makes styling 10x faster.

- Class Variance Authority: CVA is a zero runtime CSS in JS library that makes it easy to create variants for components with Tailwind.

Together, Radix, Storybook, Tailwind, CVA, and Next.js provide the tools and flexibility you need to craft your custom UI library. Ready to get started? Let’s dive in.

## Components
First, we should decide what components we want to build. This will depend on your application’s needs, but for this tutorial, we will be building the following components:

- Button
- Modal/Dialog
- Tooltip
- Dropdown Input
## Getting Started
Let’s start by creating a new Next.js application by running the following in your terminal:
```bash
npx create-next-app@latest
```
And use the following options:
```bash
✔ What is your project named? - my-app
✔ Would you like to use TypeScript? - Yes
✔ Would you like to use ESLint? - Yes
✔ Would you like to use Tailwind CSS? - Yes
✔ Would you like to use `src/` directory? - Yes
✔ Would you like to use App Router? (recommended) - Yes
✔ Would you like to customize the default import alias? - No
```
Now we need to install Storybook. Run this in your Next.js project to install Storybook:
```bash
npx storybook@latest init
```
After the command finishes installing dependencies it will open a new window in your browser with Storybook:

![Storybook Window](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1pi23ht2xs4pmvco6s8c.png)

In the left-hand bar, you can see all of our storybook components. If you click on them you can see an interactive preview of each component. Components can also have variants and controls. Controls allow you to see what the component will look like under different circumstances inside of Storybook.


![Storybook Sidebar](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5pv5w81fu1gtgp2lf2l5.png)

## Our first component
Let’s start by removing the default components that storybook gives us.

Delete the src/stories/ directory that Storybook created inside of our Next.js project
Create a new directory at src/components
Now that we have removed the default components let’s create our first component Button to do this first create a new directory src/components/Button inside of this directory create two files Button.tsx and Button.stories.tsx Our Button.tsx file is our actual component, and the Button.stories.tsx file is a configuration file for storybook that we will talk more about in a minute.

Add the following to your Button.tsx file:
```tsx
const Button = ({ label }: {
    label: string
}) => {
    return (
        <button>{label}</button>
    )
}
export default Button;
```
Then add the following to the Button.stories.tsx file:
```tsx
import type { Meta, StoryObj } from '@storybook/react';

import Button from './Button';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
const meta = {
  title: 'Button',
  component: Button,
  parameters: {
    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout
    layout: 'centered',
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Primary: Story = {
  args: {
    label: "Test"
  },
};
```
This is a pretty simple storybook config that we will expand on later. Let’s run through it quickly. The first section tells storybook the title of the component inside of storybook, and we pass in the actual component, so it knows what component to render.

The second section creates a new variant called Primary that passes the prop label: "Test" to the Button.

If you open up the storybook window again with (You don’t need to do this if you kept the window open):
```bash
yarn storybook
```
or the following if you are using npm:
```bash
npm run storybook
```
You can now see our component inside of storybook:


![Our first component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hyncf5ay7zv93llzlzp1.png)

This is a very basic component as you can see, so let’s get started with styling it with Tailwind and CVA. First, install CVA:

yarn:
```bash
yarn add -D class-variance-authority
```
npm:
```bash
npm i class-variance-authority --save-dev
```
Now we have to tell Storybook to use Tailwind to do this run:
```bash
yarn add -D @storybook/addon-styling
```
and then:
```bash
yarn addon-styling-setup
```
or for npm:
```bash
npm i @storybook/addon-styling --save-dev
```
and then add "@storybook/addon-styling" to add-ons array inside of .storybook/main.ts

Then update .storybook/preview.ts to replace:
```tsx
import "../src/index.css";
```
with:
```tsx
import "../src/app/globals.css";
```
Then run (If you run into any ESM issues try running this again):
```bash
yarn upgrade
```
or
```bash
npm upgrade
```
Then replace the contents of Button.tsx with:
```tsx
import React from "react";
import { cva, type VariantProps } from "class-variance-authority";

const button = cva("button", {
  variants: {
    intent: {
      primary: [
        "bg-white",
        "text-black",
        "border-transparent",
        "hover:bg-slate-100",
        "rounded-lg",
      ],
      secondary: [
        "bg-[#23252c]",
        "text-white",
        "border-transparent",
        "rounded-lg",
      ],
    },
    size: {
      small: ["text-sm", "py-1", "px-2"],
      medium: ["text-base", "py-2", "px-4"],
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof button> {}

const Button: React.FC<ButtonProps> = ({
  className,
  intent,
  size,
  ...props
}) => <button className={button({ intent, size, className })} {...props} />;
export default Button;
```
Let’s walk through this code. First, we create a CVA style with variants, and we style each variant with tailwind classes. Then we tell CVA what variants to use by default. After that, we create a new React component that forwards any props passed to it into a button component. It also takes in the props intent and size which are then passed into CVA inside the className={button({ intent, size, className })} section.

Now update your Button.stories.tsx file to change the Primary variant to:
```tsx
export const Primary: Story = {
  args: {
    children: "Test",
    intent: "primary",
  },
};
```
You will now be able to see our new Button component inside of Storybook. Try messing around with the controls and see what happens.

## Making it good
Currently, our Button component is a bit generic. Let’s update it so it looks nice. You can go with your own styles, but I’m going to be going for a dark monochromatic style similar to that of Vercel. So to achieve that I’m going to update the classes in our Button.tsx component to:
```tsx
const button = cva("button", {
  variants: {
    intent: {
      primary: [
        "bg-white",
        "text-black",
        "border-transparent",
        "hover:bg-slate-100",
        "rounded-lg",
      ],
      secondary: [
        "bg-[#23252c]",
        "text-white",
        "border-transparent",
        "rounded-lg",
      ],
    },
    size: {
      small: ["text-sm", "py-1", "px-2"],
      medium: ["text-base", "py-2", "px-4"],
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});
```
Here I have updated the background color and text color to achieve the style I want. This article assumes some previous knowledge of tailwind, so I’m not going to go into detail here. But take a look at the Tailwind Docs for more info.

## The Modal
Up until this point we have been building components without Radix because they have been pretty simple so far. But for our next component, we will be using Radix to handle all the accessibility features and interactions for us. Let’s start by installing the Radix modal component.

npm:
```bash
npm i @radix-ui/react-dialog && npm upgrade
```
Yarn:
```bash
yarn add @radix-ui/react-dialog && yarn upgrade
```
Once you have installed the radix modal we can start creating one. I recommend you also take a look at the radix modal docs here as it gives a nice overview of the component.

To get started create a new directory called src/components/Modal and add the files Modal.tsx and Modal.stories.ts and then scaffold Modal.tsx with:
```tsx
import * as Dialog from "@radix-ui/react-dialog";
const Modal = ({ trigger }: { trigger: any }) => {
  return (
    <Dialog.Root>
      <Dialog.Trigger>{trigger}</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title />
          <Dialog.Description />
          <Dialog.Close />
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
};
export default Modal;
```
That’s a lot so here is a quick overview of the components:

Dialog.Root is an HOC that provides context for the sub-components.
Dialog.Trigger is where you put your trigger such as a button
Dialog.Overview covers the part of the screen that is not your modal
Dialog.Title is the title of the modal
Dialog.Description is an optional description for the modal
Dialog.Close is an optional trigger that closes the modal
We take in a trigger component and put it in radix trigger HOC
Now add the following to Modal.stories.tsx:
```tsx
import type { Meta, StoryObj } from "@storybook/react";

import Modal from "./Modal";
import Button from "../Button/Button";
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
const meta = {
  title: "Modal",
  component: Modal,
  parameters: {
    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout
    layout: "centered",
  },
} satisfies Meta<typeof Modal>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Primary: Story = {
  args: {
    trigger: <Button>Trigger Modal</Button>,
    title: "Example Title",
    description:
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
  },
};
```
Now let’s fill out our modal component:
```tsx
import * as Dialog from "@radix-ui/react-dialog";
import Button from "../Button/Button";
const Modal = ({
  trigger,
  title,
  description,
  body,
}: {
  trigger: any;
  title: string;
  description: string;
  body?: any;
}) => {
  return (
    <Dialog.Root>
      <Dialog.Trigger>{trigger}</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content className="bg-black border-2 border-[#22242a]  fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] p-4 rounded-lg">
          <Dialog.Title className="text-2xl">{title}</Dialog.Title>
          <Dialog.Description className="mt-2">
            {description}
          </Dialog.Description>
          {body}
          <Dialog.Close>
            <Button className="mt-4" intent="secondary">
              Close
            </Button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
};
export default Modal;
```
Let’s go over what’s happening here: We take in a trigger prop that you can use to pass in a trigger to our modal component We also take in a description prop for the modal, a title prop for the modal, and a prop for the body of the modal.

If you look at the storybook now you should be able to see our modal component!

Modal inside of Storybook
## Tooltip
First, install the radix tooltip component:

npm:
```bash
npm i @radix-ui/react-tooltip && npm upgrade
```
Yarn:
```bash
yarn add @radix-ui/react-tooltip && yarn upgrade
```
Now create a new directory called src/components/Tooltip with the files Tooltip.tsx and Tooltip.stories.tsx then add the following to Tooltip.tsx:
```tsx
import * as Tooltip from "@radix-ui/react-tooltip";
import Button from "../Button/Button";
const TooltipComponent = ({
  text,
  position,
  intent,
  trigger,
}: {
  intent?: "normal" | "danger";
  position?: "left" | "right" | "top" | "bottom";
  text: string;
  trigger: any;
}) => {
  if (intent == undefined) {
    intent = "normal";
  }
  if (position == undefined) {
    position = "top";
  }
  return (
    <Tooltip.Provider delayDuration={0}>
      <Tooltip.Root>
        <Tooltip.Trigger className="w-full" asChild>
          {trigger}
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content
            side={position}
            className={
              "max-w-xl rounded-lg border-2 bg-base-100 p-2 " +
              (intent == "normal" ? "border-[#22242a]" : "border-red-500")
            }
          >
            {text}
            {intent == "normal" ? (
              <Tooltip.Arrow className="fill-[#22242a]" />
            ) : (
              <Tooltip.Arrow className="fill-red-500" />
            )}
          </Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  );
};
export default TooltipComponent;
```
Here we use the Radix tooltip HOC and take in an intent value that can be normal or danger. Then if it’s danger we make the border red otherwise we make it a neutral color

We also take in a text prop and a position prop. We pass the position prop into Radix’s side prop to set the tooltip side.

Then add the following to Tooltip.stories.tsx:
```tsx
import type { Meta, StoryObj } from "@storybook/react";

import Tooltip from "./Tooltip";
import Button from "../Button/Button";
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
const meta = {
  title: "Tooltip",
  component: Tooltip,
  parameters: {
    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout
    layout: "centered",
  },
} satisfies Meta<typeof Tooltip>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Normal: Story = {
  args: {
    trigger: <p>Hover Over Me</p>,
    text: "This is a tooltip",
    intent: "normal",
  },
};

export const Danger: Story = {
  args: {
    trigger: <p>Hover Over Me</p>,
    text: "This is a tooltip",
    intent: "danger",
  },
};
```
With that, you should be able to see the tooltip inside of Storybook:

![Modal Component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4bvgame8avugdth0mx63.png)

## Dropdown
First, install the radix dropdown component:

npm:
```bash
npm i @radix-ui/react-dropdown-menu && npm upgrade
```
Yarn:
```bash
yarn add @radix-ui/react-dropdown-menu && yarn upgrade
```
Create a new folder called src/components/Dropdown with the files: Dropdown.tsx and Dropdown.stories.tsx.

Scaffold out Dropdown.tsx with:
```tsx
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

const Dropdown = ({ items, trigger }: { items: string[]; trigger: any }) => {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger className="outline-none select-none">
        {trigger}
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content className="min-w-[220px] bg-white rounded-md p-[5px] shadow-[0px_10px_38px_-10px_rgba(22,_23,_24,_0.35),_0px_10px_20px_-15px_rgba(22,_23,_24,_0.2)] select-none outline-none">
          {items.map((item) => {
            return (
              <DropdownMenu.Item
                key={item}
                className="text-black select-none outline-none hover:bg-black hover:text-white p-0.5 rounded cursor-pointer data-[highlighted]:text-white data-[highlighted]:bg-black"
              >
                {item}
              </DropdownMenu.Item>
            );
          })}
          <DropdownMenu.Arrow className="fill-white" />
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
};
export default Dropdown;
```
The dropdown component is pretty simple we use the Radix dropdown component and take in an array of items and a trigger.

and then add the following to Dropdown.stories.tsx:
```tsx
import type { Meta, StoryObj } from "@storybook/react";

import Dropdown from "./Dropdown";
import Button from "../Button/Button";
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
const meta = {
  title: "Dropdown",
  component: Dropdown,
  parameters: {
    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout
    layout: "centered",
  },
} satisfies Meta<typeof Dropdown>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Primary: Story = {
  args: {
    items: [
      "10 - 100",
      "100 - 1000",
      "1000 - 10,000",
      "10,000 - 100,000",
      "100,000+",
    ],
    trigger: <Button>Dropdown</Button>,
  },
};
```
You should now be able to see the Dropdown component inside of storybook:

![Dropdown component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tuw3568bwo9pquhey1gx.png)

## Conclusion
In this article we built several components with Tailwind, Radix, CVA, and Storybook. The great thing about building your own component library is the extensibility, so with what you have learned you can continue to add more components to this library.

## Aside: Stop Spending Weeks Integrating SMS
Thanks for reading this article! If you are looking for a way to send SMS messages without a 15+ business day verification period that providers like Twilio, and Sinch have take a look at [Byteflow!](https://byteflow.app)