DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on • Edited on

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

Introduction

This is part four of our series on building a complete design system from scratch. In the previous tutorial we created our Box & Flex component. In this tutorial we will create our first theme able component Badge with light and dark modes. I would encourage you to play around with the deployed storybook. All the code for this series is available on GitHub.

Step One: Badge styles & theming

We want to achieve the following for the Badge component -

<Badge colorScheme="blue" variant="outline" size="md">
  Sample Badge
</Badge>
Enter fullscreen mode Exit fullscreen mode

We have the colorScheme prop, taking values - red, orange, blue, purple, etc. We can pass 3 variants to the component - outline, solid, subtle. Basically, we have the colorScheme & variant combination. We also have 3 size variants - sm, md, lg.

For handling the light and dark modes we will use the data attribute selector [data-theme="dark"], that means the root of our project should have the data-theme="light" or data-theme="dark" attribute. We will not use css variables for theming because chakra ui uses css in js and therefore it has a flat theme object, for the dark mode chakra ui is using javascript to create dark mode colors on the fly using rgba(), we will use scss rgba() function to achieve this.

The consumer of our library will use the attribute data-theme="light" or data-theme="dark" on its root element, we won't be creating any ThemeProvider because we don't need.

Under components/atoms create a new folder badge and under badge folder create a new file badge.scss -

@use "sass:map";

/* base badge styles */
.badge {
  display: inline-flex;
  vertical-align: top;
  align-items: center;
  max-width: 100%;
  outline: 0;
  border-radius: 0.125rem;

  font-weight: $font-weight-semibold;
  line-height: $line-height-shorter;

  /* badge size sm */ 
  &.sm {
    min-height: $spacing-md;
    min-width: $spacing-md;
    font-size: $font-size-xs;
    padding-left: $spacing-xxs;
    padding-right: $spacing-xxs;
  }

  /* badge size md */ 
  &.md {
    min-height: $spacing-lg;
    min-width: $spacing-lg;
    font-size: $font-size-sm;
    padding-left: $spacing-xxs;
    padding-right: $spacing-xxs;
  }

  /* badge size lg */ 
  &.lg {
    min-height: $spacing-xl;
    min-width: $spacing-xl;
    font-size: $font-size-md;
    padding-left: $spacing-xs;
    padding-right: $spacing-xs;
  }

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

    /* badge variant solid with colorscheme & dark mode */ 
    &.solid.#{"" + $color} {
      background-color: $color-500;
      color: map.get($colors-map, "white");

      [data-theme="dark"] & {
        color: map.get($colors-map, 'whiteAlpha800');
        background-color: rgba($color-500, 0.6);
      }
    }

    /* badge variant outline with colorScheme & dark mode */ 
    &.outline.#{"" + $color} {
      color: $color-500;
      box-shadow: inset 0 0 0px 1px currentColor;

      [data-theme="dark"] & {
        color: rgba($color-200, 0.8);
      }
    }

    /* badge variant subtle with colorScheme & dark mode */ 
    &.subtle.#{"" + $color} {
      color: $color-800;
      background-color: $color-100;

      [data-theme="dark"] & {
        color: $color-200;
        background-color: rgba($color-200, 0.16);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For the css classes we have to -

  1. First we created the base .badge class.
  2. Then we created the size variants classes sm, md, lg scoping all of them under the badge class.
  3. We then create variant classes combined with the colorScheme - .solid .red, .outline .red, .subtle .red, we used scss map function to create all these combinations.
  4. We are mapping over the $color-schemes array and for each colorScheme we are picking the respective values from $colors-map our color pallet. Both $color-schemes & $colors-map are declared under css/varaibles/_colors.scss.
  5. For the variant classes we also need to handle [data-theme="dark"] condition in the css say if you pass colorScheme as red and variant as solid how will it look in dark mode.
  6. So for the variants we are creating 2 classes, one for the light mode eg - .red .outline and one for the dark mode eg - [data-theme="dark"] .red .outline.

Notice one thing, we have not imported $color-schemes, $colors-map from our variables folder, instead of importing our variables in every .scss file we will add the following configuration to vite's defineConfig function -

css: {
   preprocessorOptions: {
     scss: {
       additionalData: `
         @use "./src/css/variables/_fonts.scss" as *;
         @use "./src/css/variables/_spacings.scss" as *;
         @use "./src/css/variables/_colors.scss" as *;
       `,
     },
   },
 },
Enter fullscreen mode Exit fullscreen mode

It will add these variables to all scss files that are imported in .tsx files, like we imported our badge.scss file in the badge/index.tsx file.

Step Two: Badge component

Under atoms/badge create a new file index.tsx -

import React from "react";
import { cva, VariantProps } from "class-variance-authority";

import { ColorScheme } from "../../../cva-utils";
import { Box, BoxProps } from "../layouts";

import "./badge.scss";

const badge = cva(["badge"], {
  variants: {
    variant: {
      outline: "outline",
      solid: "solid",
      subtle: "subtle",
    },
    size: {
      sm: "sm",
      md: "md",
      lg: "lg",
    },
  },
  defaultVariants: {
    variant: "subtle",
    size: "md",
  },
});

export type BadgeProps = VariantProps<typeof badge> &
  BoxProps & {
    colorScheme?: ColorScheme;
  };

export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
  (props, ref) => {
    const {
      variant,
      size,
      colorScheme = "green",
      children,
      className,
      ...delegated
    } = props;

    const badgeClasses = badge({
      variant,
      size,
      className: [colorScheme, className].join(" "),
    });

    return (
      <Box ref={ref} className={badgeClasses} {...delegated}>
        {children}
      </Box>
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

The above code is pretty straightforward, we created the cva function along with the variants, then we created the badgeClasses. Finally, we pass the badgeClasses to the className prop.

Step Three: Badge story

Under atoms/badge create a new file badge.stories.tsx -

import * as React from "react";
import { StoryObj } from "@storybook/react";

import { Badge, BadgeProps } from ".";
import { colorSchemes } from "../../../cva-utils";

export default {
  title: "Atoms/Badge",
};

export const Playground: StoryObj<BadgeProps> = {
  args: {
    variant: "subtle",
    colorScheme: "green",
    size: "md",
  },
  argTypes: {
    variant: {
      name: "variant",
      type: { name: "string", required: false },
      options: ["outline", "solid", "subtle"],
      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: ["sm", "md", "lg"],
      description: "Tag height width and horizontal padding",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "md" },
      },
      control: {
        type: "select",
      },
    },
  },
  render: (args) => <Badge {...args}>SAMPLE BADGE</Badge>,
};
Enter fullscreen mode Exit fullscreen mode

From the terminal run yarn storybook and check the badge stories.

Step Four: Create a theme addon in Storybook

Badge component works both for light and dark mode, but we don't have a way in storybook to switch themes. Well we have to build one. I would recommend, you read this awesome article on how to create a theme switcher in storybook.

Under .storybook/preview.tsx file paste the following -

import * as React from "react";

import { Flex, FlexProps } from "../src/components/atoms/layouts";

import "../src/css/main.scss";

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
};

