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. All the code for this series is available on GitHub.
Step One: Button styles & theming
We want to achieve the following for the Button component -
/* 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 
Buttoncomponent has a lot of different variants. - We have the normal 
colorSchemeandvariantcombination, 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 -
.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 -
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 -
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 -
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, 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 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 -
@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 -
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 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. Until next time PEACE.
    
Top comments (0)