Skip to main
the props are screaming at
Timo Mämecke
Jump to navigation
· 12 minute read

A Better Button Component with Composition

Do I really need to write an introduction to button components? We all know them. Buttons are one of the most commonly used components in our user interfaces, and are also some of the messiest components, with crappy interfaces and complex implementations for something as simple as a freaking button, and they only get worse as your codebase ages.

In this post, I’ll briefly explain why button components are a classic problem in software engineering, how composition can solve this problem, and how I implement complex buttons with simple code these days.

What’s our problem with buttons?

Buttons seem so simple at first, but we need a lot from them: Primary buttons, secondary buttons, red buttons for destructive actions, bordered buttons, transparent buttons. In forms we need them to be an actual HTML <button> element, sometimes as type="submit" and sometimes as type="button". Sometimes they aren’t buttons, they are links and should be an <a> element, unless it’s client-side routing where we need to use a special <Link> component. Buttons come in different sizes. They should support icons on the left, icons on the right, or only an icon and no text—but then of course with a tooltip! They can be disabled, but sometimes they can look disabled even though they are not actually disabled. They can have a loading state, so it also needs to support that A͟N͟D͟ T͟H͟E͟N S͟O͟M͟E͟T͟I͟M͟E͟S T̷H̴E̶ ̶B̸U̶T̶T̴O̵N̸S̶ 𝙏𝙃𝙀𝙔 𝘼𝙍𝙀 𝘽𝙐𝙏𝙏𝙊𝙉𝙎 𝘛𝙃𝘼𝙏 𝘼𝙍𝙀𝙉’𝙏¿!? ✖︎ B̷U̸T̶T̷O̴N̸S̶ — THEY REFUSE to exist ╰┈➤ t͞h̸e͟y͞ ͞m͡e͜l͜t͡ ͟i͠n͜t̛o̕ ̴t̕h̨e͝ ͘c̛o͞d͡e͢, THEY TALK <Button prop="kill_me" /> THE PROPS ARE SCREAMING AT ME > Warning: prop 'scream' is not recognized on the DOM element. Did you mean 'screm'? onClick={() => END_MY_SUFFERING()} ⠀⛓ ᴀɴᴅ ʏᴇᴛ ɪ sᴛɪʟʟ ʜᴀᴠᴇ ᴛᴏ ᴅᴇᴀʟ ᴡɪᴛʜ as="a"

It’s a lot.

Buttons have to address many concerns. And our solution is really to try to squeeze all of that into a single component? Really?

You might say: “hang on, we don’t always use a single component!” And you’re right—sometimes you’ll see separate components, such as an <IconButton>. However, I’d argue that this often makes things worse. Because now you have to implement many of those complex concerns twice. While you remove a single concern from your big bad button component (for example, the concern that it only renders an icon and no text), your new special <IconButton> still looks like a button, can be disabled and hovered like a button, and needs to share most of the same styles. You’ll also need to implement the polymorphism twice to render different clickable HTML elements.

This problem sounds familiar! It seems like we’re dealing with separation of concerns, and simply refactoring it into an <IconButton> is the wrong separation. We don’t usually solve separation of concerns like this—we solve it with layering and composition!

The button component is nothing more than a classic software engineering problem that we usually solve with composition… yet I’ve rarely seen it solved with good composition!

Moving the clickable element out of the button

Forget the idea of using a single button component. From now on, we’ll think of every button in our UI as multiple layered components.

The base layer of every button is an unstyled, native interactive element, such as a button element or an anchor, in which we’ll nest a component that looks like a button:

<button>
  <PrimaryButton>Submit form</PrimaryButton>
</button>

<a href="/pricing">
  <PrimaryButton>View our pricing</PrimaryButton>
</a>

function PrimaryButton(props) {
  return (
    <div className="p-4 bg-brand border border-brand-500 rounded">
      {props.children}
    </div>
  )
}
jsx
Fig. 1: Basic composition by moving the clickable element out of the button component.

We immediately and completely remove polymorphism from our button components. That’s already a huge improvement. Polymorphic components are a big pain in the ass, and I try to avoid them wherever possible.

Note that we won’t encapsulate our layered button into a single button component again. This would just result in a messy polymorphic component with lots of props and complexity. We will always call all layers as we need them.

We can now achieve a lot with pure CSS. Adding hover and disabled states is straightforward and elegant:

<button disabled>
  <PrimaryButton>Submit form</PrimaryButton>
</button>

function PrimaryButton(props) {
  return (
    <div
      className="
        p-4 bg-brand border border-brand-500 rounded
        not-in-disabled:hover:bg-brand-600
        in-disabled:bg-brand-300 in-disabled:text-gray
      "
    >
      {props.children}
    </div>
  )
}
jsx
Fig. 2: Styling button states based on the parent element's state.

It’s great that we can solve this simply by adding CSS, without having to change any existing code. There’s no need to concatenate classes dynamically, nor did we need to add an attribute to our button.

However, if necessary, we can also add a disabled attribute to our button component. This would be useful if we needed a button that looks disabled but isn’t.

<button>
  <PrimaryButton disabled>Submit form</PrimaryButton>
</button>

function PrimaryButton(props) {
  return (
    <div
      data-disabled={props.disabled}
      className="
        p-4 bg-brand border border-brand-500 rounded
        not-data-disabled:hover:bg-brand-600
        data-disabled:bg-brand-300 data-disabled:text-gray
      "
    >
      {props.children}
    </div>
  )
}
jsx
Fig. 3: Styling button states explicitly with attributes.

I’ve seen some really messy solutions to this problem, such as two separate visuallyDisabled and disabled attributes, where it’s unclear in which combination they should be used. But with composition, we don’t need to worry about that! We can immediately see from the way it’s used that we have a clickable button that isn’t disabled, and it has a visual child that looks disabled.

I cannot stress enough how powerful this is. We’re eliminating ambiguity and complexity while writing code that documents itself.

Isn’t this against DRY?

I’ve heard engineers in the past who argued that this approach is bad because you end up repeating yourself more. For example, writing <button><Button /></button> many times. It’s often seen as a violation of the “Don’t repeat yourself” (DRY) principle.

But DRY doesn’t mean that you’re never allowed to repeat yourself. It’s not about avoiding repetition at any cost. It’s about avoiding duplication of logic and knowledge.

In our case, the repetition is fine. It’s markup. The alternative, like <Button as="a" href="/pricing">, is the same repetition in a worse syntax.

Adding more layers on top

Now that our base is in place, we can start adding individual components for each concern and layer them into our button.

For example, let’s say we want to add a loading state to our buttons. This means:

  • The button should appear disabled.
  • The button itself should be disabled so that users cannot re-submit a loading form.
  • The button’s content should be replaced with a loading state.

Here’s how we can implement this by separating each concern into its own layer:

<button disabled>
  <PrimaryButton disabled>
    <LoadingLabel label="Saving…" active={isSubmitting}>
      Save
    </LoadingLabel>
  </PrimaryButton>
</button>

function LoadingLabel(props) {
  if (props.active) {
    return (
      <>
        <SpinIcon />
        <span role="status" aria-busy>
          {props.label || 'Loading…'}
        </span>
      </>
    )
  }
  return <>{props.children}</>
}

function PrimaryButton(props) {
  return (
    <div
      data-disabled={props.disabled}
      className="
        p-4 bg-brand border border-brand-500 rounded
        not-data-disabled:hover:bg-brand-600
        data-disabled:bg-brand-300 data-disabled:text-gray
      "
    >
      {props.children}
    </div>
  )
}
jsx
Fig. 4: Adding a loading state for buttons with composition.

With composition, we were able to solve it without a single change to the PrimaryButton. That’s a good thing! It shows that our composition is working well.

Another positive sign is that all of our attributes are in the right place. We added a label and active attribute, both of which are obvious in the context of the LoadingLabel component. This new component addresses one specific concern, and all its attributes are related to this concern.

To illustrate a poor design choice, let’s assume that we would add the loading state concern into the PrimaryButton. This would result in something like this:

<button disabled>
  <PrimaryButton
    disabled
    loadingLabel="Saving…"
    isLoading={isSubmitting}
  >
    Save
  </PrimaryButton>
</button>
jsx
Fig. 5: Anti-pattern for a loading state by overloading the button component with additional attributes.

This is a whack component interface! The attributes loadingLabel and isLoading are related to each other, but disabled is not. All attributes have to be optional because not every button has a loading state. However, setting loadingLabel makes no sense without isLoading. The relationship between the attributes is not immediately obvious. This interface requires documentation.

