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 - Box & Flex Components

Introduction

This is part three of our series on building a complete design system from scratch. In this tutorial we will create our first components Box and Flex. I would encourage you to play around with the deployed storybook. All the code for this series is available on GitHub.

Step One: Create the Box component

We already created a Box component in the first tutorial, now under atoms/layout/box create a box.scss -

.box {
  box-sizing: border-box;
}
Enter fullscreen mode Exit fullscreen mode

Now under atoms/layout/box/index.tsx paste the following -

import * as React from "react";
import { cva, cx } from "class-variance-authority";

import {
  bgColors,
  BgColorVariants,
  colors,
  ColorVariants,
  margin,
  MarginVariants,
  padding,
  PaddingVariants,
} from "../../../../cva-utils";

import "./box.scss";

const box = cva(["box"]);

export type BoxProps = ColorVariants &
  BgColorVariants &
  MarginVariants &
  PaddingVariants &
  React.ComponentPropsWithoutRef<"div">;

export const Box = React.forwardRef<HTMLDivElement, BoxProps>((props, ref) => {
  const {
    p,
    pt,
    pr,
    pb,
    pl,
    m,
    mt,
    mr,
    mb,
    ml,
    color,
    bg,
    className,
    children,
    ...delegated
  } = props;

  /**
   * Merge the utility classes
   */
  const boxClasses = cx(
    padding({ p, pt, pr, pb, pl }),
    margin({ m, mt, mr, mb, ml }),
    colors({ color }),
    bgColors({ bg }),
    box({ className })
  );

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

We imported all the cva variants that will become our utility props, merged them into one boxClasses using the cx function. Here is how we will use the Box component, all our theme tokens turned into utility props -

<Box bg="green400" color="blackAlpha200" p="md" m="sm">
  This is a Box component.
</Box>
Enter fullscreen mode Exit fullscreen mode

Now create a box.stories.tsx and paste the following -

import * as React from "react";

import { StoryObj } from "@storybook/react";

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

export default {
  title: "Atoms/Layout/Box",
};

const { spacingOptions, spacingLabels } = spacingControls();

export const Playground: StoryObj<BoxProps> = {
  args: {
    bg: "orange500",
    color: "black",
    p: "sm",
    m: "sm",
  },
  argTypes: {
    bg: {
      name: "bg",
      type: { name: "string", required: false },
      description: "Background Color CSS Prop for the component",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "transparent" },
      },
      control: {
        type: "text",
      },
    },
    color: {
      name: "color",
      type: { name: "string", required: false },
      description: "Color CSS Prop for the component",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "black" },
      },
      control: {
        type: "text",
      },
    },
    p: {
      name: "padding",
      type: { name: "string", required: false },
      options: spacingOptions,
      description: `Padding CSS prop for the Component shorthand for padding.
        We also have pt, pb, pl, pr.`,
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "-" },
      },
      control: {
        type: "select",
        labels: spacingLabels,
      },
    },
    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) => (
    <Box style={{ width: "100%" }} {...args}>
      Box Component
    </Box>
  ),
};

export const Default = () => (
  <Box style={{ width: "100%" }} p="lg" color="white" bg="teal700">
    Button
  </Box>
);
Enter fullscreen mode Exit fullscreen mode

Now from the terminal run yarn storybook and check the output.

Step Two: Create the Flex component

Things will get more clear, while building the Flex component. First under atoms/layout/flex folder create the flex.scss file and paste the following -

/* base flex class */
.flex {
  display: flex;
}

/* flex direction classes */
.flex-row {
  flex-direction: row;
}
.flex-row-reverse {
  flex-direction: row-reverse;
}
.flex-col {
  flex-direction: column;
}
.flex-col-reverse {
  flex-direction: column-reverse;
}

/* justify classes */
.justify-start {
  justify-content: flex-start;
}
.justify-end {
  justify-content: flex-end;
}
.justify-center {
  justify-content: center;
}
.justify-between {
  justify-content: space-between;
}
.justify-around {
  justify-content: space-around;
}
.justify-evenly {
  justify-content: space-evenly;
}

/* align classes */
.align-start {
  align-items: flex-start;
}
.align-end {
  align-items: flex-end;
}
.align-center {
  align-items: center;
}
.align-baseline {
  align-items: baseline;
}
.align-stretch {
  align-items: stretch;
}

/* wrap classes */
.flex-wrap {
  flex-wrap: wrap;
}
.flex-wrap-reverse {
  flex-wrap: wrap-reverse;
}
.flex-nowrap {
  flex-wrap: nowrap;
}

/* class for spacer component */
.spacer {
  flex: 1;
  justify-self: stretch;
  align-self: stretch;
}
Enter fullscreen mode Exit fullscreen mode

We basically added all the atomic classes needed for the Flex component. Now under atoms/layouts/flex folder create a new file index.tsx and paste the following -

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

import { flexGap, FlexGapVariants } from "../../../../cva-utils";
import { Box, BoxProps } from "../box";

import "./flex.scss";

const flex = cva(["flex"], {
  variants: {
    direction: {
      row: "flex-row",
      "row-reverse": "flex-row-reverse",
      col: "flex-col",
      "col-reverse": "flex-col-reverse",
    },
    justify: {
      start: "justify-start",
      end: "justify-end",
      center: "justify-center",
      between: "justify-between",
      around: "justify-around",
      evenly: "justify-evenly",
    },
    align: {
      start: "align-start",
      end: "align-end",
      center: "align-center",
      baseline: "align-baseline",
      stretch: "align-stretch",
    },
  },
  defaultVariants: {
    direction: "row",
  },
});

export type FlexProps = VariantProps<typeof flex> & FlexGapVariants & BoxProps;

