React Japan
Empathetic Coding - Button Component
Steven Sacks
Steven Sacks

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>.

import type {ButtonHTMLAttributes, FC} from 'react';
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
 
const Button: FC<ButtonProps> = (props) => (
  <button {...props} />
);
 
export default 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.

import type {ButtonHTMLAttributes} from 'react';
import {forwardRef} from 'react';
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => (
    <button ref={ref} {...props} />
  )
);
 
Button.displayName = 'Button';
 
export default Button;

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.

import type {ButtonHTMLAttributes} from 'react';
import {forwardRef} from 'react';
import {twMerge} from 'tailwind-merge';
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      type = 'button',
      ...props
    }, 
    ref
  ) => (
    <button
      ref={ref}
      className={twMerge(
        'select-none text-center whitespace-nowrap',
        className
      )}
      type={type}
      {...props}
    >
      {children}
    </button>
  )
);
 
Button.displayName = 'Button';
 
export default Button;

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.

import type {ButtonHTMLAttributes} from 'react';
import {forwardRef} from 'react';
import {twMerge} from 'tailwind-merge';
  
type Size = 'base' | 'lg' | 'sm' | 'xl' | 'xs';
 
const SIZES: Record<Size, string> = {
  base: 'px-3 py-2 text-base',
  lg: 'px-4 py-2 text-lg',
  sm: 'px-3 py-1.5 text-sm',
  xl: 'px-4 py-2.5 text-xl',
  xs: 'px-1.5 py-1 text-xs',
};
 
type Variant =
  | 'custom'
  | 'primary'
  | 'secondary'
  | 'tertiary';
  
const VARIANTS: Record<Variant, string> = {
  custom: '',
  primary:
    'border border-blue-400 bg-blue-500 text-white',
  secondary:
    'border border-blue-500 bg-white text-blue-500',
  tertiary:
    'border border-grey-600 bg-grey-500 text-white',
};
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  size?: Size;
  variant?: Variant;
}
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      size = 'base',
      type = 'button',
      variant = 'primary',
      ...props
    }, 
    ref
  ) => (
    <button
      ref={ref}
      className={twMerge(
        'select-none whitespace-nowrap text-center',
        VARIANTS[variant],
        SIZES[size],
        className
      )}
      type={type}
      {...props}
    >
      {children}
    </button>
  )
);
 
Button.displayName = 'Button';
 
export default Button;

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. The twMerge 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 a Variant. This is why we offer the custom 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.

import type {ButtonHTMLAttributes, ReactNode} from 'react';
import {forwardRef} from 'react';
import type {IconProp} from '@fortawesome/fontawesome-svg-core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {twJoin, twMerge} from 'tailwind-merge';
 
type Size = 'base' | 'lg' | 'sm' | 'xl' | 'xs';
 
const SIZES: Record<Size, string> = {
  base: 'px-3 py-2 text-base',
  lg: 'px-4 py-2 text-lg',
  sm: 'px-3 py-1.5 text-sm',
  xl: 'px-4 py-2.5 text-xl',
  xs: 'px-1.5 py-1 text-xs',
};
 
export const ICON_SIZES: Record<Size, string> = {
  base: 'px-2.5',
  lg: 'px-2.5',
  sm: 'px-2',
  xl: 'px-2.5',
  xs: 'px-1.5',
};
 
export const ICON_POSITION: Record<'left' | 'right', string> = {
  left: '',
  right: 'flex-row-reverse',
};
 
type Variant =
  | 'custom'
  | 'primary'
  | 'secondary'
  | 'tertiary';
  
const VARIANTS: Record<Variant, string> = {
  custom: '',
  primary:
    'border border-blue-400 bg-blue-500 text-white',
  secondary:
    'border border-blue-500 bg-white text-blue-500',
  tertiary:
    'border border-grey-600 bg-grey-500 text-white',
};
 
type MaybeIcon = {
  children: ReactNode;
  icon?: IconProp;
  iconPosition?: 'left' | 'right';
};
 
