Buttons seem like a very simple thing to implement. They are action driven.  Push a button to compose an email. Push a button to delete some spam.  This simplicity is excellent but belies a problem.  Communicating that action and its state can be challenging given how small an interface buttons present.

hand dryer instructions with label "push button receive bacon."

Like most UX problems there are many possible solutions each with their own ups and downs.  To rank these solutions I want to establish what our requirements are first.

  1. The button must in fact be a button. No tricks with css and <a> tags.
  2. The button must be accessible (WCAG 2.1 AA)
  3. The button must be completely styleable.
  4. The button should be completely portable.

Phase 1: Make a button

import cn from 'classnames';

const BaseButton = ({handleClick, className, children}) => (
	<button className={cn('base-button',className)} onclick={handleClick}>
		{children}
	</button>
);

BaseButton.propTypes = {
	disabled: PropTypes.bool,
	handleClick: PropTypes.func.isRequired,
	className: PropTypes.string,
	children: PropTypes.arrayOf(PropTypes.nodes).isRequired
}

BaseButton.defaultProps = {
	disabled: false
}

export default BaseButton;

There. That was easy.  It is a button that executes a function when you click it. We could make it looks like this...

<BaseButton className="my-button" handleClick={() => alert('hi')}>
	Say Hello!
</BaseButton>

Phase 2: Make it nicer looking  (i.e. Better)

In this example I'll use styled-jsx because I use NextJS at work and it's what I'm most familiar with. This technique works with styled-components as well though the syntax may vary.

import cn from 'classnames';
import css from 'styled-jsx/css';
import { darkenColor, desaturateColor } from './utils';

export const ghostButtonVarient = (buttonColor, borderRadius) => {
	return css.resolve`
		button {
			background-color: transparent;
			border-color: ${buttonColor};
			border-radius: ${borderRadius};
			color: ${buttonColor};
			padding: 6px 10px;
			font-size: 16px;
			transition: border-color 0.25s linear,
						color 0.25s linear;
		}
		button:disabled {
			border-color: ${desaturateColor(buttonColor, 20)};
			color: ${desaturateColor(buttonColor, 20)};
			cursor: not-allowed;
		}
		button:hover,
		button:focus,
		button:active {
			border-color: ${darkenColor(buttonColor, 20)};
			color: ${darkenColor(buttonColor, 20)};
		}
	`
}

const BaseButton = ({handleClick, className, children}) => (
	<button className={cn('base-button',className)} onclick={handleClick}>
		{children}
	</button>
);

BaseButton.propTypes = {
	disabled: PropTypes.bool,
	handleClick: PropTypes.func.isRequired,
	className: PropTypes.string,
	children: PropTypes.arrayOf(PropTypes.nodes).isRequired
}

BaseButton.defaultProps = {
	disabled: false
}

export default BaseButton;

With the addition of the ghostButtonVarient helper our button code can look like this

const { className, styles } = ghostButtonVariant('#aa0000', '5px');

<BaseButton className={className} handleClick={() => alert('hi')}>
    Test button
    {styles}
</BaseButton>