After six years of React development, I finally nailed a button component. It has enough style variants to cover most situations and looks great in dark and light modes. You can also easily modify those variants, add new ones, or build other UI components on top of it.
We use styled-components, so our Button
receives the same properties as it's styled container plus a few optional ones. The first one is size
, we have 5 size options starting from extra small and finishing with extra large. The second one is kind
which defines Button
variant, we have 9 sufficient options that we can easily modify. Then we have isDisabled
, isLoading
and isRounded
properties that are self-explanatory. We make the onClick
property optional since sometimes we'll render the button as a div
element inside of a link so we won't need the onClick
handler there.
import styled, { css } from "styled-components"
import { defaultTransitionCSS } from "lib/ui/animations/transitions"
import { centerContentCSS } from "lib/ui/utils/centerContentCSS"
import { getHorizontalPaddingCSS } from "lib/ui/utils/getHorizontalPaddingCSS"
import { Spinner } from "lib/ui/Spinner"
import { getCSSUnit } from "lib/ui/utils/getCSSUnit"
import { Tooltip } from "lib/ui/Tooltip"
import { UnstyledButton } from "./UnstyledButton"
import { match } from "lib/shared/utils/match"
import { getColor } from "../theme/getters"
import { CenterAbsolutely } from "../CenterAbsolutely"
export const buttonSizes = ["xs", "s", "m", "l", "xl"] as const
type ButtonSize = (typeof buttonSizes)[number]
export const buttonKinds = [
"primary",
"secondary",
"reversed",
"attention",
"alert",
"outlined",
"outlinedAlert",
"ghost",
"ghostSecondary",
] as const
export type ButtonKind = (typeof buttonKinds)[number]
interface ContainerProps {
size: ButtonSize
isDisabled?: boolean
isLoading?: boolean
isRounded?: boolean
kind: ButtonKind
}
const Container = styled(UnstyledButton)<ContainerProps>`
${defaultTransitionCSS};
${centerContentCSS};
position: relative;
white-space: nowrap;
font-weight: 500;
border-radius: ${({ isRounded }) => getCSSUnit(isRounded ? 100 : 8)};
cursor: ${({ isDisabled, isLoading }) =>
isDisabled ? "initial" : isLoading ? "wait" : "pointer"};
${({ isDisabled }) =>
isDisabled &&
css`
opacity: 0.8;
`};
${({ size }) =>
match(size, {
xs: () => css`
${getHorizontalPaddingCSS(8)}
height: 28px;
font-size: 14px;
`,
s: () => css`
${getHorizontalPaddingCSS(16)}
height: 36px;
font-size: 14px;
`,
m: () => css`
${getHorizontalPaddingCSS(20)}
height: 40px;
font-size: 16px;
`,
l: () => css`
${getHorizontalPaddingCSS(20)}
height: 48px;
font-size: 16px;
`,
xl: () => css`
${getHorizontalPaddingCSS(40)}
height: 56px;
font-size: 18px;
`,
})}
${({ kind }) =>
match(kind, {
primary: () => css`
background: ${getColor("primary")};
color: ${getColor("white")};
`,
secondary: () => css`
background: ${getColor("mist")};
color: ${getColor("contrast")};
`,
reversed: () => css`
background: ${getColor("contrast")};
color: ${getColor("background")};
`,
attention: () => css`
background: ${getColor("attention")};
color: ${getColor("white")};
`,
alert: () => css`
background: ${getColor("alert")};
color: ${getColor("white")};
`,
outlined: () => css`
border: 1px solid ${getColor("mistExtra")};
color: ${getColor("contrast")};
`,
outlinedAlert: () => css`
border: 1px solid ${getColor("alert")};
color: ${getColor("alert")};
`,
ghost: () => css`
color: ${getColor("contrast")};
`,
ghostSecondary: () => css`
color: ${getColor("textSupporting")};
`,
})}
${({ isDisabled, isLoading, kind }) =>
!isDisabled &&
!isLoading &&
css`
&:hover {
${match(kind, {
primary: () => css`
background: ${getColor("primaryHover")};
`,
secondary: () => css`
background: ${getColor("mistExtra")};
`,
reversed: () => css`
background: ${getColor("text")};
`,
attention: () => css`
background: ${getColor("attentionHover")};
`,
alert: () => css`
background: ${({ theme }) =>
theme.colors.alert
.getVariant({ l: (l) => l * 0.92 })
.toCssValue()};
`,
outlined: () => css`
background: ${getColor("mist")};
color: ${getColor("contrast")};
`,
outlinedAlert: () => css`
background: ${({ theme }) =>
theme.colors.alert
.getVariant({ a: (a) => a * 0.12 })
.toCssValue()};
`,
ghost: () => css`
background: ${getColor("mist")};
`,
ghostSecondary: () => css`
background: ${getColor("mist")};
`,
})}
}
`};
`
export interface ButtonProps extends React.ComponentProps<typeof Container> {
size?: ButtonSize
kind?: ButtonKind
isDisabled?: boolean | string
isLoading?: boolean
isRounded?: boolean
onClick?: () => void
}
const Hide = styled.div`
opacity: 0;
`
export const Button = ({
children,
size = "m",
isDisabled = false,
isLoading = false,
onClick,
kind = "primary",
...rest
}: ButtonProps) => {
const content = isLoading ? (
<>
<Hide>{children}</Hide>
<CenterAbsolutely>
<Spinner size={18} />
</CenterAbsolutely>
</>
) : (
children
)
const containerProps = {
kind,
size,
isDisabled: !!isDisabled,
isLoading,
onClick: isDisabled || isLoading ? undefined : onClick,
...rest,
}
if (typeof isDisabled === "string") {
return (
<Tooltip
content={isDisabled}
renderOpener={(props) => (
<Container {...props} {...containerProps}>
{content}
</Container>
)}
/>
)
}
return <Container {...containerProps}>{content}</Container>
}
The default values for size and kind are m
and primary
respectively. When the isLoading
is true we still render the content of the button to keep its width the same, but we make it transparent while positioning the spinner in the center of the button absolutely. When the button is either disabled or loading we don't propagate the onClick
handler to the container.
It's a good practice to show a tooltip explaining why the button is disabled when the user hovers over it. To achieve that we allow the isDisabled
property to be a string and then render the button inside the Tooltip
component, to learn about its implementation check out my other post about the tooltip.
The Container
component built on top of UnstyledButton
which resets all default button styles.
import styled from "styled-components"
export const UnstyledButton = styled.button`
cursor: pointer;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
font-family: inherit;
line-height: inherit;
`
First, we add a default transition for a more pleasant hover effect, then we center content inside using flexbox. We add position relative for the spinner to be positioned absolutely inside the button. When the isRounded
is true we make the button rounded, otherwise we keep border-radius
to 8px
. Based on isDisabled
or isLoading
flags we set cursor to either wait
, initial
or pointer
. We also set opacity to 0.8
when the button is disabled.
Each size has a custom set of styles and to make the code more readable we use the match
function that serves as a replacement for the switch-case
statement. The only attributes that change based on the size are padding
, height
and font-size
.
export function match<T extends string | number | symbol, V>(
value: T,
handlers: { [key in T]: () => V }
): V {
const handler = handlers[value]
return handler()
}
We apply the same approach for the kind
property. The primary button has a blue background and white color for text. To access the color from theme we rely on a tiny helper getColor
.
import { DefaultTheme } from "styled-components"
import { ThemeColors } from "./ThemeColors"
interface ThemeGetterParams {
theme: DefaultTheme
}
export const getColor =
(color: keyof Omit<ThemeColors, "getPaletteColor">) =>
({ theme }: ThemeGetterParams) =>
theme.colors[color].toCssValue()
The secondary
kind has the mist
background which is same as the page background, but almost transparent. The contrast
color would be absolute black in light theme and white in dark theme. The reversed
button uses reverse colors to the rest of the UI and could often be a replacement for the primary
button. The attention
could be a nice option for a call-to-action like a sign-up button. The alert
button is red and could be used for destructive actions. The outlined
button has a transparent background and a light border. The outlinedAlert
button is similar to the alert
button, but doesn't have a background. The ghost
and ghostSecondary
buttons are transparent.
mist: new HSLA(0, 0, 100, 0.06),
mistExtra: new HSLA(0, 0, 100, 0.13),
When the button is interactive, e.g. not loading or disabled we apply a custom hover effect for every variant of a button.