Better Buttons with color-mix() and Custom Properties

Let's build a button that accepts one color and calculates its hover and focus states automatically. For this experiment, we'll use CSS Custom Properties, color-mix(), and OKLCH to ensure that tints and shades are perceptually uniform.

To keep things simple, we'll follow today's flat design trend and use a single color for the background. We'll store that color in a custom property so we can reuse it and change things up as we go.

Let's kick things off with some styles.

/* Set some defaults for the button */
:root {
  --button-bg: slategrey;
}

/* Remove factory button styles and add our own */
button {
  unset: all;
  font: 16px sans-serif;
  border: none;
  border-radius: 6px;
  background-color: var(--button-bg);
  color: white;
  padding: .875rem 1.125rem;
  margin: 0;
  cursor: pointer;
  transition: 100ms background-color;
}

/* Tint the background when hovering */
button:hover {
  background-color: color-mix(in oklch, var(--button-bg) 100%, white 8%);
}

/* Shade the background when clicked */
button:active {
  background-color: color-mix(in oklch, var(--button-bg) 100%, black 4%);
}

/* Remove the default focus ring */
button:focus {
  outline: none;
}

/* Provide a color-matched focus ring for keyboard users */
button:focus-visible {
  outline: solid 3px var(--button-bg);
  outline-offset: 1px;
}

We're using a custom property called --button-bg that sets the button's background. Using a custom property allows us to pass it into the color-mix() function to calculate the hover and active states. This also allows us to use it again to make a color-matched outline for the focus state.

The magic is here:

/* Tint the background when hovering */
button:hover {
  background-color: color-mix(in oklch, var(--button-bg) 100%, white 8%);
}

/* Shade the background when clicked */
button:active {
  background-color: color-mix(in oklch, var(--button-bg) 100%, black 4%);
}

Using color-mix(), we can adjust the tint/shade based on the background color, meaning we don't need to manually select lighter/darker colors for those states. And because we're using OKLCH, the variations will be perceptually uniform, unlike HSL. This means that tints and shades will look consistent for any color we choose!

Oh, and because we're using a custom property, we can easily test that theory by setting --button-bg to any color. Note how the hover and focus states update accordingly in this example.*

The caveat is browser support for color-mix(), which is green across the board, but still somewhat recent. However, I would argue that this is a great candidate for progressive enhancement, as the absence of color changes on hover and click doesn't affect the function of the button.

Not bad for a few lines of CSS!


*Alas, we can't automatically ensure the label's contrast based on the background color, but when CSS Color Module Level 5 lands, we'll get relative colors that will allow us to do just that!