DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

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

Introduction

This is part six of our series on building a complete design system from scratch. In the previous tutorial created Spinner & Icon components. In this tutorial we will create a theme able Alert 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: Alert styles & theming

We want to achieve the following for the Alert component -

<Alert variant="solid" status="warning">
  <AlertIcon />
  <Flex direction="col">
   <AlertTitle>Your browser is outdated!</AlertTitle>
   <AlertDescription>
     Your Chakra experience may be degraded.
    </AlertDescription>
   </Flex>
 </Alert>
Enter fullscreen mode Exit fullscreen mode

We can also use the Alert component like so -

 <Alert variant="top-accent" colorScheme="red">
   <AlertIcon />
   There was an error processing your request.
  </Alert>
Enter fullscreen mode Exit fullscreen mode

So similar to the Badge component we have a combination of colorScheme and variant. Also we have another prop status which can take values info, status, warning, error these we will internally translate to info - colorScheme is blue, error - colorScheme is red.

Also we need to create 4 different components Alert, AlertIcon, AlertDescription & AlertTitle. Alert component will take in all the props like status, variant and pass this information on to other components AlertIcon using React Context.

Under the src/molecules folder create a new folder called alert, under the src/molecules/alert folder create a alert.scss file -

@use "sass:map";

/* base alert styles */
.alert {
  --alert-icon-color: none;

  width: 100%;
  position: relative;
  overflow: hidden;
  padding: $spacing-sm;

  @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-white: map.get($colors-map, "white");
    $color-black: map.get($colors-map, "black");

    /* alert variant subtle, left-accent, top-accent common styles */ 
    &.subtle.#{"" + $color}, 
    &.top-accent.#{"" + $color},  
    &.left-accent.#{"" + $color}  {
      background-color: $color-100;
      color: $color-black;
      --alert-icon-color: #{$color-500};

      [data-theme="dark"] & {
        background-color: rgba($color-200, 0.16);
        color: $color-white;
        --alert-icon-color: #{$color-200};
      }
    }

    /* alert variant top-accent with colorscheme & dark mode */ 
    &.top-accent.#{"" + $color} {
      padding-inline-start: $spacing-md;
      border-top-width: 4px;
      border-top-style: solid;
      border-top-color: $color-500;

      [data-theme="dark"] & {
        border-top-color: $color-200;
      }
    }

    /* alert variant left-accent with colorscheme & dark mode */ 
    &.left-accent.#{"" + $color} {
      padding-inline-start: $spacing-md;
      border-left-width: 4px;
      border-left-style: solid;
      border-left-color: $color-500;

      [data-theme="dark"] & {
        border-left-color: $color-200;
      }
    }

    /* alert variant solid with colorscheme & dark mode */ 
    &.solid.#{"" + $color} {
      background-color: $color-500;
      color: $color-white;
      --alert-icon-color: #{$color-white};

      [data-theme="dark"] & {
        background-color: $color-200;
        color: map.get($colors-map, "gray900");
        --alert-icon-color: #{$color-black};
      }
    }
  }

  & > .alert-icon {
    color: var(--alert-icon-color);
  }
}

.alert-icon {
  display: inline;
  flex-shrink: 0;
  margin-inline-end: $spacing-md;
  width: 1.25rem;
  height: 1.5rem;
}

.alert-title {
  font-weight: $font-weight-semibold;
  line-height: $line-height-tall;
  margin-inline-end: $spacing-xxs;
}

.alert-description {
  display: inline;
  line-height: $line-height-taller;
}
Enter fullscreen mode Exit fullscreen mode
  • We first declared the styles for all the Alert components.
  • Similar to the Badge component we create a combination of colorScheme and variants, we also included the dark mode styles.
  • Take a look at the .alert-icon we want to control the .alert-icon color for some variant values, so I have used a css custom property and I am setting its value accordingly.
  • Take note that to assign a scss variable to a css custom property you have to use interpolation --alert-icon-color: #{$color-black}.

Step Two: Alert component

Under src/utils folder create a new file create-context.ts -

import React from "react";

export interface CreateContextOptions {
  strict?: boolean;
  errorMessage?: string;
  name?: string;
}

type CreateContextReturn<T> = [React.Provider<T>, () => T, React.Context<T>];

export function createContext<ContextType>(options: CreateContextOptions = {}) {
  const {
    strict = true,
    errorMessage = "useContext: `context` is undefined. Seems you forgot to wrap component within the Provider",
    name,
  } = options;

  const componentContext = React.createContext<ContextType | undefined>(
    undefined
  );

  componentContext.displayName = name;

  function useContext() {
    const context = React.useContext(componentContext);

    if (!context && strict) {
      const error = new Error(errorMessage);
      error.name = "ContextError";
      Error.captureStackTrace?.(error, useContext);
      throw error;
    }

    return context;
  }

  return [
    componentContext.Provider,
    useContext,
    componentContext,
  ] as CreateContextReturn<ContextType>;
}
Enter fullscreen mode Exit fullscreen mode

This is a generic function that will create the context, it will also create a hook to consume the context and it also has error handling built in. So if you want to create a context for the Accordian, Tabs use this funciton.

Now under molecules/alert create an index.tsx file -

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

import { Box, BoxProps, Flex, FlexProps } from "../../atoms/layouts";
import { InfoIcon, WarningIcon, CheckIcon } from "../../atoms/icons";
import { ColorScheme } from "../../../cva-utils";
import { createContext } from "../../../utils";

