Désactiver le thème sombre

Article | Back to articles

UX Files - Coding our platoons of buttons

23/03/2021 by Benoit Rajalu

Abstract representation of a buttons being designed

This article is part of UX-Files, a series in which we give a stern but loving look at web interface patterns through three lenses: UX, UI and finally code. This time it's all about the button. Welcome to the final part: code.

We now have designs and documentation for buttons. We could just put our heads down and get to coding them but first, a question.

How do we code buttons? That's a silly question isn't it, you just create <button>{children}</button> thingies and that's a job well done.

It's not that simple. Whether we're working towards a design system or not, we are at least working in various scopes of component libraries at this point. That requires a little more thinking. We want to build highly reusable components, with clearly defined APIs and consideration put into their maintenance cost.

We're not isolated workers either. The design team produces components of their own, but those are not "the truth". They are models. The truth is what visitors use. As such, the design team has a need and a right to easily find that "truth". To make that ever easier, naming and equivalency are excellent allies. But when the design team use variants like those:

Three lines of buttons, each line representing a level of hierarchy and each column a visual state for the button

Things get a bit more complicated. For the design team, that's a ButtonAction Figma component with two types (Primary and Secondary), three levels (Neutral, Positive, Negative) and five interactive states all bundled up in variants.

It could be worse. In Figma it would have been just as easy to create one gigantic Button component with all the designs for all button "kinds" turned into variants. If we were to stick to the "naming and equivalency" rule, developpers could interpret it as a single <Button> component with a lot of props to achieve the same effect.

Instead of doing that, I've split the designs in large semantic patterns. I think it is a better way to document the design language, and thanks to that it becomes easier to translate designs as code patterns. As a dev, I would have preffered breaking the variant down further, having dinstinct ButtonActionPrimary and ButtonActionSecondary components for instance, but would that make sense for the design team? Do they need that overhead? It's a give and take.

That however brings us back to the question: how will we code these buttons as patterns?

Enforcing button basics

Let's first build our pattern like we would any other element that's going to have users: by ensuring its API makes sense.

Buttons in HTML are, like most HTML elements, wide open. They're of type="submit" by default, something that isn't really relevant to most of the patterns we are trying to build now. They accept classnames and the style attribute: both are threats to our app's design consistency.

We can enforce a more rigorous API by building a BaseButton whose entire responsibility is to offer a sanitized API. We'll start with enforcing better types:

tsx


export type BaseButtonProps =
  Omit<
    React.ButtonHTMLAttributes<HTMLButtonElement>, // classic <button> props
    | "style" // ...but no style override allowed
    | "className" // ...and no override through classes either
    | "type" // ..and only some types allowed, see below
    | "onClick" // HTMLButtonElement thinks that's optional, for us it's mandatory
  > & {
    type?: "button" | "submit" | "reset";
    onClick: () => void;
  };

We can then apply them to the <button> element.

tsx


import React, { forwardRef, Ref } from "react";

export const BaseButton = forwardRef(function BaseButton(
  {
    children,
    type = "button",
    ...rest
  }: BaseButtonProps, // Here's the typing being applied
  ref: Ref<HTMLButtonElement>
) {
  return (
    <button
      ref={ref}
      {...rest}
      type={type}
    >
      <span>{children}</span>
    </button>
  );
});

The styling conundrum

We now have a single reusable "better" HTML button. That however isn't the extent of what we want to pin down as developers. We also need all our buttons to come with styles! We know in advance that, as interactive elements, buttons will need styles for all their states:

  • Base: default button styles.
  • Hover: the pointer is currently over the button.
  • Active: the button is currently being pressed.
  • Focus: the focus is currently on the button.
  • Disabled: buttons can be disabled. This one's special, not all interactive elements can be disabled.

Our design team (me) has been generous enough (oh, you!) to design for these states but as developers we know that if we are given the chance to forget something, we will. Especially if we're working collectively on a public, expanding library.

To enforce best practice, we can provide opinionated "style builders".

In this case I went with Styled Components, providing a function with a typed interface demanding styles for each state:

tsx


import styled, {
  SimpleInterpolation
} from "styled-components";

