DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Build Chakra UI Button component using react, typescript, styled-components and styled-system - Part 1

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.
  • All the code for this tutorial can be found under the atom-form-button branch here.

Prerequisite

Please check the previous post where we have completed the Icon Components. Also please check the Chakra Button Component code here, along with the theme / styles for the button here

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 -
git checkout -b atom-form-button
Enter fullscreen mode Exit fullscreen mode
  • 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 -
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>;
};
Enter fullscreen mode Exit fullscreen mode
  • 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 -
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>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • 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 -

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 };
Enter fullscreen mode Exit fullscreen mode
  • 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 -

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;
  align-self: flex-start;

  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)}
`;
Enter fullscreen mode Exit fullscreen mode
  • 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.

<Button colorScheme="orange" variant="solid" s="md" bg="orange900">
  Button
</Button>
Enter fullscreen mode Exit fullscreen mode
  • For the base button styles check this awesome post here, highly recommended.

ButtonContent Component

  • Last component for this tutorial I promise.

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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. In the next tutorial we will create Button component. Until next time PEACE.

Discussion (0)