import "./alert.scss";

const STATUSES = {
  info: { icon: InfoIcon, colorScheme: "blue" },
  warning: { icon: WarningIcon, colorScheme: "orange" },
  success: { icon: CheckIcon, colorScheme: "green" },
  error: { icon: WarningIcon, colorScheme: "red" },
};

export type AlertStatus = keyof typeof STATUSES;

const alert = cva(["alert"], {
  variants: {
    variant: {
      subtle: "subtle",
      "left-accent": "left-accent",
      "top-accent": "top-accent",
      solid: "solid",
    },
  },
  defaultVariants: {
    variant: "subtle",
  },
});

type AlertVariant = VariantProps<typeof alert>["variant"];

interface AlertContext {
  status: AlertStatus;
  variant: AlertVariant;
  colorScheme: ColorScheme;
}

const [AlertProvider, useAlertContext] = createContext<AlertContext>({
  name: "AlertContext",
  errorMessage:
    "useAlertContext: `context` is undefined. Seems you forgot to wrap alert components in `<Alert />`",
});

interface AlertOptions {
  status?: AlertStatus;
}

export interface AlertProps
  extends Omit<FlexProps, "bg" | "backgroundColor">,
    AlertOptions {
  colorScheme?: ColorScheme;
  variant?: AlertVariant;
}

export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
  (props, ref) => {
    const { status = "info", variant, align = "center", ...delegated } = props;

    const colorScheme =
      delegated.colorScheme ?? (STATUSES[status].colorScheme as ColorScheme);

    const alertClasses = alert({
      variant,
      className: colorScheme,
    });

    return (
      <AlertProvider value={{ status, variant, colorScheme }}>
        <Flex
          ref={ref}
          role="alert"
          align="center"
          className={alertClasses}
          {...delegated}
        />
      </AlertProvider>
    );
  }
);

export interface AlertTitleProps extends BoxProps {}

const alertTitle = cva(["alert-title"]);

export function AlertTitle(props: AlertTitleProps) {
  const { children, className, ...delegated } = props;

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

export interface AlertDescriptionProps extends BoxProps {}

const alertDescription = cva(["alert-description"]);

export function AlertDescription({
  className,
  ...delegated
}: AlertDescriptionProps) {
  return <Box className={alertDescription({ className })} {...delegated} />;
}

export interface AlertIconProps extends BoxProps {}

const alertIcon = cva(["alert-icon"]);

export function AlertIcon(props: AlertIconProps) {
  const { status, colorScheme } = useAlertContext();
  const { colorScheme: statusColorScheme, icon: BaseIcon } = STATUSES[status];

  const iconColorScheme = colorScheme ?? statusColorScheme;

  const alertIconClasses = alertIcon({
    className: iconColorScheme,
  });

  return (
    <span className={alertIconClasses} {...props}>
      <BaseIcon />
    </span>
  );
} 
Enter fullscreen mode Exit fullscreen mode

The above code is pretty straightforward, I would suggest you read it carefully and play around with the Alert component in storybook. Let me know if you have any questions.

Step Three: Alert Stories

Under molecules/alert create a new file alert.stories.tsx and paste the following code -

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

import { Alert, AlertIcon, AlertDescription, AlertTitle, AlertProps } from ".";
import { Flex } from "../../atoms/layouts";
import { colorSchemes } from "../../../cva-utils";

export default {
  title: "Molecules/Alert",
};

export const Playground: StoryObj<AlertProps> = {
  args: {
    colorScheme: "gray",
    variant: "solid",
  },
  argTypes: {
    colorScheme: {
      name: "colorScheme",
      type: { name: "string", required: false },
      options: colorSchemes,
      description: "The Color Scheme for the button",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "gray" },
      },
      control: {
        type: "select",
      },
    },
    variant: {
      name: "variant",
      type: { name: "string", required: false },
      options: ["solid", "subtle", "left-accent", "top-accent"],
      description: "The variant of the alert",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "solid" },
      },
      control: {
        type: "select",
      },
    },
  },
  render: (args: AlertProps) => (
    <Alert {...args}>
      <AlertIcon />
      There was an error processing your request
    </Alert>
  ),
};

export const AlertStatus: StoryObj<AlertProps> = {
  args: {
    status: "info",
    variant: "subtle",
  },
  argTypes: {
    status: {
      name: "status",
      type: { name: "string", required: false },
      options: ["info", "warning", "success", "error"],
      description: "The status of the alert",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "status" },
      },
      control: {
        type: "select",
      },
    },
    variant: {
      name: "variant",
      type: { name: "string", required: false },
      options: ["solid", "subtle", "left-accent", "top-accent"],
      description: "The variant of the alert",
      table: {
        type: { summary: "string" },
        defaultValue: { summary: "solid" },
      },
      control: {
        type: "select",
      },
    },
  },
  render: (args: AlertProps) => (
    <Alert {...args}>
      <AlertIcon />
      <Flex direction="col">
        <AlertTitle>Your browser is outdated!</AlertTitle>
        <AlertDescription>
          Your Chakra experience may be degraded.
        </AlertDescription>
      </Flex>
    </Alert>
  ),
};
Enter fullscreen mode Exit fullscreen mode

From the terminal run yarn storybook and check the output.

Conclusion

In this tutorial we created the second theme able component Alert. All the code for this tutorial can be found here. In the next tutorial we will work on the Button component. Until next time PEACE.

Top comments (0)