Front-end education for the real world. Since 2018.





How I build a button component

Andy Bell

Topic: CSS

A button is arguably the most likely component to find itself in your codebase so I’m going to show you how I approach building one. The hope is it demystifies the humble button and encourages folks who reach for a <div> and a JavaScript handler to use semantic elements.

What we’re buildingpermalink

A cluster of 7 button elements. The default is off-black with round corners, followed by a yellow button with off-black border, red, green, a button with only border and transparent background, a button with hard edges and finally, a button with an icon

We’ve got a pretty standard button with three variants, a “ghost” version (outline only) and a version with hard edges. The icon version doesn’t count as a variant because we’re building the button to support icons as standard.

Just with that collection, there’s quite a lot of variety in colour treatment especially, so a lot of our focus is how to keep that manageable in the long term.

HTML first, alwayspermalink

Let’s first take a look at the HTML.

Code language
html

<!-- Actual button -->
<button class="button">A button</button>

<-- Link button -->
<a href="/" class="button">A button</a>


Hopefully I’ve answered the inevitable “why not style the HTML <button> element?” question with both <button> and <a> elements being represented. There’s also an argument to not use buttons for links — mainly because <button> elements can be activated with the space key and links can’t. Button links are an extremely common user interface pattern so today, I’m in the business of presuming you’re gonna use them so I can at least show you how to do it well from a CSS perspective.

In terms of deciding when to use a <button> or an <a>, here’s my rule of thumb:

  • Does it trigger a page change? <a>
  • Does it trigger something interactive? <button>

For the icon version, the HTML markup looks like this:

Code language
html

<a href="#" class="button">
  <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="<http://www.w3.org/2000/svg>">
    <!-- SVG innards redacted because life is too short -->
  </svg>
  Button with icon
</a>

The main thing to alert you to here is the aria-hidden="true" attribute. In almost every case, an icon within a button is decorative, so it’s better to hide it from screen readers. Hiding it from screen readers allows them to focus on announcing whether it is a button or a link and what the label is too, which results in a better overall experience.

The width and height attributes will be ignored when we apply icon sizes with CSS, but they’re useful for if the CSS doesn’t load. Without them, the SVG will fill the width of its container, which with no CSS available, will be the whole viewport.

Base component CSSpermalink

The aim of the game with this component is to make it maintainable and a good way to do that is to make it configurable. What I mean by that is instead of setting properties like background and color in each variant, we should instead power those properties with variables — also known as custom properties.

Let’s make a start on our component CSS and I’ll explain as we go.

Code language
css

.button {

  /* Configuration */
  --button-padding: 0.7em 1.2em;
  --button-gap: 0.5em;
  --button-bg: #342a21;
  --button-color: #ffffff;
  --button-hover-bg: #4b4b4a;
  --button-hover-color: #ffffff;
  --button-border-width: 3px;
  --button-border-style: solid;
  --button-border-color: var(--button-bg);
  --button-radius: 0.5em;

  /* Layout and spacing */
  display: inline-flex;
  align-items: center;
  gap: var(--button-gap);
  padding: var(--button-padding);

  /* Colours */
  background: var(--button-bg);
  color: var(--button-color);

  /* Stroke and radius */
  border-width: var(--button-border-width);
  border-style: var(--button-border-style);
  border-color: var(--button-border-color);
  border-radius: var(--button-radius);

  /* Typography */
  text-decoration: none;
  font-weight: var(--button-font-weight, 700);
  font-size: var(--button-font-size, 1em);
  letter-spacing: 0.05ch;
  font-family: sans-serif;
  line-height: 1.1;

  /* Interactive */
  cursor: pointer;
}

There’s always a wall of CSS when you’re making buttons, so to make things easier to digest, I’ve broken the CSS properties and values into nice logical groups. Let’s break them down.

Configuration

Code language
css

--button-padding: 0.7em 1.2em;
--button-gap: 0.5em;
--button-bg: #342a21;
--button-color: #ffffff;
--button-hover-bg: #4b4b4a;
--button-hover-color: #ffffff; 
--button-border-width: 3px;
--button-border-style: solid;
--button-border-color: var(--button-bg);
--button-radius: 0.5em;

