How we built our button component

Jan 25, 2025

TLDR:

The button component is one of the most used components in our UI library, alongside the input fields. It appears in all our dialogs and under every form as we have over 88+ forms in our app. It is also used as a trigger for our menus.

With that in mind, careful consideration had to be taken when building this component.

We used Tailwind-v3 and Angular 19.

TLDR: Here is a stackblitz of the button using Tailwind-v3 and Angular19. You can also see the button in Storybook here. Here is also a visualisation of how the button was built.

Before we build the button, let us first consider how developers will use it.

Button Architecture

  1. What features should our button support?
  2. What is the ideal developer experience of using the button?

What features should our button support?

Our button should support:

  1. Customizable sizes, via the size input with three options: xs, sm, md
  2. Different button types/variants using the variant input with the options - default, danger and outline
  3. Disabling the button using the input disabled. The default value will be false
  4. Showing a loading state using the loading input. This is useful during form submissions where we will show a spinner SVG and the Loading... text.

Given all that we have outlined, our button will be used like this:

The above button is fine for some forms. What if we have a button that is submitting a user creation form? With the current button the user will see Loading... but what if we could show them a different text that captured the current context of the form they just submitted? We could define a new button input called loadingText where we can control exactly what text will be displayed when the button is loading.

Now our button will be used like this:

At this point, the button works for most cases but we had one more consideration. Our button is used as a foundation for other components. We need more control over other parts of the button.

The button has some animation which is acceptable when used on its own, but when it is used as a trigger to open a menu, we need to disable this animation. I will name this input animate and its default value will be true.

The other considerations were the font-weight of the button which I will name weight and the border-radius of the button which I will name radius . Now, when our button is used to build other reusable components, it can now be used like this:

We have outlined the button architecture and answered our initial questions of:

  1. What features should our button support?
  2. What's the ideal developer experience of using the button?

Now let's build the button.

Building the button

The button went through several iterations, each teaching valuable lessons about component design. Here's the journey:

  1. In my first version, I built a basic button with the essential features but the code had some issues:
    • Classes were tightly coupled in the component
    • The template logic was complex
    • State management wasn't as clean as it could be
  2. Looking for a solution, I turned to the CVA (Class Variance Authority) library. The code became cleaner and more organized. When I proudly showed this version to my mentor Bonnie Brennan, she asked a simple but profound question: "Why are you using this library?"
  3. This question led to an important discussion about dependencies. In our fintech environment, every dependency is a security/compliance risk and while CVA solved my immediate problems, it added an external dependency that might not be necessary. Bonnie challenged me to achieve the same clean structure without the library.

The Code

We will define all the button features we support:

Notice we use the host element - this is a recommendation from the Angular docs

Always prefer using the host property over @HostBinding and @HostListener. These decorators exist exclusively for backwards compatibility.

Putting the classes together.

All the buttons share some styles, but each variant has its own unique style for when it is disabled or loading. We want this to be configurable. Let us first define how our data will look like and then build the interface.

I've found it better to define how my data will be structured first and then define the interface and at other times, I found it easier to define the interface first. For this case, I first defined some parts of the configuration, then I defined some parts of the interface and then went back and forth refining it.

Here is a visualizer I built to showcase how I iterated through the classes:

This is our final ButtonClasses interface:

Now we manage button focus for accessibility and use HTML data attributes to manange the different button variants

Class Composition

The final piece is how we combine all these classes together. We use Angular's computed signals to create a reactive class string:

This computed signal automatically updates whenever any input changes, and we bind it to the host element:

The data attributes (data-loading, data-disabled) work with our Tailwind classes to show the correct state styling.