export const Flex = React.forwardRef<HTMLDivElement, FlexProps>(
  (props, ref) => {
    const {
      direction,
      justify,
      align,
      gap,
      className,
      children,
      ...delegated
    } = props;

    const flexClasses = cx(
      flexGap({ gap }),
      flex({ direction, justify, align, className })
    );

    return (
      <Box ref={ref} className={flexClasses} {...delegated}>
        {children}
      </Box>
    );
  }
);

export interface SpacerProps extends BoxProps {}

export const Spacer = React.forwardRef<HTMLDivElement, SpacerProps>(
  (props, ref) => {
    const { children, ...delegated } = props;

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

To the cva function we first passed the main flex class and created variants for our various utility props. We then create the flexClasses using the cx utility function, take a note we also used our flexGap cva function. Here is how we are going to use the Flex component, all our atomic classed turned into utility props -

<Flex direction="col" justify="center" align="start" gap="sm">
  <Box>First Component</Box>
  <Box>Second Component</Box>
</Flex>
Enter fullscreen mode Exit fullscreen mode

Now create a new file called flex.stories.tsx -

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

import { Flex, FlexProps, Spacer } from ".";
import { spacingControls } from "../../../../cva-utils";

const { spacingOptions, spacingLabels } = spacingControls();

export default {
  title: "Atoms/Layout/Flex",
};

function Container(props: FlexProps) {
  const { children, ...delegated } = props;
  return (
    <Flex
      style={{
        minHeight: "100px",
        minWidth: "100px",
      }}
      justify="center"
      align="center"
      {...delegated}
    >
      {children}
    </Flex>
  );
}

export const Playground: StoryObj<FlexProps> = {
  args: {
    direction: "row",
    justify: "start",
    align: "stretch",
  },
  argTypes: {
    direction: {
      name: "direction",
      type: { name: "string", required: false },
      description: "Shorthand for flexDirection style prop",
      options: ["row", "row-reverse", "col", "col-reverse"],
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "row" },
      },
      control: {
        type: "select",
      },
    },
    justify: {
      name: "justify",
      type: { name: "string", required: false },
      options: ["start", "end", "center", "between", "around", "evenly"],
      description: "Shorthand for justifyContent style prop",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "start" },
      },
      control: {
        type: "select",
      },
    },
    align: {
      name: "align",
      type: { name: "string", required: false },
      options: ["start", "end", "center", "baseline", "stretch"],
      description: "Shorthand for alignItems style prop",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "stretch" },
      },
      control: {
        type: "select",
      },
    },
    gap: {
      name: "gap",
      type: { name: "string", required: false },
      options: spacingOptions,
      description: "Shorthand for flexGap style prop",
      table: {
        type: { summary: "string" },
      },
      control: {
        type: "select",
        labels: spacingLabels,
      },
    },
  },
  render: (args) => (
    <Flex style={{ width: "100%" }} bg="blue100" color="white" p="md" {...args}>
      <Container bg="green500">Box 1</Container>
      <Container bg="blue500">Box 2</Container>
      <Container bg="orange500">Box 3</Container>
    </Flex>
  ),
};

export const FlexSpacer = {
  args: {
    direction: "row",
  },
  argTypes: {
    direction: {
      name: "direction",
      type: { name: "string", required: false },
      options: ["row", "row-reverse", "col", "col-reverse"],
      description: "Shorthand for flexDirection style prop",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "row" },
      },
      control: {
        type: "select",
      },
    },
  },
  render: (args: FlexProps) => (
    <Flex
      style={{ width: "100%", height: "80vh" }}
      color="white"
      bg="blackAlpha200"
      p="xs"
      {...args}
    >
      <Container p="md" bg="red400">
        Box 1
      </Container>
      <Spacer />
      <Container p="md" bg="green400">
        Box 2
      </Container>
    </Flex>
  ),
};

export const Stack: StoryObj<FlexProps> = {
  args: {
    direction: "row",
    gap: "md",
  },
  argTypes: {
    direction: {
      name: "direction",
      type: { name: "string", required: false },
      description: "Shorthand for flexDirection style prop",
      options: ["row", "row-reverse", "col", "col-reverse"],
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "row" },
      },
      control: {
        type: "select",
      },
    },
    align: {
      name: "align",
      type: { name: "string", required: false },
      options: ["start", "end", "center", "baseline", "stretch"],
      description: "Shorthand for alignItems style prop",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "stretch" },
      },
      control: {
        type: "select",
      },
    },
    gap: {
      name: "gap",
      type: { name: "string", required: false },
      options: spacingOptions,
      description: "Shorthand for flexGap style prop",
      table: {
        type: { summary: "string" },
      },
      control: {
        type: "select",
        labels: spacingLabels,
      },
    },
  },
  render: (args) => (
    <Flex
      style={{ width: "100%", minHeight: "100vh" }}
      color="white"
      bg="blue100"
      p="md"
      {...args}
    >
      <Container p="md" bg="yellow500">
        Box 1
      </Container>
      <Container p="md" bg="red500">
        Box 2
      </Container>
      <Container p="md" bg="teal500">
        Box 3
      </Container>
    </Flex>
  ),
};
Enter fullscreen mode Exit fullscreen mode

From the terminal run yarn storybook, I would encourage you to play around with the components and you will understand the code much better. Also let me know if you have any queries.

From atoms/layouts/index.ts export these components -

export * from "./box";
export * from "./flex";
Enter fullscreen mode Exit fullscreen mode

Finally, under atoms/index.ts paste -

export * from "./layouts";
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this tutorial we created the Box & Flex components. All the code for this tutorial can be found here. In the next tutorial we will create our first theme able component Badge with both light and dark modes. Until next time PEACE.

Top comments (0)