Building a design system with dark mode using React, Typescript, scss, cva and Vite - Button Component
Tags: react, css, typescript, vite
Published: 2023-03-21 09:16:41

### Introduction
This is part seven of our series on building a complete design system from scratch. In the previous tutorial created `Alert` component. In this tutorial we will create a theme able `Button` component with dark mode. I would encourage you to play around with the deployed [storybook](http://yaldram-react-scss-chakra-ui.s3-website.ap-south-1.amazonaws.com/?path=/story/atoms-forms-button--playground&globals=theme:split). All the code for this series is available on [GitHub](https://github.com/yaldram/react-scss-chakra-ui).

### Step One: Button styles & theming
We want to achieve the following for the `Button` component - 
```tsx
/* Normal Button usage */ 
<Button size="md" colorScheme="red" variant="solid">
  Submit
</Button>

/* Button in a loading state, showing a spinner & disabled */
<Button isLoading size="md" colorScheme="red" variant="solid">
  Submit
</Button>

/* Button in a loading state, showing a spinner & text */
<Button
  isLoading
  size="md"
  loadingText="Submit...."
  colorScheme="red"
  variant="outline"
  spinnerPlacement="start"
 >
  Submit
 </Button>

/* Button with icon; can be left and right */
<Button
  size="md"
  leftIcon={<EmailIcon />}
  colorScheme="green"
  variant="solid"
>
 Email
</Button>      
```
- The `Button` component has a lot of different variants. 
- We have the normal `colorScheme` and `variant` combination, along with the size variant.
- Then we can also show a loading spinner and pass a loadingText and we can even place the spinner either to the right or left of the loadingText.
- We can also pass a leftIcon or rightIcon prop which will display the icon either to the right or left of the button label.

We will first create 2 components `ButtonIcon` and `ButtonSpinner` to handle the spinner and icon states. 

### Step 2: ButtonIcon & ButtonSpinner components
Under `atoms` create a folder `forms`. Inside `forms` create a new folder `button`, now under `atoms/forms/button` create `button.scss` file and paste the following - 
```scss
.button-spinner {
  display: flex;
  font-size: 1em;
  line-height: normal;
  align-items: center;
  position: relative;
  color: currentColor;
  margin: 0px;

  &.start {
    margin-right: 0.5rem;
  }

  &.end {
    margin-left: 0.5rem;
  }

  &.isAbsolute {
    position: absolute;
  }
}
```
Now create a new file `button-spinner.tsx` and paste the following - 
```tsx
import * as React from "react";
import { cva, VariantProps } from "class-variance-authority";

import { Spinner } from "../../feedback";

import "./button.scss";

const buttonSpinner = cva(["button-spinner"], {
  variants: {
    placement: {
      start: "start",
      end: "end",
    },
    isAbsolute: {
      true: "isAbsolute",
    },
  },
});

export type ButtonSpinnerProps = VariantProps<typeof buttonSpinner> & {
  children?: React.ReactElement;
  labelText?: string;
};

export function ButtonSpinner(props: ButtonSpinnerProps) {
  const { labelText, placement, children = <Spinner /> } = props;

  return (
    <div
      className={buttonSpinner({
        placement: labelText ? placement : undefined,
        isAbsolute: !labelText,
      })}
    >
      {children}
    </div>
  );
}
```
Create another new file `button-icon.tsx` and paste - 
```tsx
import * as React from "react";

import { MarginVariants, margin } from "../../../../cva-utils";

export type ButtonIconProps = MarginVariants & {
  children?: React.ReactNode;
};

export function ButtonIcon(props: ButtonIconProps) {
  const { children, m, mt, mr, mb, ml, ...delegated } = props;

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

  return (
    <span className={margin({ m, mt, mr, mb, ml })} {...delegated}>
      {componentChildren}
    </span>
  );
}
```
Take a note we are using the helper cva function margin, we will pass the `iconSpacing` prop to the `Button` component that will add the margin to the icons.

### Step Three: Button Component
Under `atoms/forms/button` create the `button.tsx` file - 
```tsx
import React from "react";
import { cva, cx, VariantProps } from "class-variance-authority";

import { margin, MarginVariants, ColorScheme } from "../../../../cva-utils";
import { ButtonSpinner } from "./button-spinner";
import { ButtonIcon } from "./button-icon";

import "./button.scss";

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>
  );
}

const button = cva(["button"], {
  variants: {
    variant: {
      link: "link",
      outline: "outline",
      solid: "solid",
      ghost: "ghost",
      unstyled: "unstyled",
    },
    size: {
      xs: "xs",
      sm: "sm",
      md: "md",
      lg: "lg",
    },
    isFullWidth: {
      true: "isFullWidth",
    },
  },
  defaultVariants: {
    variant: "solid",
    size: "md",
    isFullWidth: false,
  },
});

export type ButtonProps = MarginVariants &
  VariantProps<typeof button> &
  React.ComponentPropsWithoutRef<"button"> & {
    colorScheme?: ColorScheme;

    isLoading?: boolean;
    isDisabled?: boolean;

    loadingText?: string;
    leftIcon?: React.ReactElement;
    rightIcon?: React.ReactElement;
    iconSpacing?: MarginVariants["m"];

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

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => {
    const {
      m,
      mt,
      mr,
      mb,
      ml,
      variant,
      size,
      isFullWidth,
      colorScheme = "teal",
      className,
      isDisabled = false,
      isLoading = false,
      loadingText,
      spinnerPlacement = "start",
      spinner,
      rightIcon,
      leftIcon,
      iconSpacing = "xxs",
      children,
      ...delegated
    } = props;

    const buttonClasses = cx(
      margin({ m, mt, mr, mb, ml }),
      button({
        variant,
        size,
        isFullWidth,
        className: [colorScheme, className].join(" "),
      })
    );

    const buttonContentProps = {
      rightIcon,
      leftIcon,
      iconSpacing,
      children,
    };

    return (
      <button
        ref={ref}
        className={buttonClasses}
        disabled={isDisabled || isLoading}
        {...delegated}
      >
        {isLoading && spinnerPlacement == "start" && (
          <ButtonSpinner labelText={loadingText} placement="start">
            {spinner}
          </ButtonSpinner>
        )}

        {isLoading ? (
          loadingText || (
            <span style={{ opacity: 0 }}>
              <ButtonContent {...buttonContentProps} />
            </span>
          )
        ) : (
          <ButtonContent {...buttonContentProps} />
        )}

        {isLoading && spinnerPlacement === "end" && (
          <ButtonSpinner labelText={loadingText} placement="end">
            {spinner}
          </ButtonSpinner>
        )}
      </button>
    );
  }
);
```
I would suggest you play with the Button component on the [deploy preview](http://yaldram-react-scss-chakra-ui.s3-website.ap-south-1.amazonaws.com/?path=/story/atoms-forms-button--playground), you will understand the code automatically. We created the button cva function with all the variants, handled the cases for loading, loading with loadingText, icons. 

### Step Four: Button styles with dark mode
The styles for the `Button` are complicated meaning, we are using a combination of css custom properties and plain scss variables. I would recommend you take a look at [chakra ui](https://github.com/chakra-ui/chakra-ui/blob/main/packages/components/theme/src/components/button.ts) source code. For some `colorScheme` and `variants` we need to use the `rgba()` function especially for dark mode styles. Similar to the `Alert`, `Badge` components we need to create classes for the `colorScheme` & `varaint` combination and also target the dark mode. Under `button.scss` paste the following code - 
```scss
@use "sass:map";

/* base button styles */
.button {
  border: none;
  outline: none;
  font-family: inherit;
  cursor: pointer;

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

  padding: 0.25em 0.75em;
  font-weight: $font-weight-semibold;

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

  border-radius: 0.375rem; 

  &:focus {
    box-shadow: outline;
  }

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

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

  /* isFullWidth prop */
  &.isFullWidth {
    width: 100%;
  }

  /* button size xs */
  &.xs {
    height: 1.5rem;      
    min-width: 1.5rem;
    font-size: $font-size-xs;
    padding-left: $spacing-xs;
    padding-right: $spacing-xs;
  }

    /* button size sm */
  &.sm {
    height: 2rem;      
    min-width: 2rem;
    font-size: $font-size-sm;
    padding-left: $spacing-sm;
    padding-right: $spacing-sm;
  }

  /* button size md */
  &.md {
    height: 2.5rem;      
    min-width: 2.5rem;
    font-size: $font-size-md;
    padding-left: $spacing-md;
    padding-right: $spacing-md;
  }

  /* button size lg */
  &.lg {
    height: 3rem;      
    min-width: 3rem;
    font-size: $font-size-lg;
    padding-left: $spacing-lg;
    padding-right: $spacing-lg;
  }
}

/* button variant link */
.button.link {
  --color: none;
  --active-color: none;

  background: none;
  line-height: normal;
  vertical-align: baseline;
  color: var(--color);

  &:hover {
    text-decoration: underline;
  }

  &:hover:disabled {
    text-decoration: none;
  }

  &:active {
    color: var(--active-color);
  }

  @each $color in $color-schemes {
    $color-200: map.get($colors-map, #{$color + '200'});
    $color-500: map.get($colors-map, #{$color + '500'});
    $color-700: map.get($colors-map, #{$color + '700'});

    &.link.#{"" + $color} {
      --color: #{$color-500};
      --active-color: #{$color-700};

      [data-theme="dark"] & {
        --color: #{$color-200};
        --active-color: #{$color-500};
      }
    }
  }
}

/* button variant unstyled */
.button.unstyled {
  background: none;
  color: inherit;
  display: inline;
  line-height: inherit;
  margin: 0;

  [data-theme="dark"] & {
    color: #{map.get($colors-map, "white")};
  }
}

/* button variant solid */
.button.solid {
  --bg: none;
  --color: var(--color-black);
  --bg-hover: none;
  --bg-active: none;

  background: var(--bg);
  color: var(--color);

  &:hover {
    background: var(--bg-hover);
  }

  &:hover:disabled {
    background: var(--bg);
  } 

  &:active {
    background: var(--bg-active);
  }
}

@each $color in $color-schemes {
  @if ($color == gray) {
    .button.solid.gray {
      --bg : #{map.get($colors-map, "gray100")};
      --bg-hover: #{map.get($colors-map, "gray200")};
      --color: #{map.get($colors-map, "black")};
      --bg-active: #{map.get($colors-map, "gray300")}; 
    }
  } @else if ($color == yellow or $color == cyan) {
    .button.solid.#{"" + $color} {
      --bg : #{map.get($colors-map, #{$color + '400'})};
      --bg-hover: #{map.get($colors-map, #{$color + '500'})};
      --color: #{map.get($colors-map, "black")};
      --bg-active: #{map.get($colors-map, #{$color + '600'})};
    }
  } @else {
    .button.solid.#{"" + $color} {
      --bg : #{map.get($colors-map, #{$color + '500'})};
      --color: #{map.get($colors-map, "white")};
      --bg-hover: #{map.get($colors-map, #{$color + '600'})};
      --bg-active: #{map.get($colors-map, #{$color + '700'})};
    }
  }
}

@each $color in $color-schemes {
  @if ($color == gray) {
    [data-theme="dark"] .button.solid.gray {
      --bg : #{map.get($colors-map, "whiteAlpha200")}; 
      --color: #{map.get($colors-map, "whiteAlpha900")};
      --bg-hover: #{map.get($colors-map, "whiteAlpha300")};
      --bg-active: #{map.get($colors-map, "whiteAlpha400")};
    }
  } @else {
    [data-theme="dark"] .button.solid.#{"" + $color} {
      --bg : #{map.get($colors-map, #{$color + '200'})};
      --color: #{map.get($colors-map, "gray800")};
      --bg-hover: #{map.get($colors-map, #{$color + '300'})};
      --bg-active: #{map.get($colors-map, #{$color + '400'})};
    }
  }
}


/* button variant ghost */
.button.ghost {
  --color: none;
  --bg-hover: none;
  --bg-active: none;

  color: var(--color);
  background: transparent;

  &:hover {
    background: var(--bg-hover);
  }

  &:active {
    background: var(--bg-active);
  }
}

.button.outline {
  --color: none;
  --bg-hover: none;
  --bg-active: none;

  color: var(--color);
  background: transparent;
  border: 1px solid currentColor;

  &:hover {
    background: var(--bg-hover);
  }

  &:active {
    background: var(--bg-active);
  }
}

@each $color in $color-schemes {
  @if ($color == gray) {
    .button.ghost.gray, .button.outline.gray {
      --color: inherit;
      --bg-hover: #{map.get($colors-map, "gray100")};
      --bg-active: #{map.get($colors-map, "gray200")};
    }
  } @else {
    .button.ghost.#{"" + $color}, .button.outline.#{"" + $color} {
      --color: #{map.get($colors-map, #{$color + '600'})};
      --bg-hover: #{map.get($colors-map, #{$color + '50'})};
      --bg-active: #{map.get($colors-map, #{$color + '100'})};
    }
  }
}

@each $color in $color-schemes {
  @if ($color == "gray") {
    [data-theme="dark"] .button.ghost.gray, 
    [data-theme="dark"] .button.outline.gray {
      --color: #{map.get($colors-map, "whiteAlpha900")};
      --bg-hover: #{map.get($colors-map, "whiteAlpha200")};
      --bg-active: #{map.get($colors-map, "whiteAlpha300")};
    }
  } @else {
    [data-theme="dark"] .button.ghost.#{"" + $color}, 
    [data-theme="dark"] .button.outline.#{"" + $color} {
      $fg-color: map.get($colors-map, #{$color + '200'});

      --color: #{$fg-color};
      --bg-hover: #{rgba($fg-color, 0.12)};
      --bg-active: #{rgba($fg-color, 0.12)};
    }
  }
}
```
In `scss` to assign a variable to a css custom property you need to use interpolation #{} like so `--bg-active: #{rgba($fg-color, 0.12)}`; Please feel free to ask any questions.

### Step Five: Button Story
Under `atoms/forms/button` create the `button.stories.tsx` file and paste the following - 
```tsx
import * as React from "react";
import { StoryObj } from "@storybook/react";

import { colorSchemes, spacingControls } from "../../../../cva-utils";
import { Flex } from "../../layouts";
import { ArrowForwardIcon, EmailIcon } from "../../icons";
import { Button, ButtonProps } from "./button";

const { spacingOptions, spacingLabels } = spacingControls();

export default {
  title: "Atoms/Forms/Button",
};

export const Playground: StoryObj<ButtonProps> = {
  parameters: {
    theme: "split",
  },
  args: {
    variant: "solid",
    colorScheme: "green",
    size: "md",
    isFullWidth: false,
    m: "xxs",
  },
  argTypes: {
    variant: {
      name: "variant",
      type: { name: "string", required: false },
      options: ["link", "outline", "solid", "ghost", "unstyled"],
      description: "Variant for the Badge",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "subtle" },
      },
      control: {
        type: "select",
      },
    },
    colorScheme: {
      name: "colorScheme",
      type: { name: "string", required: false },
      options: colorSchemes,
      description: "The Color Scheme for the button",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "green" },
      },
      control: {
        type: "select",
      },
    },
    size: {
      name: "size (s)",
      type: { name: "string", required: false },
      options: ["xs", "sm", "md", "lg"],
      description: "Tag height width and horizontal padding",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "md" },
      },
      control: {
        type: "select",
      },
    },
    isFullWidth: {
      name: "isFullWidth",
      type: { name: "boolean", required: false },
      description: "Full width button",
      table: {
        type: { summary: "boolean" },
        defaultValue: { summary: "false" },
      },
      control: {
        type: "boolean",
      },
    },
    m: {
      name: "margin",
      type: { name: "string", required: false },
      options: spacingOptions,
      description: `Margin CSS prop for the Component shorthand for padding.
        We also have mt, mb, ml, mr.`,
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "-" },
      },
      control: {
        type: "select",
        labels: spacingLabels,
      },
    },
  },
  render: (args) => <Button {...args}>Button</Button>,
};

export const Default: StoryObj<ButtonProps> = {
  parameters: {
    theme: "split",
  },
  args: {
    colorScheme: "teal",
    size: "md",
  },
  argTypes: {
    colorScheme: {
      name: "colorScheme",
      type: { name: "string", required: false },
      options: colorSchemes,
      description: "The Color Scheme for the button",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "teal" },
      },
      control: {
        type: "select",
      },
    },
    size: {
      name: "size (s)",
      type: { name: "string", required: false },
      options: ["xs", "sm", "md", "lg"],
      description: "Tag height width and horizontal padding",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "md" },
      },
      control: {
        type: "select",
      },
    },
  },
  render: (args) => {
    const { colorScheme, size } = args;

    return (
      <Flex direction="col" gap="lg">
        <Flex gap="lg" align="center">
          <Button colorScheme={colorScheme} size="xs">
            Button
          </Button>
          <Button colorScheme={colorScheme} size="sm">
            Button
          </Button>
          <Button colorScheme={colorScheme} size="md">
            Button
          </Button>
          <Button colorScheme={colorScheme} size="lg">
            Button
          </Button>
        </Flex>
        <Flex gap="lg" align="center">
          <Button size={size} colorScheme={colorScheme} variant="solid">
            Button
          </Button>
          <Button size={size} colorScheme={colorScheme} variant="outline">
            Button
          </Button>
          <Button size={size} colorScheme={colorScheme} variant="ghost">
            Button
          </Button>
          <Button size={size} colorScheme={colorScheme} variant="link">
            Button
          </Button>
        </Flex>
        <Flex gap="lg" align="center">
          <Button
            isLoading
            size={size}
            colorScheme={colorScheme}
            variant="solid"
          >
            Button
          </Button>
          <Button
            isLoading
            size={size}
            loadingText="Loading...."
            colorScheme={colorScheme}
            variant="outline"
            spinnerPlacement="start"
          >
            Button
          </Button>
          <Button
            isLoading
            size={size}
            loadingText="Loading...."
            colorScheme={colorScheme}
            variant="outline"
            spinnerPlacement="end"
          >
            Button
          </Button>
        </Flex>
        <Flex gap="lg" align="center">
          <Button
            size={size}
            leftIcon={<EmailIcon />}
            colorScheme={colorScheme}
            variant="solid"
          >
            Email
          </Button>
          <Button
            size={size}
            rightIcon={<ArrowForwardIcon />}
            colorScheme={colorScheme}
            variant="outline"
          >
            Call us
          </Button>
        </Flex>
      </Flex>
    );
  },
};
```
From the terminal run `yarn storybook` and check the output.

### Conclusion & next steps
- In this series we managed to clone chakra ui components with its variants and dark theme using plain css. 
- We have to create two different classes for the variants, one for light mode and another for dark mode.
- We can avoid this by using css variables for theming, in my next tutorial series, we will implement a small [next-ui](https://nextui.org/) clone. There we will use only css variables for light and dark modes.

In this tutorial we created a theme able, feature packed `Button` component. All the code for this tutorial can be found [here](https://github.com/yaldram/react-scss-chakra-ui). Until next time PEACE.