workflow · 2022Creating my own UI components

jorenrui's avatar

@jorenrui / December 17, 2022

12 min read

While it's cool that there are UI frameworks out here that can speed up our development like MUI, Ant Design, Base Web, and Bootstrap, I mostly prefer to create my own UI components for personal projects and at times even at work. I have similar thoughts to Josh Cameau's "You Don't Need A UI Framework" article.

Reasons for implementing my own

1. Branding, mostly customization

While it's great that existing UI frameworks can help in speeding up the development process when it's time to add that personal touch it will become cumbersome in the long run. Most companies want their websites to feel familiar but unique. Familiar that most UX happens as what the user expected - leveraging existing mental models, but unique in that users can still differentiate the site from other ones on the internet.

Just using a UI framework as is makes the site the same with the others. If the site is just a prototype then this is not really a concern. But if branding is important which is most often the case with companies, then some tweaks is needed when using an existing UI framework.

We've tried that before. Updating design tokens most often does the job of making it feel unique. Adjusting the spacing, a little tweak with the corner radius, and changing the primary and secondary colors can do its job. But there are times when this is just not enough.

Sometimes a component, let's say a button, gets tweaked over and over for a long period of time until it becomes a wholly different one. A lot of variants get added. A lot of style overrides has been made. You wrestle as you try to make a certain client request work by customizing that imported Button to its limits. That the Button component gets wrapped in another component that makes you wonder if it were a little easier if you roll out your own button instead.

import PrimitiveButton from "some-component-ui-framework";
import { BUTTON_OVERRIDES } from "./styles";

export function Button({ ...variantPropsHere }) {
 return (
	 <PrimitiveButton overrides={BUTTON_OVERRIDES}>
		 {...variantPropsHereUsedInWhoKnowsWhat}
	 </PrimitiveButton>
 );
}

As developers follow the design spec and client requests, we find ourselves adding more and more variations to a UI component that leads to this whole lot mess. Prolly there's a more sane way of doing this, but for now I much prefer my current approach over this. Yes, yes, I can just create another Button instead of using that imported Button. Maybe it's just me but doesn't it confuses the other devs more if you have two buttons which you can import? One from the UI framework and one of your own. Also the naming ahhh, I don't like naming things. It's hard hence I hate it. I'll prolly name it Button2 or CustomButton to remove redundancy.

Lastly, I mostly think that UI frameworks are design systems made for its parent company like how Material UI is for Google and Base Web is for Uber. So if you want an identity of your own, customizing other companies' design system isn't the way.

2. Access to Code - More Control

Although most of what you need are already supported by existing UI frameworks, there will be certain functionalities that the current UI framework you are using does not support yet. You really can't and shouldn't force the maintainers to add features just for your specific issue. With this, the choices are either forking it - which I don't really wanna do especially if it's for a single UI component, or reimplementing that single component.

For me, in terms of UI components, I much prefer more control. Mostly because existing UI frameworks are mostly made for general use or mostly for their maker's use. Some UI components that you might need might only be specific to the project you are working on. Imagine being able to customize a Button behavior easily. Base Web has this Style Overrides which enables more control which I think is one solution for allowing custom buttons. But I think if for every button in your codebase, you need to apply your default style overrides to conform to your branding then I think either you are doing it wrong or we may really have a problem.

3. Usability and Accessibility

Quoting from Josh Cameau, "UI frameworks have a hit-or-miss record when it comes to usability and accessibility". I agree. Though I don't really blame UI framework maintainers for this because it's really hard to hit all those accessibility checklist. They already have a lot of things to worry about and I mostly prefer using libraries that are specifically made for implementing them instead.

Journey of creating my own UI framework

In my opinion, creating your UI framework is hard the first time. But if you have an initial one, then you can just copy-paste them over to another project and then tweak it to your liking.

In deciding what styling library/framework I would use that fits my preferences, one that works well with ReactJS, I first started with CSS-in-JS solutions. With this, I started with Emotion. It was ok, but felt lacking for me. Luckily at that time, Stitches was in its alpha stages. When I saw its API, I quickly fell in love with it. It was exactly what I was looking for. Even before I saw variants in Figma, I saw it first at Stitches. With this, I can create multiple combination of variants for a single component. An example of its API is this:

// Taken from the documentation

const Button = styled('button', {
  // base styles

  variants: {
    color: {
      violet: {
        backgroundColor: 'blueviolet',
        color: 'white',
        '&:hover': {
          backgroundColor: 'darkviolet',
        },
      },
      gray: {
        backgroundColor: 'gainsboro',
        '&:hover': {
          backgroundColor: 'lightgray',
        },
      },
    },
  },
});