If you have a whack component interface like this and your attributes are a mess of unrelated concerns, it’s a clear sign that you need to separate these concerns into different layers.

Different button variants

Until now, we have only used a single PrimaryButton. In reality we often have different button variants, such as a SecondaryButton in a different color, or a transparent button, or a red button for destructive actions.

We could simply create an attribute for this, which would be an acceptable solution. But I’ll explain why we might not want this:

<button>
  <Button variant="primary">
    Submit
  </Button>
</button>

<button type="button">
  <Button variant="secondary">
    Reset
  </Button>
</button>
jsx
Fig. 6: Button variants with attributes.

According to what I wrote in the previous section, an attribute is sufficient for the variant. We don’t need to add another layer. It still only controls the visual appearance of the button’s base.

But it’s not the implementation that I would choose. I think it’s fine to have separate components for each variant, all within the same file:

function PrimaryButton(props) {
  return (
    <div
      className="
        p-4 bg-brand border border-brand-500 rounded
        not-in-disabled:hover:bg-brand-600
        in-disabled:bg-brand-300 in-disabled:text-gray
      "
    >
      {props.children}
    </div>
  )
}

function SecondaryButton(props) {
  return (
    <div
      className="
        p-4 bg-gray border border-gray-500 rounded
        not-in-disabled:hover:bg-gray-600
        in-disabled:bg-gray-300 in-disabled:text-gray
      "
    >
      {props.children}
    </div>
  )
}

function TransparentButton(props) {
  return (
    <div
      className="
        p-4 bg-transparent border border-transparent rounded
        not-in-disabled:hover:bg-gray-100
        in-disabled:bg-gray-200 in-disabled:text-gray
      "
    >
      {props.children}
    </div>
  )
}
jsx
Fig. 7: Button variants with individual components.

Although we’re repeating some styles multiple times, the repeated code is close together, so I don’t consider it a big problem.

Even with different button sizes, I’d still prefer a <PrimaryButton size="sm"> over a <Button variant="primary" size="sm">.

There are two main reasons why I prefer separate components:

  1. Keep your design in mind before implementing a combination of all possible variants and sizes.
    Some button variants only come in specific sizes. For example, the secondary button may always be medium and never large. One benefit of keeping the variants in separate components is that you don’t have to support all possible combinations of variants and sizes.
  2. It discourages contributors from changing a generic button component for “one-off buttons”.
    Instead, it encourages your peers and contributors to create separate components for new buttons that look slightly different. There’s a high chance that it’s a one-off button. You might ask: “what’s a one-off button?”

Make one-off buttons not reusable

Not all components in a design will be reused. For instance, we might use a huge button with a shiny effect on the landing page that is never used again.

There’s often the tendency to change the button component and add attributes like size="xxl" and variant="shiny" to it, even though it’s only used once in this exact combination. While it may seem like a small, quick change and we can reuse the existing button, we then make the decision to carry this burden around. Maintaining the button will be more difficult because we’ll always have to check that our special styling won’t break. At some point, the huge shiny button on the landing page is removed, but nobody removed the styles from the reusable button component.

If you encounter a button style that you haven’t seen before and it’s only used in one place, I recommend writing it inline instead of extending the existing button component. This tells all contributors that it is not meant to be reusable and only supports exactly what was needed.

<a href="/signup">
  <div className="p-8 bg-brand effect-shine text-xxl rounded">
    Sign up
  </div>
</a>
jsx
Fig. 8: Implementing a one-off button inline. You don't always need to use a button component.

We don’t need to think about the styles when the special button is disabled because it cannot be disabled. And when it’s removed, no remnants are left behind.

Example: icons in buttons

To wrap up some patterns that I use more often, you can add support for icons with just some CSS. This works well for left icons, right icons, and only icon buttons without text:

function PrimaryButton(props) {
  return (
    <div
      className="
        p-4 bg-brand border border-brand-500 rounded
        not-in-disabled:hover:bg-brand-600
        in-disabled:bg-brand-300 in-disabled:text-gray

        inline-flex flex-wrap
        gap-1.5 items-center justify-center
        *:[svg]:size-4 *:shrink-0
      "
    >
      {props.children}
    </div>
  )
}

<button>
  <PrimaryButton>
    <FloppyDiskIcon />
    Save
  </PrimaryButton>
</button>

<a href="/page/2">
  <PrimaryButton>
    Next page
    <ArrowRightIcon />
  </PrimaryButton>
