DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Building a design system with dark mode using React, Typescript, scss, cva and Vite - Spinner & Icon Components

Introduction

This is part five of our series on building a complete design system from scratch. In the previous tutorial created the Badge component. In this short tutorial we will create 2 components Spinner and Icon which we will use in the future for the Alert & Button components. I would encourage you to play around with the deployed storybook. All the code for this series is available on GitHub.

Step One: Spinner Component

We want to achieve the following for the Spinner component -

<Spinner thickness="3px" speed="1s" size="sm" />
Enter fullscreen mode Exit fullscreen mode

Under atoms create a new folder feedback and under feedback create a new folder called spinner. Under atoms/feedback/spinner create the spinner.scss file and paste the following -

@keyframes spinner-animation {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

.spinner {
  --thickness: 2px;
  --speed: 0.4s;

  display: inline-block;
  color: currentColor;

  border-color: currentColor;
  border-style: solid;
  border-radius: 9999px;
  border-width: var(--thickness);
  border-bottom-color: transparent;
  border-left-color: transparent;

  width: 1em;
  height: 1em;

  animation: spinner-animation var(--speed) linear infinite;

  &.xs {
    width: 1.5rem;
    height: 1.5rem;
  }

  &.sm {
    width: 2rem;
    height: 2rem;
  }

  &.md {
    width: 2.5rem;
    height: 2.5rem;
  }

  &.lg {
    width: 3rem;
    height: 3rem;
  }

  &.xl {
    width: 3.5rem;
    height: 3.5rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice for the thickness and speed we are using custom css properties, these will enable us to pass dynamic values at runtime.
Now under atoms/feedback/spinner folder create index.tsx file -

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

import "./spinner.scss";

const spinner = cva(["spinner"], {
  variants: {
    size: {
      xs: "xs",
      sm: "sm",
      md: "md",
      lg: "lg",
      xl: "xl",
    },
  },
});

export type SpinnerProps = VariantProps<typeof spinner> &
  React.ComponentProps<"span"> & {
    thickness?: string;
    speed?: string;
  };

export function Spinner(props: SpinnerProps) {
  const { thickness, speed, size, style, className, ...delegated } = props;

  const stylesWithVars = {
    "--thickness": thickness,
    "--speed": speed,
    ...style,
  } as React.CSSProperties;

  return (
    <span
      style={stylesWithVars}
      className={spinner({ size, className })}
      {...delegated}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Take a note, that we are using the style prop to set the thickness and speed custom css properties dynamically, to pass in dynamic values at runtime we have to use custom css properties. And with that our Spinner component is ready, you can create a story for it.

Finally, under atoms/feedback create a new file index.ts -

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

Step Two: Icon Component

Under atoms create a new folder icons under atoms/icons folder create five files namely icon.scss, icon.tsx, icons.tsx, create-icon.tsx and index.ts.

Under atoms/icons/icon.scss paste the following -

.icon {
  display: inline-block;
  width: 1em;
  height: 1em;
  line-height: 1em;
  vertical-align: middle;
}
Enter fullscreen mode Exit fullscreen mode

Under atoms/icons/icon.tsx paste the following -

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

import "./icon.scss";

const fallbackIcon = {
  path: (
    <g stroke="currentColor" strokeWidth="1.5">
      <path
        strokeLinecap="round"
        fill="none"
        d="M9,9a3,3,0,1,1,4,2.829,1.5,1.5,0,0,0-1,1.415V14.25"
      />
      <path
        fill="currentColor"
        strokeLinecap="round"
        d="M12,17.25a.375.375,0,1,0,.375.375A.375.375,0,0,0,12,17.25h0"
      />
      <circle fill="none" strokeMiterlimit="10" cx="12" cy="12" r="11.25" />
    </g>
  ),
  viewBox: "0 0 24 24",
};

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

export interface IconProps extends React.ComponentProps<"svg"> {}

export const Icon = (props: IconProps) => {
  const {
    viewBox = fallbackIcon.viewBox,
    color = "currentColor",
    focusable = false,
    children,
    className,
    ...delegated
  } = props;

  const path = (children ?? fallbackIcon.path) as React.ReactNode;

  return (
    <svg
      className={icon({ className })}
      color={color}
      viewBox={viewBox}
      focusable={focusable}
      {...delegated}
    >
      {path}
    </svg>
  );
};
Enter fullscreen mode Exit fullscreen mode

Under atoms/icons/create-icon.tsx paste the following -

import * as React from "react";

import { Icon, IconProps } from "./icon";

interface CreateIconOptions {
  viewBox?: string;
  path?: React.ReactElement | React.ReactElement[];
  d?: string;
  defaultProps?: IconProps;
}

export function createIcon(options: CreateIconOptions) {
  const {
    viewBox = "0 0 24 24",
    d: pathDefinition,
    path,
    defaultProps = {},
  } = options;

  const Component = (props: IconProps) => (
    <Icon viewBox={viewBox} {...defaultProps} {...props}>
      {path ?? <path fill="currentColor" d={pathDefinition} />}
    </Icon>
  );

  return Component;
}
Enter fullscreen mode Exit fullscreen mode

Now we will use the Icon component and createIcon function to make some new Icons, under atoms/feedback/icons.tsx -

import * as React from "react";

import { createIcon } from "./create-icon";
import { Icon, IconProps } from "./icon";

export const CheckIcon = createIcon({
  d: "M12,0A12,12,0,1,0,24,12,12.014,12.014,0,0,0,12,0Zm6.927,8.2-6.845,9.289a1.011,1.011,0,0,1-1.43.188L5.764,13.769a1,1,0,1,1,1.25-1.562l4.076,3.261,6.227-8.451A1,1,0,1,1,18.927,8.2Z",
});

export const InfoIcon = createIcon({
  d: "M12,0A12,12,0,1,0,24,12,12.013,12.013,0,0,0,12,0Zm.25,5a1.5,1.5,0,1,1-1.5,1.5A1.5,1.5,0,0,1,12.25,5ZM14.5,18.5h-4a1,1,0,0,1,0-2h.75a.25.25,0,0,0,.25-.25v-4.5a.25.25,0,0,0-.25-.25H10.5a1,1,0,0,1,0-2h1a2,2,0,0,1,2,2v4.75a.25.25,0,0,0,.25.25h.75a1,1,0,1,1,0,2Z",
});

export const WarningIcon = createIcon({
  d: "M11.983,0a12.206,12.206,0,0,0-8.51,3.653A11.8,11.8,0,0,0,0,12.207,11.779,11.779,0,0,0,11.8,24h.214A12.111,12.111,0,0,0,24,11.791h0A11.766,11.766,0,0,0,11.983,0ZM10.5,16.542a1.476,1.476,0,0,1,1.449-1.53h.027a1.527,1.527,0,0,1,1.523,1.47,1.475,1.475,0,0,1-1.449,1.53h-.027A1.529,1.529,0,0,1,10.5,16.542ZM11,12.5v-6a1,1,0,0,1,2,0v6a1,1,0,1,1-2,0Z",
});

export const WarningTwoIcon = createIcon({
  d: "M23.119,20,13.772,2.15h0a2,2,0,0,0-3.543,0L.881,20a2,2,0,0,0,1.772,2.928H21.347A2,2,0,0,0,23.119,20ZM11,8.423a1,1,0,0,1,2,0v6a1,1,0,1,1-2,0Zm1.05,11.51h-.028a1.528,1.528,0,0,1-1.522-1.47,1.476,1.476,0,0,1,1.448-1.53h.028A1.527,1.527,0,0,1,13.5,18.4,1.475,1.475,0,0,1,12.05,19.933Z",
});

export const CloseIcon = createIcon({
  d: "M.439,21.44a1.5,1.5,0,0,0,2.122,2.121L11.823,14.3a.25.25,0,0,1,.354,0l9.262,9.263a1.5,1.5,0,1,0,2.122-2.121L14.3,12.177a.25.25,0,0,1,0-.354l9.263-9.262A1.5,1.5,0,0,0,21.439.44L12.177,9.7a.25.25,0,0,1-.354,0L2.561.44A1.5,1.5,0,0,0,.439,2.561L9.7,11.823a.25.25,0,0,1,0,.354Z",
});

export const SearchIcon = createIcon({
  d: "M23.384,21.619,16.855,15.09a9.284,9.284,0,1,0-1.768,1.768l6.529,6.529a1.266,1.266,0,0,0,1.768,0A1.251,1.251,0,0,0,23.384,21.619ZM2.75,9.5a6.75,6.75,0,1,1,6.75,6.75A6.758,6.758,0,0,1,2.75,9.5Z",
});

export const Search2Icon = createIcon({
  d: "M23.414,20.591l-4.645-4.645a10.256,10.256,0,1,0-2.828,2.829l4.645,4.644a2.025,2.025,0,0,0,2.828,0A2,2,0,0,0,23.414,20.591ZM10.25,3.005A7.25,7.25,0,1,1,3,10.255,7.258,7.258,0,0,1,10.25,3.005Z",
});

export const PhoneIcon = createIcon({
  d: "M2.20731,0.0127209 C2.1105,-0.0066419 1.99432,-0.00664663 1.91687,0.032079 C0.871279,0.438698 0.212942,1.92964 0.0580392,2.95587 C-0.426031,6.28627 2.20731,9.17133 4.62766,11.0689 C6.77694,12.7534 10.9012,15.5223 13.3409,12.8503 C13.6507,12.5211 14.0186,12.037 13.9993,11.553 C13.9412,10.7397 13.186,10.1588 12.6051,9.71349 C12.1598,9.38432 11.2304,8.47427 10.6495,8.49363 C10.1267,8.51299 9.79754,9.05515 9.46837,9.38432 L8.88748,9.96521 C8.79067,10.062 7.55145,9.24878 7.41591,9.15197 C6.91248,8.8228 6.4284,8.45491 6.00242,8.04829 C5.57644,7.64167 5.18919,7.19632 4.86002,6.73161 C4.7632,6.59607 3.96933,5.41495 4.04678,5.31813 C4.04678,5.31813 4.72448,4.58234 4.91811,4.2919 C5.32473,3.67229 5.63453,3.18822 5.16982,2.45243 C4.99556,2.18135 4.78257,1.96836 4.55021,1.73601 C4.14359,1.34875 3.73698,0.942131 3.27227,0.612963 C3.02055,0.419335 2.59457,0.0708094 2.20731,0.0127209 Z",
  viewBox: "0 0 14 14",
});

export const EmailIcon = createIcon({
  path: (
    <g fill="currentColor">
      <path d="M11.114,14.556a1.252,1.252,0,0,0,1.768,0L22.568,4.87a.5.5,0,0,0-.281-.849A1.966,1.966,0,0,0,22,4H2a1.966,1.966,0,0,0-.289.021.5.5,0,0,0-.281.849Z" />
      <path d="M23.888,5.832a.182.182,0,0,0-.2.039l-6.2,6.2a.251.251,0,0,0,0,.354l5.043,5.043a.75.75,0,1,1-1.06,1.061l-5.043-5.043a.25.25,0,0,0-.354,0l-2.129,2.129a2.75,2.75,0,0,1-3.888,0L7.926,13.488a.251.251,0,0,0-.354,0L2.529,18.531a.75.75,0,0,1-1.06-1.061l5.043-5.043a.251.251,0,0,0,0-.354l-6.2-6.2a.18.18,0,0,0-.2-.039A.182.182,0,0,0,0,6V18a2,2,0,0,0,2,2H22a2,2,0,0,0,2-2V6A.181.181,0,0,0,23.888,5.832Z" />
    </g>
  ),
});

export const ArrowForwardIcon = createIcon({
  d: "M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z",
});

export const AvatarFallback: React.FC<IconProps> = (props) => {
  return (
    <Icon
      viewBox="0 0 128 128"
      color="#fff"
      width="100%"
      height="100%"
      {...props}
    >
      <path
        fill="currentColor"
        d="M103,102.1388 C93.094,111.92 79.3504,118 64.1638,118 C48.8056,118 34.9294,111.768 25,101.7892 L25,95.2 C25,86.8096 31.981,80 40.6,80 L87.4,80 C96.019,80 103,86.8096 103,95.2 L103,102.1388 Z"
      />
      <path
        fill="currentColor"
        d="M63.9961647,24 C51.2938136,24 41,34.2938136 41,46.9961647 C41,59.7061864 51.2938136,70 63.9961647,70 C76.6985159,70 87,59.7061864 87,46.9961647 C87,34.2938136 76.6985159,24 63.9961647,24"
      />
    </Icon>
  );
};
Enter fullscreen mode Exit fullscreen mode

Finally under atoms/icons/index.ts paste the following -

export * from "./icon";
export * from "./create-icon";
export * from "./icons";
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this tutorial we created the Spinner & Icon components, we will use these in the next tutorials. All the code for this tutorial can be found here. In the next tutorial we will create a theme able Alert component. Until next time PEACE.

Top comments (0)