Quite a lot of these are self explained colours. I just want to highlight that I recommend setting a hover colour (--button-hover-color) explicitly because you absolutely want to make sure your text has sufficient contrast with the background, regardless of variant.

You’ll spot later in the article that for some properties, I’m looking for a custom property and setting a fallback value like this:

Code language
css

padding: var(--button-padding, 0.7em 1.2em);

You might be wondering what the decision process is in deciding whether to add a custom property in the big ol’ block of them at the start of a component or to do the above. There’s no absolute rule as I see it. I tend to add to the big ol’ block if there’s a 100% chance that the variable is going to change per variant because surfacing them up there makes it easier to see what is configurable for my colleagues.

Layout and spacing

Code language
css

display: inline-flex;
align-items: center;
gap: var(--button-gap);
padding: var(--button-padding);

Next up, I like to group layout and spacing together. For a button, I recommend setting display to inline-flex or inline-block. If you are 100% sure there will be no icons, then opt for inline-block. Both of these options give you characteristics of a block-level element but importantly, will still flow inline with text.

If you were to use block or flex as the value for display, the button would automatically fill the horizontal (inline) width, which is not ideal. You could control the width with the following if you absolutely want to do that:

Code language
css

.button {
  max-width: max-content;
}

I don’t personally see a reason to do that, so I opt for the inline- prefixed options because I know compositional layouts will control the rest for me.

Stroke and radius

Code language
css

border-width: var(--button-border-width);
border-style: var(--button-border-style);
border-color: var(--button-border-color);
border-radius: var(--button-radius);

You indeed read right — I skipped colours. They’re super straightforward representations of the configuration options we set earlier. The same can be said for borders and radius, but there’s a couple of bits I wanted to cover with those.

Firstly, I break border into individual properties so each part can be configurable. You can use the border shorthand with multiple custom properties, as Germán shows, but I do prefer breaking up shorthand properties if they’re using lots of custom properties for ease of reading.

Second, I recommend always setting a border value because if you use a <button> element, it’ll have the user agent stylesheet version. I think this alone is why a lot of JavaScript orientated folks like to use <div> elements (don’t do that). I can’t think of another reason why someone wold commit such a crime 😅

Anyway, another reason to define a border, even for an element that has a solid background is that if like our context, you’ve got a border-only button (known as “ghost” buttons) and a solid button sat next to each other, they will be the same height. That won’t be the case without borders and it’ll look weird.

Typography

Code language
css

text-decoration: none;
font-weight: var(--button-font-weight, 700);
font-size: var(--button-font-size, 1em);
letter-spacing: 0.05ch;
font-family: sans-serif;
line-height: 1.1; 

There’s a couple of examples of looking for a custom property and falling back to a default value here. It’s unlikely that font-weight and font-size are going to be configured in a variant, but it’s useful to present that option for in-context configuration in another component, where .button is a child:

Code language
css

.my-component {
  --button-font-size: 2em;
}

One thing I do want to point out is line-height. If you don’t set this, <a> buttons and <button> buttons will be different heights because they usually have different line heights from user agent styles. I chose 1.1 because text won’t overlap with our chosen font if it wraps on to multiple lines.

A thing you might think I’m being overly specific about is the font-family. Even though our global styles for the body define the font we want to use (sans-serif), it’s still a good idea to define it in our .button styles. This is for if the button finds itself in a parent that defines a different font.

Interactive

Code language
css

cursor: pointer;

I imagine I’ll be yelled at for this, but I set cursor: pointer because I like a consistent user experience. There probably will forever be a debate that <button> elements should not use a pointer cursor, but I firmly side on yes, use pointer. Meet user expectations!

This is how it’s looking so far. A big cluster of the same button.

See the Pen Buttons - Core only by piccalilli (@piccalilli) on CodePen.

Sizing the iconpermalink

Time for a pro tip: use relative units like em units and lh units to size icons. It means as the text size increases or decreases, your icon will size relative to that change.

For our button, I’ve opted for the cap unit, which to simplify, is the height of the chosen font’s capital letters. Because buttons are often capitalised or even uppercase, that makes more sense than an ex unit (height of the lowercase “x” character), especially.

Code language
css

.button svg {
  height: var(--button-icon-size, 1.2cap);
  width: auto;
  flex: none;
}

