Empathetic Coding - Button Component
In this article, we’ll be creating a Button component from the ground up using an empathetic approach.
We are using Tailwind and the tailwind-merge library for styling.
Step 1 - Foundation
The first and most important decision is that our <Button>
should work the same as a native <button>
. It should accept the same props so that any developer who uses our <Button>
can use it as they would any other <button>
.
We’re missing one thing (you may have already guessed). Most components don’t need forwardRef, but buttons often do, so we should include it now.
Now, our <Button>
works exactly like a native <button>
. Let’s start adding functionality, focusing on convenience.
Step 2 - Reasonable Defaults
Setting reasonable default props is a simple way to add convenience.
The native default type for <button>
is submit
, but developers usually want type button
(and if a developer doesn’t remember to set this, it can lead to unexpected behavior).
By making the default type button
, developers only need to include the type prop if they want it to explicitly be submit
or reset
.
Most buttons have a single line of centered text which should not be able to be selected (since it can accidentally interfere with click actions), so we added Tailwind classes to handle this.
Most importantly, we’re using the tailwind-merge twMerge
function to apply these classes so that developers can override these default classes if they need to.
Escape Hatches
We can’t predict the future. By providing strategic escape hatches, we are allowing for exceptions without requiring developers to hack around it.
Some devs don’t do this because they think they’re preventing other devs from “getting into trouble” by doing something “unexpected”. Instead, trust that other developers know what they’re doing and may have needs we can’t anticipate.
The simplest escape hatch you can add to your components is an optional className?: string
prop (preferably, with twMerge
). You’d be surprised how much convenience this one thing adds.
Step 3 - Styling
Our Button component should follow the design provided by our designer, including different sizes and variants.
We’ve implemented Tailwind classes for design, including a few sizes and variants. We’ve created types for them, and set defaults for size
and variant
.
The size
prop follows the same convention as Tailwind font sizes which makes it consistent, easier to remember, and therefore convenient.
The variant
prop has a custom
value which contains no css classes. This makes it easy to make one-off buttons instead of forcing developers to override the border, background, text color, etc. Because of the twMerge
escape hatch, they could start with one of the variants as a base and change just the things they need.
If a developer finds themselves using the same custom classes multiple times, they can add it to the Button component as a new Variant
, or they can compose our Button component inside of their own component which sets the variant. Either solution could be appropriate, and our Button component is flexible enough to support either one.
Limitation
The size
prop does not include a custom
value like variant
does. This is the first limitation that we’ve added.
We’ve decided we will always set padding and text size and developers need to start with one of our pre-defined sizes and explicitly override them. There are a few reasons for this decision.
- The
size
prop is only setting padding and text size. ThetwMerge
escape hatch allows developers to override these if they need to. - The
Variant
types will likely have many more css properties (hover colors, dark mode colors, rounded corners, etc.). It’s too big a burden if we require that the developer override or unset every class in aVariant
. This is why we offer thecustom
variant to zero all of them out, when needed. - It is more likely that a developer will need a custom colored button we couldn’t predict than one which breaks with the font size and padding we’ve defined in the design.
You might decide you want a custom
size with empty string, and that’s valid. In my experience, I’ve never encountered a situation where that was needed, but I regularly encounter situations where we’ve needed one-off custom buttons here and there.
Step 4 - Icon
Sometimes, a button needs an icon. While we could allow the developer to pass it in via children
, it will be more convenient with props, and we can apply the spacing and sizing of our design system for consistency.
For this example, we are using FontAwesome icons, but the same ideas apply for any icons you are using.
Let’s review what we’ve added.
- The
icon
prop accepts the FontAwesomeIconProp
type. This makes it easy for the developer to pass theicon
prop the same as if they were rendering their own<FontAwesomeIcon>
. - When the button has an icon, the horizontal padding is slightly different. We’re leveraging the
twMerge
override functionality for thepx
value. - When the button has icon and text, we no longer want to use
text-center
so we override it withtext-left
when there is an icon, and we use a flex box to vertically center the icon and text with agap-2
between them. - We use
justify-around
so that if a developer styles the button wider than its content, the icon and text remain horizontally centered. - We also added support for showing the icon on the left or right of the text with an
iconPosition
prop. We can useflex-row-reverse
to render the icon on the right. - Using a discriminated union (日本語), we can take advantage of TypeScript Narrowing (日本語) to enforce that a Button requires children and/or an icon, and if it only has an icon or doesn’t have an icon at all, then the
iconPosition
prop is not allowed. - Technically speaking, passing
iconPosition
when there isn’t anicon
would not do anything, sinceiconPosition
is only used if there is an icon. But, this could result in cruft in the future if a developer removes theicon
prop and noticonPosition
. By enforcing this now, we’re preventing potential technical debt, which is another form of convenience!
Step 5 - Loading state and disabled
Buttons are often used to trigger asynchronous events. Let’s add a loading state and visual clarity when a button is disabled.
Our Button now takes an optional isLoading
boolean prop, and the native disabled
prop is now explicitly handled instead of being spread in {...props}
.
- When the Button is loading, we disable the button and use
cursor-wait
. - When the Button is disabled and not loading, we make it semi-transparent and use
cursor-not-allowed
. - The
<Spinner>
component renders a spinning circle. The important thing is that we still render the icon and/or text invisibly so that the Button maintains the same dimensions when it switches to a loading state. Note: we use<span>
not<div>
because putting a<div>
inside of<button>
is technically not valid. - We need to center the
<Spinner>
in the<button>
using absolute positioning so it doesn’t alter the dimensions. We wrap everything inside a relatively positioned block and center the<Spinner>
inside a flex box withinset-0
to make it the width and height of the button.
Key Takeaways
Our Button now has a lot of convenience for any developer on our team to use in almost any situation. It’s still a <button>
under the hood, and if a developer needs any other native features, or needs to override any classes, they can.
From a code maintenance perspective, we could decide to move the types and/or constants into a separate colocated file and import them into the Button component, or leave it as-is.
You can add more sizes, more variants, rounded corners, hover colors, dark mode, etc. It’s really up to your needs. The foundation we’ve built makes it easy to maintain and add new convenience without compromising expectations of how a button works.
We accomplished this through native types, escape hatches, and some carefully considered limitations.