Build Chakra UI Button component using react, typescript, styled-components and styled-system - Part 1
Tags: react, typescript
Published: 2021-09-04 07:59:13

### Introduction

Let us continue building our chakra components using `styled-components` & `styled-system`. In this tutorial we will be cloning the Chakra UI `Button` component.

- I would like you to first check the [chakra docs] for [button](https://chakra-ui.com/docs/form/button).
- All the code for this tutorial can be found  under the **atom-form-button** branch [here](https://github.com/yaldram/chakra-ui-clone).

### Prerequisite

Please check the previous post where we have completed the Icon Components. Also please check the Chakra Button Component code [here](https://github.com/chakra-ui/chakra-ui/tree/main/packages/button/src), along with the theme / styles for the button [here](https://github.com/chakra-ui/chakra-ui/blob/main/packages/theme/src/components/button.ts)

After checking the docs for the `Button` component, you know that it takes in quite a few props like `isLoading`, `rightIcon`, `leftIcon`, `spinner`, etc. Internally we have multiple components that handle these scenarios. So in this tutorial we will -

- Create a ButtonIcon component.
- Create a ButtonSpinner component.
- Create a BaseButton styled component.
- Create a ButtonContent component.

And in the next tutorial we will build the actual `Button` Component, by bringing all these together. So in this tutorial if you don't get the actual component logic no problem, everything will be clear in the next tutorial.

### Setup

- First let us create a branch, from the main branch run -

```bash
git checkout -b atom-form-button
```

- Under the `components/atoms` folder create a new folder called `form`.

- Under form folder create another folder called `button`. Under it create 4 files `button.tsx`, `button-icon.tsx`, `button-spinner.tsx` and `index.ts`.

- Also under `components/atom/form` create a new file `index.ts`.

- So our folder structure stands like - **src/components/atoms/form/button**.

### ButtonIcon Component

- Under `components/atom/form/button-icon.tsx` paste the following code -

```tsx
import * as React from "react";
import styled from "styled-components";
import { space, SpaceProps } from "styled-system";

export interface ButtonIconProps extends SpaceProps {
  children?: React.ReactNode;
}

const BaseSpan = styled.span<ButtonIconProps>`
  ${space}
`;
export const ButtonIcon: React.FC<ButtonIconProps> = (props) => {
  const { children, ...delegated } = props;

  const componentChildren = React.isValidElement(children)
    ? React.cloneElement(children, {
        "aria-hidden": true,
        focusable: false,
      })
    : children;

  return <BaseSpan {...delegated}>{componentChildren}</BaseSpan>;
};
```

- We created a `BaseSpan` styled component and passed in `space` utility from `styled-system`. This will allow us to pass marginProps like ml and mr to our component.

### Button Spinner

- Under `components/atom/form/button-spinner.tsx` paste the following code -

```tsx
import * as React from "react";

import { Box, BoxProps } from "../../layout";
import { Spinner } from "../../feedback";

interface ButtonSpinnerProps extends BoxProps {
  label?: string;
  placement?: "start" | "end";
}

export const ButtonSpinner: React.FC<ButtonSpinnerProps> = (props) => {
  const {
    label,
    placement,
    children = <Spinner color="currentColor" />,
    ...delegated
  } = props;

  const marginProp = placement === "start" ? "marginRight" : "marginLeft";

  const spinnerStyles = {
    display: "flex",
    fontSize: "1em",
    lineHeight: "normal",
    alignItems: "center",
    position: label ? "relative" : "absolute",
    [marginProp]: label ? "0.5rem" : 0,
  };

  return (
    <Box {...spinnerStyles} {...delegated}>
      {children}
    </Box>
  );
};
```

- The above `ButtonSpinner` component is used when we pass the `isLoading` prop to the `Button` component. It also handles the case in which we pass a custom Spinner using the `spinner` prop, if we don't pass a custom Spinner it will use the default one we created before.

### Styled Button Component

- We will be creating 2 variant props for our `Button`, namely `variant` for off-course the variant - "solid, outline" and `s` for the size of the button, I chose `s` so that it won't conflict with the size prop that comes with styled-system `layout` utility function.

- We will start by first creating our variant types - `ButtonSizes` and `ButtonVariants`.

- Then we will create our `ButtonOptions` & `ButtonProps`.

- Under `components/atom/form/button.tsx` paste the following code -

```tsx
import * as React from "react";
import styled from "styled-components";
import {
  compose,
  variant as variantFun,
  color,
  border,
  layout,
  space,
  fontSize,
  ResponsiveValue,
  SpaceProps,
  ColorProps,
  BorderProps,
  FontSizeProps,
  LayoutProps,
} from "styled-system";

import { ColorScheme as ButtonColorScheme } from "../../../../theme/colors";
import { ButtonSpinner } from "./button-spinner";
import { ButtonIcon } from "./button-icon";

type ButtonSizes = "xs" | "sm" | "md" | "lg";

type ButtonVariants = "link" | "outline" | "solid" | "ghost" | "unstyled";

interface ButtonOptions {
  colorScheme?: ButtonColorScheme;
  s?: ResponsiveValue<ButtonSizes>;
  variant?: ResponsiveValue<ButtonVariants>;

  isLoading?: boolean;
  isActive?: boolean;
  isDisabled?: boolean;
  isFullWidth?: boolean;

  loadingText?: string;
  leftIcon?: React.ReactElement;
  rightIcon?: React.ReactElement;
  iconSpacing?: SpaceProps["marginRight"];

  spinner?: React.ReactElement;
  spinnerPlacement?: "start" | "end";
}

export type ButtonProps = ColorProps &
  BorderProps &
  FontSizeProps &
  LayoutProps &
  ButtonOptions &
  SpaceProps &
  React.ComponentPropsWithoutRef<"button"> & { children?: React.ReactNode };
```

- Under the `ButtonOptions` we have covered all the props that chakra's original `Button` takes in.

- For the StyledButton let me first paste the code and we will go over it one by one -

```tsx
function variantGhost(colorScheme: ButtonColorScheme) {
  if (colorScheme === "gray") {
    return {
      color: "inherit",
      "&:hover": {
        bg: "gray100",
      },
      "&:active": {
        bg: "gray200",
      },
    };
  }

  return {
    color: `${colorScheme}600`,
    bg: "transparent",
    "&:hover": {
      bg: `${colorScheme}50`,
    },
    "&:active": {
      bg: `${colorScheme}100`,
    },
  };
}

function variantOutline(colorScheme: ButtonColorScheme) {
  return {
    border: "1px solid",
    borderColor: colorScheme === "gray" ? "gray200" : "currentColor",
    ...variantGhost(colorScheme),
  };
}

function variantSolid(colorScheme: ButtonColorScheme) {
  const accessibleColorMap = {
    yellow: {
      background: "yellow400",
      componentColor: "black",
      hoverBg: "yellow500",
      activeBg: "yellow600",
    },
    cyan: {
      background: "cyan400",
      componentColor: "black",
      hoverBg: "cyan500",
      activeBg: "cyan600",
    },
  };

  if (colorScheme === "gray") {
    return {
      bg: "gray100",
      "&:hover": {
        bg: "gray200",
        "&:disabled": { bg: "gray100" },
      },
      "&:active": { bg: "gray300" },
    };
  }

  const {
    background = `${colorScheme}500`,
    componentColor = "white",
    hoverBg = `${colorScheme}600`,
    activeBg = `${colorScheme}700`,
  } = accessibleColorMap[colorScheme] || {};

  return {
    bg: background,
    color: componentColor,
    "&:hover": {
      bg: hoverBg,
      "&:disabled": { bg: background },
    },
    "&:active": { bg: activeBg },
  };
}

function variantLink(colorScheme: ButtonColorScheme) {
  return {
    padding: 0,
    background: "none",
    height: "auto",
    lineHeight: "normal",
    verticalAlign: "baseline",
    color: `${colorScheme}500`,
    "&:hover": {
      textDecoration: "underline",
      "&:disabled": {
        textDecoration: "none",
      },
    },
    "&:active": {
      color: `${colorScheme}700`,
    },
  };
}

function variantUnStyled() {
  return {
    background: "none",
    color: "inherit",
    display: "inline",
    lineHeight: "inherit",
    margin: 0,
    p: 0,
  };
}

function variantSizes() {
  return {
    lg: {
      height: "3rem",
      minWidth: "3rem",
      fontSize: "lg",
      paddingLeft: "lg",
      paddingRight: "lg",
    },
    md: {
      height: "2.5rem",
      minWidth: "2.5rem",
      fontSize: "md",
      paddingLeft: "md",
      paddingRight: "md",
    },
    sm: {
      height: "2rem",
      minWidth: "2rem",
      fontSize: "sm",
      paddingLeft: "sm",
      paddingRight: "sm",
    },
    xs: {
      height: "1.5rem",
      minWidth: "1.5rem",
      fontSize: "xs",
      paddingLeft: "xs",
      paddingRight: "xs",
    },
  };
}

const BaseButton = styled.button<ButtonProps>`
  border: none;
  outline: none;
  font-family: inherit;
  padding: 0;
  cursor: pointer;

  display: inline-flex;
  align-items: center;
  justify-content: center;

  padding: 0.25em 0.75em;
  font-weight: 500;

  text-align: center;
  line-height: 1.1;
  transition: 220ms all ease-in-out;

  border-radius: 0.375rem;

  width: ${({ isFullWidth }) => (isFullWidth ? "100%" : "auto")};

  &:hover {
    &:disabled {
      background: initial;
    }
  }

  &:focus {
    box-shadow: outline;
  }

  &:disabled {
    opacity: 0.4;
    cursor: not-allowed;
    box-shadow: none;
  }

  ${({ colorScheme = "gray" }) =>
    variantFun({
      prop: "variant",
      variants: {
        link: variantLink(colorScheme),
        outline: variantOutline(colorScheme),
        solid: variantSolid(colorScheme),
        ghost: variantGhost(colorScheme),
        unstyled: variantUnStyled(),
      },
    })}

  ${variantFun({
    prop: "s",
    variants: variantSizes(),
  })}


  ${compose(color, border, layout, space, fontSize)}
`;
```

- Now I can write a lot explaining the above, but I would suggest one thing read the chakra docs play around with the `variant` & `colorScheme` props you will get to know what we did above.

- For the variant prop we are depending on the `colorScheme` passed, picking a shade from the theme. Notice I used `bg` instead of `background` which means these styled-system shorthands also work in the variant function.

- For the solid variant notice the `yellow` and `cyan` variants have a black color and light background we handled that case using a simple object `accessibleColorMap`.

- Also notice that I called `compose()` after the `variant()` function calls, this is done so that I can overwrite the styles. Say we have a button which variant = solid, colorScheme = 'orange' and s = 'md' for some reason I want the orange to be more dark say orang900 while keeping the other values same I can simply overwrite my variant bg color like below - why because specificity matters.

```tsx
<Button colorScheme="orange" variant="solid" s="md" bg="orange900">
  Button
</Button>
```

- For the base button styles check this awesome post [here](https://moderncss.dev/css-button-styling-guide/), highly recommended.

### ButtonContent Component

- Last component for this tutorial I promise.

- Under `components/atom/form/button.tsx` paste the following code -

```tsx
type ButtonContentProps = Pick<
  ButtonProps,
  "leftIcon" | "rightIcon" | "children" | "iconSpacing"
>;

function ButtonContent(props: ButtonContentProps) {
  const { leftIcon, rightIcon, children, iconSpacing } = props;
  return (
    <React.Fragment>
      {leftIcon && <ButtonIcon mr={iconSpacing}>{leftIcon}</ButtonIcon>}
      {children}
      {rightIcon && <ButtonIcon ml={iconSpacing}>{rightIcon}</ButtonIcon>}
    </React.Fragment>
  );
}
```

### Summary

This was quite long I guess, believe me guys you will understand everything in the next tutorial where we will bring all these components together. You can find the code for this tutorial under the **atom-form-button** branch [here](https://github.com/yaldram/chakra-ui-clone). In the next tutorial we will create `Button` component. Until next time PEACE.