export function MakeButton({
  base,
  hover,
  active,
  disabled
}: {
  base: SimpleInterpolation;
  hover: SimpleInterpolation;
  active: SimpleInterpolation;
  disabled: SimpleInterpolation;
}) {
  const Button = styled("button")`
    cursor: pointer;
    display: inline-block;
    border: 0;
    background: transparent;
    position: relative;

    span {
      position: relative;
      z-index: 2;
      line-height: 1;
      display: inline-flex;
      justify-content: center;
      align-items: center;
    }

    ${base}

    &:hover:not(:disabled):not(:active) {
      ${hover}
    }

    &:active {
      ${active}
    }

    &:disabled {
      cursor: not-allowed;
      ${disabled}
    }

    &:after {
      content: "";
      display: block;
      position: absolute;
      z-index: 1;
      width: 100%;
      height: 100%;
      border-radius: inherit;
      top: 0;
      left: 0;
      box-sizing: border-box;
    }

    &:focus {
      outline: 0;
    }

    &:focus:after {
      box-shadow: 0 0 0 0.2rem rgba(0, 0, 0, 1);
    }
  `;

  return { Button };
}

As you've seen, MakeButton is also our chance to do a little more than enforce best practices: we can use it to lay down common styles for our army of buttons.

Gathering our two base elements forms a single toolset that will help us produce buttons, but what about variants? Will I need to write all my styles all over again?

In traditionnal CSS we would simply extend the styles of the default elements and use the cascade to do the distinctions. We can do that too, using an additional MakeVariant function that relies on being given a Styled Component to expand from:

tsx


export function MakeVariant({
  button,
  base,
  hover,
  active,
  disabled
}: {
  button: AnyStyledComponent;
  base: SimpleInterpolation;
  hover?: SimpleInterpolation;
  active?: SimpleInterpolation;
  disabled?: SimpleInterpolation;
}) {
  const Variant = styled(button)`
    ${base}

    &:hover:not(:disabled):not(:active) {
      ${hover}
    }

    &:active {
      ${active}
    }

    &:disabled {
      ${disabled}
    }
  `;

  return { Variant };
}

Combined, we now have a "private" button, something not made to be used directly but to build our patterns from. Think of it as a sourdough starter: you wouldn't eat it on its own, but it will make any bread you make much better.

Checkout the completed file on CodeSandbox.

Components are patterns

Having a strong toolset is nice, using it is better.

Now before we code, let's take a moment and think about what we are making here. We are building patterns, components that carry specific meanings and can be tailored to better fit pre-identified situations.

As developers we have grown used to translating this train of thought as components with props. If designers have given us a pattern that can be either red or blue or green, then let's give them a colors prop and call it a day.

There is a sense to that. It builds flexible components with easy-to-use APIs. But there are also a few caveats: what would happen if the design team needs the same component to be also either small or large...but cannot be large and green at the same time?

Our API would make the component succeptible to "impossible states".

The granularity of our API is also often uncessessary. Why ask for each prop to be properly filled-in when from the start we know which "variants" are truly available? Can't we simply offer those directly? After all we are delivering patterns, not components: we deliver specific tools for specific jobs, not abstract concepts for abstract purposes.

In this example, I chose to materialize this concern as a single prop. Why bother mix and matching when you can achieve the same result in one go?

tsx


import React, { forwardRef, Ref } from "react";

import { BaseButton, BaseButtonProps } from "../Private/index";

import {
  Neutral, // Built using MakeButton
  Positive, // Built using MakeVariant
  Negative,
  Secondary,
  SecondaryNegative,
  SecondaryPositive
} from "./styles";

type ActionButtonTypes = Omit<BaseButtonProps, "StyledComponent"> & {
  variant?:
    | "NEUTRAL"
    | "NEGATIVE"
    | "POSITIVE"
    | "SECONDARY"
    | "SECONDARY POSITIVE"
    | "SECONDARY NEGATIVE";
};

