Building NomadHair: A Journey in Component Driven Development Part II — UI Library and Reusable Components
In the previous article, I have covered how I have set up the foundation for component-driven development for NomadHair. I explained my process for coming up with a UI layouts and how I dissected the UIs into a smaller components using Atomic Design pattern.
In this part of the series, I’ll be delving into how I developed a UI library with Storybook. Also, we’ll be exploring several React patterns for building reusable components, how to handle component variants, as well as discussing some challenges and limitations I faced along the way.
Table of Content
- UI Library with Storybook
– Documentation
– Iterate and integrate - Patterns for reusable component
– Pattern I: Extended prop interface
– Pattern II: Polymorphic component
– Pattern III: ReactforwardRef
API - Handling component style variants (feat.CVA)
– Problems with handling variants with TailwindCSS
– Class Variants Authority (CVA) to the rescue - Challenges and limitations
– Agility vs scalability
– Limitations of third party library
UI Library with Storybook
When building UIs from the bottom up, it is often useful to have UI component library — a collection of pre-built UI components. It ensures that all team members are using the same source of truth and that the end product maintains a consistent and polished appearance.
For this project, I have used Storybook as my UI component library.
Before we dive in, here is the the UI component library for your reference.
Storybook is a development tool that allows you to build and test UI components in isolation without interferences of the app’s business logic or context.
It creates interactive documentations for each UI component you build, which makes cross-collaboration with designers and product owners more productive.
i. Documentation
In Storybook, each component is documented in a form of “Story”. You can create a Story by creating a separate file with .stories.tsx
file extension. Each story file supports documentation out of the box.
Creating a story is pretty straightforward. You can simply import the component into the storiex.tsx
file and embed it inside Story object based on the Component Story Format.
// avatar.stories.tsx
import { Meta, StoryObj } from "@storybook/react";
import { Avatar, AvatarImage, AvatarFallback } from "./avatar";
/**
* Avatar component is used to represent a user, and display the profile picture, initials or fallback icon.
*/
const meta: Meta<typeof Avatar> = {
title: "Atoms/Avatar",
component: Avatar,
...
};
export default meta;
type Story = StoryObj<typeof Avatar>;
export const Default: Story = {
render: ({ size }) => (
<Avatar size={size}>
<AvatarImage
src="<https://images.unsplash.com/photo-1506863530036-1efeddceb993?q=80&w=3444&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D>"
alt="profile"
/>
<AvatarFallback />
</Avatar>
),
};
export const Fallback: Story = {
render: ({ size }) => (
<Avatar size={size}>
<AvatarImage src="unknown-path" alt="profile" />
<AvatarFallback>TN</AvatarFallback>
</Avatar>
),
};
Once you save it, Storybook will render the components in the Storybook app with interactive documentation. You can toggle between different states or props, and also check out code snippet.
To take this step a further, you can deploy Storybook app to Chromatic to showcase these UI components in real-time.
ii. Iterate and integrate
Building UI components for the project was an iterative process. It involved designing components with Figma, then quickly building those components in Storybook to validate their features.
It is hard to predict how component should interact and how every edge case should look like only by using design tools and your imagination.
- How does the component look in different viewport size?
- Does it break when the server returns error?
Reducing this feedback loop is crucial to the success of component driven approach.
Each component was thoroughly tested for different use cases (viewport size, component states, color variants, and accessibility).
Once the components are tried and tested, then I imported those components into the application assembled into larger pieces of UIs.
Now, there are many different ways to verify the UIs, such as accessibility testing, integration testing, visual testing. And I’ll be covering these topics in more details in the next blog post.
Patterns for reusable component
When building out components for UI library, each component has to be flexible enough to support the use cases and product needs that you don’t know about yet.
In this way, developers who are using the components will be able to quickly extend the features to accommodate new requirements.
The key elements of reusable UI components are as follows:
- Components should share the same APIs as native HTML elements.
- Components should allow easy access to DOM nodes.
There are three patterns that can be useful in achieving this.
Pattern I: Prop Spreading with Extended Interface
In order to maximize the flexibility of the UI components, you want to make the interface and behavior of the component resemble a native HTML element as closely as possible. We can do this by spreading props from the extended interface of native HTML elements.
This approach helps us avoid the need to explicitly define props for every single HTML attribute, such as aria-labels
and onClick
, keeping the prop sheets as minimal as possible.
We can implement this by extending your prop interface from ComponentWithoutRef<T>
. For instance, we can make a <Button>
component as a drop-in replacement for the native HTML <button>
element like this:
// extends HTML button elements' attributes
interface ButtonProps extends ComponentPropsWithoutRef<'button'>
export const Button = (props: ButtonProps) => {
return <button {...props}>...</button>;
};
// All the valid button elements' attribute can be accessed via props.
// Valid prop
<Button aria-label="submit button" />
// Valid prop
<Button onClick={()=> console.log('click me')} />
// Valid prop
<Button className="w-4 h-4 rounded-full" />
// Error - Invalid prop. There is no "select" type on HTML button element.
<Button type="select" />
You can also use React.ButtonHTMLAttributes<HTMLButtonElement>
to achieve the same results. Personally, I tend to stick with ComponentPropsWithoutRef<’button’>
since it’s less verbose.
Pattern II: Polymorphic component
Sometimes, a single component may need to render different HTML elements depending on the context in which it is used. Buttons (<button>
) and links (<a>
) are common examples of this use case.
From the users’ perspective, the differences between a button and a link are trivial. However, they are semantically two different elements and should be distinguished for better accessibility of the app.
- Button — triggers actions upon click
- Link — navigates to a different path upon click
To solve this issue, we can use the polymorphic component pattern. This pattern allows us to dynamically render different DOM elements inside our React component based on a prop. Here is an example of a <Button>
component in the polymorphic pattern.
// Receive HTML element as a generic constraint
type ButtonProps<T extends React.ElementType> = {
as?: T;
...
// Extend props from given element type
} & Omit<React.ComponentPropsWithoutRef<T>, 'as'>;
export const Button = <C extends React.ElementType>({
className,
as,
...props
}: ButtonProps<C>) => {
// if as prop is undefined, render button element by default
const Comp = as || 'button';
return (
<Comp
className={clsx(
...
)}
{...props}
>
{props.children}
</Comp>
);
};
The component receives as
props as a generic constraint for React.ElementType
(e.g., 'a', 'div'). If the as
prop is defined, it will render that element as the DOM nodes. Otherwise, it will render a button
element by default. React.ComponentPropsWithoutRef<T>
will also extend props from the given value of the as
prop.
// Renders <button>
<Button>
This is a button
</Button>
// Invalid: 'href' doesn't exist on button attribute
<Button href="/">
This is a link?
</Button>
// Renders <a>
<Button as="a" href="/">
This is a link
</Button>
However, for the sake of simplicity, I ended up using Radix-UI’s <Slot>
component to render a placeholder for a child component. It achieves pretty much the same results, but with more concise code. It also applies all the same styles as the button without having to abstract away the child elements.
import { Slot } from "@radix-ui/react-slot";
// extends HTML button elements' attributes
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
asChild?: boolean;
}
export const Button = ({asChild, ...props}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp {...props}>...</button>
);
};
// Wrap HTML a tag with Button component
<Button asChild>
<a href='/'>This is a Link</a>
</Button>
// Result
<a href='/' class='...all the button styles' >This is a Link </a>
Pattern III: React forwardRef API
React team has announced that with React 19, forwardRef is deprecated and you no longer need to wrap your components with forwardRef in order to pass down ref to children. So we may not need this pattern going forward (pun intended).
I have also used forwardRef
API, so that parent component can access the the DOM node from the outside. This enables a developer to access the native HTML <button>
node simply by passing in ref
prop to <Button />
component.
export const Button = forwardRef<HTMLButtonElement>((props, ref) => {
return <button ref={ref}>...</button>;
});
Button.displayName = 'Button';
This can be useful in situations like whenever a developer needs to trigger focus()
event, or access getBoundingClientRect()
to locate the position of a component on the screen.
However, please do note that this pattern should be use sparingly. You don’t want to wrap every component with forwardRef
.
In most cases, if you are just building regular component, you won’t have to worry about forwarding a ref, unless it’s really necessary.
Handling component style variants (feat. CVA)
Besides providing a flexible component APIs, the components also need to support various set of predefined styles that developers can easily access.
In the previous article, I briefly touched on how I designed component variants using Figma.
The goal is to turn these variants into component APIs that allow developers to easily use and customize styles as they need, just like you may have seen in libraries like MUI or Radix-UI.
<Button className={'custom-styles'} variant='outlined' size='lg'>
This is a reusable button
</Button>
i. Problems with handling variants with TailwindCSS
Tailwind is a great tool for writing styles. Its syntax is intuitive and easy to pick up. However, if you have tried to update variants based on component states or props, you may have experienced a bit of a headache.
For instance, you might start out with a small set of variants like this. Nice and easy.
interface ButtonProps extends ComponentsWithoutRef<'button'> {
variant?: 'contained' | 'outlined';
}
export const Button = ({
variant = 'contained',
...props
}: ButtonProps) => {
return (
// conditionally handle variant using ternary
<button
className={`w-24 h-8 flex items-center justify-center shadow-md ${
variant === 'contained'
? 'bg-teal-600 text-white'
: 'bg-white text-teal-600 border border-teal-600'
}`}
{...props}
>
{props.children}
</button>
);
};
As you might have already noticed, this way of handling style does not scale very well as more variants are added on. What if you want to add a size prop? what about the color of the components?
Sure, you can use libraries like, clsx to handle multiple permutations of these variants.
import clsx from "clsx";
interface ButtonProps extends ComponentsWithoutRef<'button'> {
variant?: 'contained' | 'outlined' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button = ({
className,
variant = 'contained',
size,
...props
}: ButtonProps) => {
return (
<button
// Use clsx to handle multiple variants
className={clsx(
`rounded-full flex items-center justify-center shadow-md`,
{
// variant
'bg-teal-600 text-white': variant === 'contained',
'text-teal-600 bg-white border border-teal-600':
variant === 'outlined',
// size
'w-20 h-5': size === 'sm',
'w-24 h-8': size === 'md',
'w-32 h-8': size === 'lg',
...
}
)}
{...props}
>
{props.children}
</button>
);
};
This works out nicely, but this can get really complicated if the <Button>
need to support dozens of props - isLoading, iconPosition, hasIcon, etc..
Soon, you find yourself writing conditional logics to handle 20+ permutations of styles.
To handle this problem, I have used a library called Class Variants Authority (CVA).
ii. Class Variants Authority (CVA) to the rescue
CVA provides easy to use interface to define your component variants. It lets you conditionally apply sets of classes based on your variants, or a combinations of variants.
With CVA you can:
- Define default styles
- Apply sets of classes based on the different combinations of variant
- Handle type checking for each variants and its combinations.
Styling with CVA looks something like this.
First define the component variants using cva()
function. This function takes in base classes, and a set of options for your variants.
This, then, will return a cva
component function that takes in props as parameters to determine appropriate styles and a returns a string of classes.
// Button.tsx
import { cva } from "class-variance-authority";
// cva component
const buttonVariants = cva(
// base style
"rounded-full flex items-center justify-center shadow-md",
// variant schema
{
variants: {
variant: {
contained: "bg-teal-600 text-white",
outline: "bg-white border border-teal-600 hover:border-teal-700",
ghost: "bg-transparent",
...
},
},
// variants based on a combination of previously defined variants
compoundVariants: [
{
variant: ["outline", "ghost"],
className: "text-teal-100 hover:bg-teal-100/5",
},
...
],
// default variant
defaultVariants: {
intent: "primary",
varint: "contained",
...
}
}
)
...
Once you’ve defined the cva
component, you can use it alongside the utility type VariantProps
. This extends the type interface for your components' props. After doing this, you can pass it inside the className
prop to apply Tailwind classes based on the props that have been passed in.
// Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
// cva component
const buttonVariants = cva(
...
);
export interface ButtonProps
extends ComponentsWithoutRef<'button'>,
// extend ButtonProp interface from buttonVariants
VariantProps<typeof buttonVariants> {
children?: React.ReactNode;
}
const Button =({variant, props}: ButtonProps) => {
return (
<button className={buttonVariants({variant, size)} {...props}>
{children}
</button>
);
};
export { Button, buttonVariants };
With all that being said, here is how the button component for NomadHair looks and behaves.
If you wish to learn more about CVA, I encourage you to check out Tru Narla’s tutorial on CVA.
You can also find the source code for this button component in Git repository.
Challenges and limitations
Of course, like any other development, building a UI component didn’t follow the happy path.
The entire process described above involved a lot of trial and error. I would like to share some of the challenges that I struggled with, as well as the limitations of this project.
i. Agility vs scalability
One of the dilemmas in building UI components was finding the right balance between development speed and supporting robust component design. While it was important to consider the long-term reusability of components, I also did not want to get bogged down in supporting use cases that were not going to be used in the near future.
Finding the right balance, in most cases, boiled down to thinking from the perspective of users and the product:
- “What purpose does this component serve in the context of the whole app?”
- “Will adding an icon to the button improve the user experience?”
- “How does transition animation on the modal improve the perceived performance of the app?”
These types of questions do not have clear-cut answers, and there was a limit to the perspective I can bring as I was working on this independently. However, in an ideal world, these answers should be discussed with product owners, UI/UX designers, and other stakeholders.
ii. Limitations of third party library
When building components for a UI library, it is advised that one should try to avoid reinventing the wheel. However, I’ve learned this is only partially true.
I have used third-party tools like ShadCN, Radix, and React Calendar to build out more complex components like Toast and Calendar. While these libraries do provide easy-to-use interfaces to layer the custom component on top, they are not completely free from bugs.
I’ve run into some accessibility bugs on Radix’s <Toast>
and React Calendar's <Calendar>
. However, since I don't own their source code, I couldn't figure out an easy way to fix this.
- Radix Toast accessibility bug
Currently, there is a issue created on Raidx-UI repository to address this bug.
- React Calendar accessibility bug
This was the part where I had to cut corners and decided to move forward with the project.
Creating these components from scratch would be amazing, but it would be a project of its own, which I might attempt in the near future.
If you have some ideas on how I could have handled this better, let me know in the comments!
Recap
In this post, I have covered how I have used Storybook to document those components and deployed it to Chromatic as a shareable UI library. I have also discussed component design patterns and a tool to create reusable and flexible React components.
However, there’s one more missing piece that we haven’t covered — Testing!
In the next article, I will dive deeper into the different types of tests I used to verify UIs and how I have automated these tests into a more productive workflow.