type OnlyIcon = {
  children?: never;
  icon: IconProp;
  iconPosition?: never;
};
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  size?: Size;
  variant?: Variant;
} & (MaybeIcon | OnlyIcon);
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      icon,
      iconPosition = 'left',
      size = 'base',
      type = 'button',
      variant = 'primary',
      ...props
    },
    ref
  ) => {
    const iconComponent =
      icon ?
        <FontAwesomeIcon
          className={twJoin(children && 'flex-none')}
          fixedWidth={true}
          icon={icon}
          size="1x"
        />
      : null;
 
    return (
      <button
        ref={ref}
        className={twMerge(
         'select-none whitespace-nowrap text-center',
          VARIANTS[variant],
          SIZES[size],
          icon &&
            (children ?
              `flex justify-around items-center gap-2 text-left ${ICON_SIZES[size]} ${ICON_POSITION[iconPosition]}`
            : ICON_SIZES[size]),
          className
        )}
        type={type}
        {...props}
      >
        {iconComponent}
        {children}
      </button>
    );
  }
);
 
Button.displayName = 'Button';
 
export default Button;

Let’s review what we’ve added.

  • The icon prop accepts the FontAwesome IconProp type. This makes it easy for the developer to pass the icon 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 twMergeoverride functionality for the px value.
  • When the button has icon and text, we no longer want to use text-center so we override it with text-left when there is an icon, and we use a flex box to vertically center the icon and text with a gap-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 use flex-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 an icon would not do anything, since iconPosition is only used if there is an icon. But, this could result in cruft in the future if a developer removes the icon prop and not iconPosition. 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.

import type {ButtonHTMLAttributes, ReactNode} from 'react';
import {forwardRef} from 'react';
import type {IconProp} from '@fortawesome/fontawesome-svg-core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {twJoin, twMerge} from 'tailwind-merge';
import Spinner from '~/components/Spinner';
 
type Size = 'base' | 'lg' | 'sm' | 'xl' | 'xs';
 
const SIZES: Record<Size, string> = {
  base: 'px-3 py-2 text-base',
  lg: 'px-4 py-2 text-lg',
  sm: 'px-3 py-1.5 text-sm',
  xl: 'px-4 py-2.5 text-xl',
  xs: 'px-1.5 py-1 text-xs',
};
 
export const ICON_SIZES: Record<Size, string> = {
  base: 'px-2.5',
  lg: 'px-2.5',
  sm: 'px-2',
  xl: 'px-2.5',
  xs: 'px-1.5',
};
 
const ICON_POSITION: Record<'left' | 'right', string> = {
  left: '',
  right: 'flex-row-reverse',
};
 
type Variant =
  | 'custom'
  | 'primary'
  | 'secondary'
  | 'tertiary';
  
const VARIANTS: Record<Variant, string> = {
  custom: '',
  primary:
    'border border-blue-400 bg-blue-500 text-white',
  secondary:
    'border border-blue-500 bg-white text-blue-500',
  tertiary:
    'border border-grey-600 bg-grey-500 text-white',
};
 
type MaybeIcon = {
  children: ReactNode;
  icon?: IconProp;
  iconPosition?: 'left' | 'right';
};
 
type OnlyIcon = {
  children?: never;
  icon: IconProp;
  iconPosition?: never;
};
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  isLoading?: boolean;
  size?: Size;
  variant?: Variant;
} & (MaybeIcon | OnlyIcon);
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      disabled,
      icon,
      iconPosition = 'left',
      isLoading,
      size = 'base',
      type = 'button',
      variant = 'primary',
      ...props
    },
    ref
  ) => {
    const iconComponent =
      icon ?
        <FontAwesomeIcon
          className={twJoin(children && 'flex-none')}
          fixedWidth={true}
          icon={icon}
          size="1x"
        />
      : null;
 
    return (
      <button
        ref={ref}
        className={twMerge(
         'select-none whitespace-nowrap text-center',
          VARIANTS[variant],
          SIZES[size],
          icon &&
            (children ?
              `flex justify-around items-center gap-2 text-left ${ICON_SIZES[size]} ${ICON_POSITION[iconPosition]}`
            : ICON_SIZES[size]),
          isLoading ?
            'cursor-wait' :
            'disabled:cursor-not-allowed disabled:opacity-50',
          className
        )}
        disabled={disabled || isLoading}
        type={type}
        {...props}
      >
       {isLoading ?
        <span className="relative block">
          <span className="invisible block">
            {iconComponent}
            {children}
          </span>
          <span className="absolute inset-0 flex items-center justify-center">
            <Spinner />
          </span>
        </span>
      : <>
          {iconComponent}
          {children}
        </>
      }
      </button>
    );
  }
);
 
Button.displayName = 'Button';
 
export default Button;

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 with inset-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.