DEV Community

Cover image for The Ultimate Guide to building a UI Library
Max Campbell for Byteflow

Posted on

The Ultimate Guide to building a UI Library

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:
npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

And use the following options:

✔ 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
Enter fullscreen mode Exit fullscreen mode

Now we need to install Storybook. Run this in your Next.js project to install Storybook:

npx storybook@latest init
Enter fullscreen mode Exit fullscreen mode

After the command finishes installing dependencies it will open a new window in your browser with Storybook:

Storybook Window

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

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:

const Button = ({ label }: {
    label: string
}) => {
    return (
        <button>{label}</button>
    )
}
export default Button;
Enter fullscreen mode Exit fullscreen mode

Then add the following to the Button.stories.tsx file:

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"
  },
};
Enter fullscreen mode Exit fullscreen mode

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):

yarn storybook
Enter fullscreen mode Exit fullscreen mode

or the following if you are using npm:

npm run storybook
Enter fullscreen mode Exit fullscreen mode

You can now see our component inside of storybook:

Our first component

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:

yarn add -D class-variance-authority
Enter fullscreen mode Exit fullscreen mode

npm:

npm i class-variance-authority --save-dev
Enter fullscreen mode Exit fullscreen mode

Now we have to tell Storybook to use Tailwind to do this run:

yarn add -D @storybook/addon-styling
Enter fullscreen mode Exit fullscreen mode

and then:

yarn addon-styling-setup
Enter fullscreen mode Exit fullscreen mode

or for npm:

npm i @storybook/addon-styling --save-dev
Enter fullscreen mode Exit fullscreen mode

and then add "@storybook/addon-styling" to add-ons array inside of .storybook/main.ts

Then update .storybook/preview.ts to replace:

import "../src/index.css";
Enter fullscreen mode Exit fullscreen mode

with:

import "../src/app/globals.css";
Enter fullscreen mode Exit fullscreen mode

Then run (If you run into any ESM issues try running this again):

yarn upgrade
Enter fullscreen mode Exit fullscreen mode

or

npm upgrade
Enter fullscreen mode Exit fullscreen mode

Then replace the contents of Button.tsx with:

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;
Enter fullscreen mode Exit fullscreen mode

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:

export const Primary: Story = {
  args: {
    children: "Test",
    intent: "primary",
  },
};
Enter fullscreen mode Exit fullscreen mode

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:

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

npm i @radix-ui/react-dialog && npm upgrade
Enter fullscreen mode Exit fullscreen mode

Yarn:

yarn add @radix-ui/react-dialog && yarn upgrade
Enter fullscreen mode Exit fullscreen mode

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:

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;
Enter fullscreen mode Exit fullscreen mode

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:

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.",
  },
};
Enter fullscreen mode Exit fullscreen mode

Now let’s fill out our modal component:

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;
Enter fullscreen mode Exit fullscreen mode

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:

npm i @radix-ui/react-tooltip && npm upgrade
Enter fullscreen mode Exit fullscreen mode

Yarn:

yarn add @radix-ui/react-tooltip && yarn upgrade
Enter fullscreen mode Exit fullscreen mode

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:

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;
Enter fullscreen mode Exit fullscreen mode

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:

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",
  },
};
Enter fullscreen mode Exit fullscreen mode

With that, you should be able to see the tooltip inside of Storybook:

Modal Component

Dropdown

First, install the radix dropdown component:

npm:

npm i @radix-ui/react-dropdown-menu && npm upgrade
Enter fullscreen mode Exit fullscreen mode

Yarn:

yarn add @radix-ui/react-dropdown-menu && yarn upgrade
Enter fullscreen mode Exit fullscreen mode

Create a new folder called src/components/Dropdown with the files: Dropdown.tsx and Dropdown.stories.tsx.

Scaffold out Dropdown.tsx with:

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;
Enter fullscreen mode Exit fullscreen mode

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:

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>,
  },
};
Enter fullscreen mode Exit fullscreen mode

You should now be able to see the Dropdown component inside of storybook:

Dropdown component

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!

Top comments (2)

Collapse
 
respect17 profile image
Kudzai Murimi

Thanks a lot! Helpful

Collapse
 
jippy16 profile image
Jip

Thanks, can you please also discuss how to publish this in npm?