export const ButtonAction = forwardRef(function ActionButton(
  { onClick, variant = "NEUTRAL", children, ...props }: ActionButtonTypes,
  ref: Ref<HTMLButtonElement>
) {
  const getVariantPropValue = () => {
    if (variant === "NEGATIVE") {
      return Negative;
    }

    if (variant === "POSITIVE") {
      return Positive;
    }

    if (variant === "SECONDARY") {
      return Secondary;
    }

    if (variant === "SECONDARY NEGATIVE") {
      return SecondaryNegative;
    }

    if (variant === "SECONDARY POSITIVE") {
      return SecondaryPositive;
    }

    return Neutral;
  };
  return (
    <BaseButton
      onClick={onClick}
      StyledComponent={getVariantPropValue()}
      ref={ref}
      {...props}
    >
      {children}
    </BaseButton>
  );
});

Checkout this file on CodeSandbox.

From this, it's real easy to build the other buttons we were tasked with. They will all stem from our <BaseButton> and rely on our opininated style builders. By the way, those look like that:

tsx


import { css } from "styled-components";

import { colors } from "../../tokens";

import { MakeButton, MakeVariant } from "../Private/index";

export const { Button: Neutral } = MakeButton({
  base: css`
    background: ${colors.$neutral};
    box-shadow: 0 0 0 0 ${colors.$neutral_200};
    font-family: "Roboto", sans-serif;
    font-weight: 700;
    font-size: 1.6rem;
    height: 4rem;
    justify-content: center;
    align-items: center;
    padding: 0 1.6rem;
    border-radius: 8px;
    color: ${colors.$white};
    transition: box-shadow 200ms ease-out;
  `,
  hover: css`
    box-shadow: 4px 4px 0 0 ${colors.$neutral_200};
  `,
  active: css`
    box-shadow: 6px 6px 0 0 ${colors.$neutral_000};
  `,
  disabled: css`
    background: ${colors.$neutral_200};
    opacity: 0.6;
  `
});

export const { Variant: Negative } = MakeVariant({
  button: Neutral,
  base: css`
    background: ${colors.$negative};
    box-shadow: 0 0 0 0 ${colors.$negative_200};
  `,
  hover: css`
    box-shadow: 4px 4px 0 0 ${colors.$negative_200};
  `,
  active: css`
    box-shadow: 6px 6px 0 0 ${colors.$negative_000};
  `,
  disabled: css`
    background: ${colors.$negative_200};
  `
});

All our pretty buttons can now be safely exported from a single source and be consumed by hordes of developers.

I could bore you with fantastic CSS but it's probably better to leave you digging through the complete button set here.

Going further: changing habits

I first showed this article to my good friend Guillaume for review. We've discussed many of the issues around buttons in the past and we share the same concerns about seeing components as patterns. To my surprise he however felt that I could have gone further.

He was unsurprisingly right, but going further does come with strings attached.

Let's start with the part were I kept a single prop. Why keep it at all? We can simply create standalone buttons organized in a way that keep their naming related to the original design.

To do that, the base button toolkit evolved into one single export rather than a two part set of style and separate markup.

tsx


import React, { forwardRef, Ref } from "react";
import styled, { SimpleInterpolation } from "styled-components";

export type ButtonProps =
  // classic <button> props
  Omit<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    | "style" // no style override allowed
    | "css" // no style override allowed, really
    | "className" // no override through classes either
    | "type" // only some types allowed, see below
    | "onClick" // That's not optional, it's mandatory
  > & {
    type?: "button" | "submit" | "reset";
    onClick: () => void;
  };

export function MakeButton({
  base,
  hover,
  active,
  disabled
}: {
  base: SimpleInterpolation;
  hover: SimpleInterpolation;
  active: SimpleInterpolation;
  disabled: SimpleInterpolation;
}) {
  const Button = styled("button")`
    ...

    ${base}

    &:hover:not(:disabled):not(:active) {
      ${hover}
    }

    &:active:not(:disabled) {
      ${active}
    }

    &:disabled {
      cursor: not-allowed;
      ${disabled}
    }

    &:focus {
      outline: 0;
    }

    &:focus:after {
      box-shadow: 0 0 0 0.2rem rgba(0, 0, 0, 1);
    }
  `;

  return {
    Button: forwardRef(function BaseButton(
      { children, type = "button", ...rest }: ButtonProps,
      ref: Ref<HTMLButtonElement>
    ) {
      return (
        <Button
          ref={ref}
          {...rest}
          type={type}
        >
          <span>{children}</span>
        </Button>
      );
    })
  };
}