</a>

<Tooltip label="Edit">
  <button disabled aria-label="Edit">
    <PrimaryButton>
      <PencilIcon />
    </PrimaryButton>
  </button>
</Tooltip>
jsx
Fig. 9: Simple support for icons in buttons.
  • Icons can be added as simple children.
  • The icons are automatically sized.
  • It adds space between icons and text.
  • It ensures that the button wraps nicely.
  • We use composition for the tooltip instead of adding a tooltip into our button.

For more advanced cases—when you feel the need to add an attribute to the PrimaryButton to control the icons—keep in mind that it’s better to separate the concerns and use composition. Don’t overload the button component with weird attributes again. A separate component that only handles the icon layout will be easier to maintain, and we can use it as an overlay on the base button.

<button>
  <PrimaryButton>
    <IconLabel start={<FloppyDiskIcon />}>
      Save
    </IconLabel>
  </PrimaryButton>
</button>
jsx
Fig. 10: Using composition for icons in buttons.

Bonus: Focus rings

Sadly, focus styles are often overlooked and lack sufficient emphasis. Often, they simply mirror the hover styles, but hover and focus are two very different things. A hover style alone isn’t enough to catch your eye when navigating with a keyboard.

Focus rings are an effective solution because they make the focus state highly visible. And because we separated the button into multiple layers, adding focus rings for all elements is quick and easy:

:focus-visible {
  outline-color: var(--color-brand-300);
  outline-offset: 4px;
  outline-width: 2px;
  border-radius: 2px;
}
css
Fig. 11: Global styles for focus rings of clickable elements.

The focus ring is now a concern of the native button- and anchor element, so we can style it independently of the button component. It does not affect the button component at all, and the focus rings will always be present and consistent.

Of course, you could still define a focus style in the button component. But you don’t need to. It works great without it.

Tip: avoid className as attribute

Adding a className attribute to a component is one of the easiest ways to shoot yourself in the foot. It opens up the component too much, and quickly becomes hard to control. It invites contributors (and even future-you!) to mess with the internal styles from the outside, making it harder to change anything without breaking stuff.

Just don’t.

Reflection

So, here we are! What started as “I just wanna write a short post about this pattern I’m using for buttons” quickly turned into a long article.

We talked a lot about buttons because, as we all know, they’re often the worst offenders in a codebase. At this point, “the button component” has basically become a meme. But somewhere along the way, it feels like we’ve started to accept that these components are inherently messy.

I asked ChatGPT to “write me a button component in React that implements all the necessary features that I will need”, and it shat out this:

import React from "react";
import { cn } from "@/lib/utils";

export type ButtonProps = {
  asChild?: boolean;
  variant?: "default" | "destructive" | "outline" | "ghost" | "link";
  size?: "default" | "sm" | "lg" | "icon";
  loading?: boolean;
  disabled?: boolean;
  type?: "button" | "submit" | "reset";
  children?: React.ReactNode;
  className?: string;
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = "default",
      size = "default",
      loading = false,
      disabled = false,
      type = "button",
      className,
      children,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        type={type}
        disabled={disabled || loading}
        className={cn(
          "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
          {
            default: "bg-primary text-white hover:bg-primary/90",
            destructive: "bg-red-500 text-white hover:bg-red-600",
            outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
            ghost: "hover:bg-accent hover:text-accent-foreground",
            link: "underline-offset-4 hover:underline text-primary",
          }[variant],
          {
            default: "h-10 px-4 py-2",
            sm: "h-9 px-3",
            lg: "h-11 px-8",
            icon: "h-10 w-10",
          }[size],
          className
        )}
        {...props}
      >
        {loading ? <span className="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full" /> : null}
        {children}
      </button>
    );
  }
);
Button.displayName = "Button";

export { Button };
tsx
Fig. 12: A horrible button component written by ChatGPT, as it exists in many projects.

This is why the button component is a meme. It’s a brittle, hard-to-maintain mess. But it also looks like something I’ve seen a lot in codebases—and, honestly, it’s something I’ve contributed to in the past. It shouldn’t be like this.

While I only talked about buttons in this article, the same patterns can and should be used for other UI components as well. Buttons are just the worst offenders, but they are not the only ones. I now use the same layered UI composition patterns for many more things, and I believe it is some of the best frontend code I have ever written.