DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Building a design system with dark mode using React, Typescript, scss, cva and Vite - Button Component

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

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

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

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

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

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

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

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)