() => <Button color="violet">Button</Button>;

I was so excited about Stitches that I used it for the alpha version of my first side project after college, Sutle. Moreover, because of them, it led me to discover Radix UI. It was a game changer for me because I learned how to avoid building soul-crushing components or components that have a huge list of props. Moreover, using it enables you to use accessible UI components out of the box. An example Radix UI component is this Slider demo:

// Taken from the documentation
import React from 'react';
import * as Slider from '@radix-ui/react-slider';
import './styles.css';

const SliderDemo = () => (
  <form>
    <Slider.Root className="SliderRoot" defaultValue={[50]} max={100} step={1} aria-label="Volume">
      <Slider.Track className="SliderTrack">
        <Slider.Range className="SliderRange" />
      </Slider.Track>
      <Slider.Thumb className="SliderThumb" />
    </Slider.Root>
  </form>
);

export default SliderDemo;

With Stitches and Radix UI, I was able to create my first UI component library, Minorui.

However, as I use Stitches more and more I found some frictions in my workflow:

  1. It's easy to not conform to the design tokens. Instead of using the theme tokens like backgroundColor: '$violet800', I found myself using literal values at times.
  2. Creating my own theme tokens is hard and a hassle. It's an irony that I want to create my UI framework yet I don't want to set up the initial design system. I'm more of a developer than a UI designer hence I don't enjoy thinking about what color values or what point spacing system I would use.

In addition, reading an article entitled "Why We're Breaking Up with CSS-in-JS" written by Sam Magura, an active maintainer of Emotion, made me think twice in using CSS-in-JS solutions. Don't get me wrong, CSS-in-JS solutions like Stitches has their own benefits. However, it just not fits what I prefer.

This led me to look into CSS solutions more. Just writing the good ol' plain CSS and using the BEM methodology seemed appealing. But since I'm using ReactJS, I decided to look into solutions that work well with components like CSS Modules. Due to work, I ended up learning TailwindCSS. At first, I was hesitant since I got used to using CSS-in-JS solutions and/or creating custom CSS classes, and I find it weird that I'm writing a lot of CSS classes in my JSX and/or HTML.

In the end, I slowly fell in love with it. I also realized that it satisfy what I need mainly that it forces me to conform to using its existing classes and a default theme configuration. Moreover, it works great with components. So now I use TailwindCSS paired with VS Code's Tailwind CSS IntelliSense Extension. But there's a missing ingredient...

Then Joe Bell, who I met when I was using Stitches, shipped CVA. It was the missing ingredient to what I needed. Using it, I was able to easily add variants to my components just like what I love about Stitches. Moreover, it is framework agnostic! With it, you can do something like:

// Taken from GitHub repo's README.md
import { cva } from "class-variance-authority";

