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
- What features should our button support?
- What is the ideal developer experience of using the button?
What features should our button support?
Our button should support:
- Customizable sizes, via the
size
input with three options:xs
,sm
,md
- Different button types/variants using the
variant
input with the options -default
,danger
andoutline
- Disabling the button using the input
disabled
. The default value will befalse
- Showing a loading state using the
loading
input. This is useful during form submissions where we will show a spinner SVG and theLoading...
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:
- What features should our button support?
- 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:
- 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
- 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?"
- 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.