Joonas' blog

Opinionated UI: <Button> in React

published

Buttons are a core part of any design system and thus warrant some design consideration to make them both straightforward to use and flexible enough to fit the most common corner cases. In this article I list some of the learnings I’ve gained over years of building button components.

This is the base Button component over which the improvements in this article are built on top of:

export function Button(props: React.ComponentProps<"button">) {
return (
<button {...props}>
{props.children}
</button>
);
}

This article further assumes that the buttons are unstyled, which is the default for e.g. Tailwind.

Use sane defaults for the design

Placing your Button component on a page without any additional props or parameters should result in a button that fits in the design and is easily recognized as a button.

A recognizable button tends to follow these design characteristics:

  • Horizontal padding is twice the vertical padding (this looks better than this)
  • High contrast colors are reserved important Buttons. Limit the number of buttons with eye-catching colors

(credits: https://anthonyhobday.com/sideprojects/saferules/)

Default button type to “button”

Using the standard HTML as the underlying DOM element is a good choice for semantic reasons. However, there is one default that might trip you up: type of <button> element is “submit” by default, which causes default buttons placed within forms to submit the form itself.

This is undesirable behavior for a generic Button component, which is why type="button" should be added.

export function Button(props: React.ComponentProps<"button">) {
return (
<button type="button" {...props}>
{props.children}
</button>
);
}

We still support overriding the type property by setting type="button" before passing all the other props to the underlying button.

Semantically, Buttons are often used as replacements for regular <a> HTML links. We should support this behavior by detecting if a href prop has been passed to the Button component and use <a> as the underlying HTML element if so.

export type ButtonProps =
| ({ href: string } & React.ComponentProps<"a">)
| ({} & React.ComponentProps<"button">);
export function Button(props: ButtonProps) {
if ("href" in props) {
return (
<a {...props}>
{props.children}
</a>
);
} else {
return (
<button type="button" {...props}>
{props.children}
</button>
);
}
}

Example:

<Button
href="https://google.com"
>
Search up!
</Button>

turns into

<a
href="https://google.com"
>
Search up!
</a>

This seamlessly adds support for links in Next.js, SvelteKit, and probably most other frameworks as well.

Generic variants instead of hard-coded

As mentioned above, Buttons should have good and functional designs by default. We can aid this with preset variants passed to Button directly.

The default Button variant when nothing else is specified should be neutrally color coded. This means that on a regular black text on white background-type website, buttons would look something like this.

Explicitly specified variants are more visually standing. Common variants I’ve used and found useful are primary, success, warning, danger, link.

Allow style overrides if needed

In most cases variants are enough to stylize Buttons to different contexts. However, an escape hatch is always useful since there will always be exceptions.

Some exceptions I’ve bumped into are splitbuttons or buttons with icons

When using Tailwind, tailwind-merge is an extremely useful library. Specifying default styles while still allowing overrides is then as simple as <button className={twMerge(DEFAULT_CLASSES, props.className)}>