function Container(props: FlexProps) {
  const { style, children, ...delegated } = props;

  return (
    <Flex
      align="start"
      p="md"
      style={{ minHeight: "100vh", ...style }}
      {...delegated}
    >
      {children}
    </Flex>
  );
}

const StoriesWithTheme = (StoryFun, context) => {
  const theme = context.parameters.theme || context.globals.theme;

  if (theme === "split") {
    return (
      <Flex>
        <Container bg="white" style={{ flexBasis: "50%" }} data-theme="light">
          <StoryFun />
        </Container>
        <Container bg="gray800" style={{ flexBasis: "50%" }} data-theme="dark">
          <StoryFun />
        </Container>
      </Flex>
    );
  }

  return (
    <Container bg={theme === "dark" ? "gray800" : "white"} data-theme={theme}>
      <StoryFun />
    </Container>
  );
};

export const globalTypes = {
  theme: {
    name: "Change Theme",
    description: "Global theme for components",
    defaultValue: "light",
    toolbar: {
      // The icon for the toolbar item
      icon: "circlehollow",
      // Array of options
      items: [
        { value: "light", icon: "circlehollow", title: "light-view" },
        { value: "dark", icon: "circle", title: "dark-view" },
        { value: "split", icon: "graphline", title: "split-view" },
      ],
      // Property that specifies if the name of the item will be displayed
      showName: true,
    },
  },
};

/**
 * This decorator is a global decorator will
 * be applied to each and every story
 */
export const decorators = [StoriesWithTheme];
Enter fullscreen mode Exit fullscreen mode

Now from the terminal run yarn storybook and play with the Theme switcher the above code will be understandable. To change the theme as stated earlier we will use the data attribute data-theme on our root element, we would change the theme in a similar way if are using our library in our React projects.

Why are we not using a ThemeProvider? Because we don't need a ThemeProvider, the consumer of our library has to change the data-theme attribute on the root element of his project and handle theme in whichever way he wants store it in Redux, zustand, or Context.

The consumer of our app can also create a ThemeContext. But never wrap your whole app inside a Context because if something updates the whole app will re-render. If say you want to hide some components in dark mode, you just wrap those components inside the context rather than wrapping the whole app.

Conclusion

In this tutorial we created the first themeable component Badge. All the code for this tutorial can be found here. In the next tutorial we will create a spinner and some Icons. Until next time PEACE.

Top comments (0)