As you can see this is all familiar code, but it's now packaged in a single bundle. This time, it directly exports a component, not just styles. We're now able to do this:

tsx


import { css } from "styled-components";

import { colors } from "../../tokens";

import { MakeButton } from "../Private/index";

const defaultBase = css`
  background: ${colors.$neutral};
  box-shadow: 0 0 0 0 ${colors.$neutral_200};
  ...
`;

const defaultHover = css`
  box-shadow: 4px 4px 0 0 ${colors.$neutral_200};
`;

const defaultActive = css`
  box-shadow: 6px 6px 0 0 ${colors.$neutral_000};
`;

const defaultDisabled = css`
  background: ${colors.$neutral_200};
  opacity: 0.6;
`;

export const { Button: Neutral } = MakeButton({
  base: defaultBase,
  hover: defaultHover,
  active: defaultActive,
  disabled: defaultDisabled
});

export const { Button: Negative } = MakeButton({
  base: css`
    ${defaultBase}
    background: ${colors.$negative};
    box-shadow: 0 0 0 0 ${colors.$negative_200};
  `,
  hover: css`
    ${defaultHover}
    box-shadow: 4px 4px 0 0 ${colors.$negative_200};
  `,
  active: css`
    ${defaultActive}
    box-shadow: 6px 6px 0 0 ${colors.$negative_000};
  `,
  disabled: css`
    ${defaultDisabled}
    background: ${colors.$negative_200};
  `
});

// Same for Positive

This produces several buttons now, all standalone. With some clever structuring and exporting, we can use them in our apps like so:

tsx


import { ButtonAction } from "./Buttons";

export default function App() {
  return (
    <div className="App">
      <ul
        style={{
          listStyleType: "none"
        }}
      >
        <li style={{ marginBottom: "2rem" }}>
          <ButtonAction.Primary.Neutral
            onClick={() => console.log("You clicked me")}
          >
            Action Button
          </ButtonAction.Primary.Neutral>
        </li>
        <li style={{ marginBottom: "2rem" }}>
          <ButtonAction.Primary.Negative
            onClick={() => console.log("You clicked me")}
          >
            Action Button
          </ButtonAction.Primary.Negative>
        </li>
        <li style={{ marginBottom: "2rem" }}>
          <ButtonAction.Primary.Positive
            onClick={() => console.log("You clicked me")}
            disabled
          >
            Action Button
          </ButtonAction.Primary.Positive>
        </li>
        <li style={{ marginBottom: "2rem" }}>
          <ButtonAction.Secondary.Neutral
            onClick={() => console.log("You clicked me")}
          >
            Action Button
          </ButtonAction.Secondary.Neutral>
        </li>
        <li style={{ marginBottom: "2rem" }}>
          <ButtonAction.Secondary.Negative
            onClick={() => console.log("You clicked me")}
            disabled
          >
            Action Button
          </ButtonAction.Secondary.Negative>
        </li>
        <li style={{ marginBottom: "2rem" }}>
          <ButtonAction.Secondary.Positive
            onClick={() => console.log("You clicked me")}
          >
            Action Button
          </ButtonAction.Secondary.Positive>
        </li>
      </ul>
    </div>
  );
}

How is that better?

Well there is a bit of personal preference involved here. Those are no longer props-based buttons because they actually don't need props to exist. They are standalone patterns. They retain the same readable naming, all the same API and style type restrictions as before. They have their updsides.

They do require a change of habit. The Name.Name.Name component pattern is not so common. They do eventually produce more HTML buttons down the line as well. If you care to dig through the complete Codesandbox file built around that approach, you'll find the buttons with icons did involve a bit of duplication.

Parting words

In the end, whether the cost of one method outweights its benefits over the other one is up to you. Similarly, the whole idea behind this article can be done entirely in regular CSS, albeit without the ability to enforce types so strongly.

As you've seen, the end results are the same. TIMTOWTDI after all. What matters is that we try to keep in mind that components are not patterns, but visitors don't use components. They use patterns. So it makes sense to at least try to build our code around them!