const button = cva(["font-semibold", "border", "rounded"], {
  variants: {
    intent: {
      // You can do one liner string
      primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
      // Or an array
      secondary: [
        "bg-white",
        "text-gray-800",
        "border-gray-400",
        "hover:bg-gray-100",
      ],
    },
    size: {
      small: ["text-sm", "py-1", "px-2"],
      medium: ["text-base", "py-2", "px-4"],
    },
  },
  compoundVariants: [
    {
      intent: "primary",
      size: "medium",
      class: "uppercase",
    },
  ],
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

button({ intent: "secondary", size: "small" });
// => "font-semibold border rounded bg-white text-gray-800 border-gray-400 hover:bg-gray-100 text-sm py-1 px-2"

With this, my toolset is complete. So now I create UI components by using TailwindCSS, CVA, and Radix UI. Radix UI for the initial unstyled components that focus on accessibility, TailwindCSS for adding the styles with its utility classes, then CVA for creating component styles with variants for those unstyled components. Using this toolset, I rewrote and redesign the beta version of Sutle.

A sample implementation of this toolset is this button from Sutle beta version.

Note that I'm just adding stuff as I go since I'm mostly not that strict in my coding style when it comes to my personal projects. So it might look like trash to you in some way, but it's my trash😎

import { cva, VariantProps } from 'cva';
import { ImSpinner7 } from 'react-icons/im';
import { DetailedHTMLProps, ButtonHTMLAttributes } from 'react';

export const button = cva('inline-flex items-center justify-center border border-transparent font-medium rounded whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-offset-2', {
  variants: {
    kind: {
      primary: 'text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500',
      secondary: 'text-white bg-slate-800 hover:bg-slate-700 focus:bg-slate-700 focus:ring-slate-700',
      tertiary: 'text-white hover:bg-slate-800 focus:bg-slate-800 focus:ring-slate-800',
      error: 'text-white bg-red-700 hover:bg-red-600 focus:bg-red-600',
    },
    size: {
      xs: 'text-xs px-2.5 py-1.5',
      sm: 'text-sm px-2.5 py-1.5',
      base: 'text-base px-4 py-2',
      lg: 'text-base md:text-lg py-2.5 px-4 md:px-6 md:py-3',
    },
    disabled: {
      true: '!bg-slate-800/25 text-slate-600 cursor-not-allowed',
      false: 'cursor-pointer',
    },
  },

  compoundVariants: [
    { kind: 'light:primary', disabled: true, class: '!bg-slate-50 !text-slate-600 !ring-1 !ring-slate-400' },
    { kind: 'light:secondary', disabled: true, class: '!bg-slate-50 !text-slate-600 !ring-1 !ring-slate-400' },
    { kind: 'light:tertiary', disabled: true, class: '!bg-slate-50 !text-slate-600 !ring-1 !ring-slate-400' },
    { kind: 'light:error', disabled: true, class: '!bg-slate-50 !text-slate-600 !ring-1 !ring-slate-400' },
  ],

  defaultVariants: {
    kind: 'tertiary',
    size: 'base',
    disabled: false,
  },
});

type ButtonProps = VariantProps<typeof button>;
type IProps = ButtonProps & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
  loader?: 'default' | 'spinner';
  loading?: boolean;
};

export function Button({ kind, size, className = '', loader, children, disabled, loading, ...props }: IProps) {
  if (loading) {
    return (  
      <button
        className={button({ kind, size, disabled, class: `relative ${className}` })}
        disabled={loading}
        {...props}
      >
        {loader === 'spinner'
          ? <ImSpinner7 className="absolute h-4 w-4 animate-spin" aria-hidden="true" />
          : (
            <span className="absolute flex items-center justify-center gap-2" aria-hidden="true">
              <span className="h-1 w-1 bg-white rounded-full animate-pulse " />
              <span className="h-1 w-1 bg-white rounded-full animate-pulse duration-100" />
              <span className="h-1 w-1 bg-white rounded-full animate-pulse duration-300" />
            </span>
          )}
        <span className={button({ kind, size, class: `invisible relative ${className} !p-0 !m-0 !border-none` })}>{children}</span>
        <span className="sr-only">
          Loading, please wait.
        </span>
      </button>
    );
  }

  return (
    <button
      className={button({ kind, size, disabled, class: className })}
      disabled={disabled}
      {...props}
    >
      {children}
    </button>
  );
}

For example of Radix UI usage, it's too much code to paste so I think this is enough:

import * as Collapsible from '@radix-ui/react-collapsible';

...
<Collapsible.Root open={open} onOpenChange={setOpen} className="my-2">
  <Collapsible.Trigger
    type="button"
    className="w-full py-1.5 px-2 flex justify-between items-center gap-1 text-sm text-white bg-slate-800 rounded hover:bg-slate-700 focus:bg-slate-700"
  >	
    Tags
    {open
      ? <HiChevronDown className="h-5 w-5" />
      : <HiChevronRight className="h-5 w-5" />}
  </Collapsible.Trigger>
  <Collapsible.Content>
    {...}
  </Collapsible.Content>
</Collapsible.Root>

But I don't recommend creating your own every time especially since creating your own has the following downsides:

  • Lack of documentation - unless you put in the time to create one.
  • Takes time to create your own instead of using an existing one.

Some cases that I think are worth using existing libraries are small projects where the client doesn't really have the money to roll out its own or it doesn't make sense to create one, creating prototypes, and your personal small side projects.

Summary

In summary, when creating my own UI components I mostly use the following techs:

  • TailwindCSS - a utility-first CSS framework. This is what I used for my creating styles.
  • VS Code's Tailwind CSS IntelliSense Extension - autocompletion for TailwindCSS for VSCode.
  • CVA - Class Variance Authority - allows the creation of variants. Used for creating style variants for components also helps in encapsulating styles from logic.
  • Radix UI - unstyled, accessible ReactJS UI components. This is used in combination with CVA and TailwindCSS to build my own UI components.

Other unstyled accessible UI component libraries/frameworks:

  • Headless UI - Made by the maintainers of TailwindCSS.
  • Reach UI - Made by Ryan Florence, co-creator of React Router and Remix.
  • React Aria - by Adobe.

Currently, I'm building my own UI kit called Suikun UI.