By setting width as auto, we can maintain that nice square aspect ratio too or if it isn’t square, maintain relative proportions. I add flex: none to the SVG to stop it shrinking when the viewport/container is squashing the button.

See the Pen Buttons - icon styles by piccalilli (@piccalilli) on CodePen.

Interactive stylespermalink

Let’s target our hover, focus and active states, starting with hover:

Code language
css

.button:hover {
  background: var(--button-hover-bg);
  color: var(--button-hover-color);
}

Right now, all we need to apply the already existing custom properties from earlier. Job done.

Code language
css

.button:focus {
  outline-width: var(--button-outline-width, var(--button-border-width));
  outline-style: var(--button-outline-style, var(--button-border-style));
  outline-color: var(--button-outline-color, var(--button-border-color));
  outline-offset: var(
    --button-outline-offset,
    calc(var(--button-border-width) * 2)
  );
}

You must provide a focus style for interactive elements. There’s no excuse to remove outline, especially as browsers will honour your border radius now too. What I’m doing here is looking for specific outline custom properties then falling back to the border style. For the offset, I’m again looking for a specific custom property and falling back to a multiple of the border width, which gives us a nice consistent gap between border and outline.

One thing I’ll note is that you need to test your focus styles in various contexts and set --button-outline-color if there’s not enough contrast.

Code language
css

.button:active {
  transform: scale(99%);
}

When I say active, I mean the :active pseudo-class, rather than an .active/.is-active state. This is what I like to call the “pressed state”. It’ll only trigger when a user presses the button or clicks (without releasing).

Because of this, I like to make buttons a bit squidgy with a little 1% reduction in the element’s scale. It’s a nice touch which makes buttons feel like they’re actually being pressed.

See the Pen Buttons - interactive states by piccalilli (@piccalilli) on CodePen.

Variantspermalink

Right, everything gets easy from this point. Because our button is really configurable, our variants (primary/positive/negative) are a case of declaring some custom properties.

Code language
css

.button[data-button-variant="primary"] {
  --button-bg: #f3de8a;
  --button-color: #342a21;
  --button-border-color: currentColor;
  --button-hover-bg: #f1d979;
  --button-hover-color: #342a21;
}

.button[data-button-variant="positive"] {
  --button-bg: #2d936c;
  --button-border-color: #107350;
  --button-hover-bg: #359d75;
}

.button[data-button-variant="negative"] {
  --button-bg: #b33c48;
  --button-border-color: #a62f3d;
  --button-hover-bg: #c24a56;
}

If you’re wondering why I’m using data attributes, head over to the CUBE CSS Exception documentation. In short, I prefer to use data attributes to achieve a more finite state change than risk that my element has multiple, conflicting classes.

Creating variants in the long term will also be a case of declaring some colours. It also means if the core component needs changes made to it, you don’t have to update every variant.

See the Pen Buttons - variants by piccalilli (@piccalilli) on CodePen.

Ghost buttonspermalink

I don’t know why we call border-only buttons ghost buttons. If someone wants to wade through the internet to find out, go for it, but we’re here today to write some CSS.

Code language
css

.button[data-ghost-button] {
  --button-bg: transparent;
  --button-border-color: currentColor;
  --button-color: currentColor;
}

Again, just like with variants, all we need to do is define some custom properties. For ghost buttons, it’s a good idea to use the currentColor keyword because the button will inherit its parent’s colour then. If we specified them as the dark colour, they would disappear on dark backgrounds!

See the Pen Buttons - with ghost styles by piccalilli (@piccalilli) on CodePen.

Hard edgespermalink

Finally, let’s make a quick version of the button with no rounded corners:

Code language
css

.button[data-button-radius="hard"] {
  --button-radius: 0;
}

You guessed it: custom properties and job done!

Wrapping uppermalink

With all that done, here’s our collection of button component instances with their variants.

See the Pen Buttons by piccalilli (@piccalilli) on CodePen.

I’m hoping you took something away from this today. If you did, let us know! Most importantly, if you use <div> for buttons, I hope this guide shows you how simple it is to style actual button elements too.

Enjoyed this article? You can support us by leaving a tip via Open Collective


Newsletter