DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Build Chakra UI Image component using react, typescript, styled-components and styled-system

Introduction

Let us continue building our chakra components using styled-components & styled-system. In this tutorial we will be cloning the Chakra UI Image component.

  • I would like you to first check the chakra docs for image.
  • All the code for this tutorial can be found under the atom-image branch here.

Prerequisite

Please check the Chakra Image Component code here. In this tutorial we will -

  • Create an useImage hook.
  • Create an Imagecomponent.
  • Create story for the Image component.

Setup

  • First let us create a branch, from the main branch run -
git checkout -b atom-image
Enter fullscreen mode Exit fullscreen mode
  • Under the components/atom folder create an new folder called image. Under image folder create 3 files - image.tsx, use-image.ts and index.ts.

  • So our folder structure stands like - src/components/atoms/image.

useImage hook

  • I would again request you to please check the chakra docs. The Image component takes in a lot of very useful props.

  • Internally the Image component uses the useImage hook, this hook does some neat tricks. And the benefit of separating the image handling logic into it's own hook is that we can use this hook in other components like Avatar.

  • First open the utils/dom.ts file and paste the following code -

export function canUseDOM(): boolean {
  return !!(
    typeof window !== "undefined" &&
    window.document &&
    window.document.createElement
  );
}

export const isBrowser = canUseDOM();
Enter fullscreen mode Exit fullscreen mode
  • Now under the src folder create a new folder called hooks. Under src/hooks create 2 files use-safe-layout-effect.ts & index.ts.

  • useSafeLayoutEffect enables us to safely call useLayoutEffect on the browser (for SSR reasons). React currently throws a warning when using useLayoutEffect on the server. To get around it, we can conditionally useEffect on the server (no-op) and useLayoutEffect in the browser. Under use-safe-layout-effect.ts paste the following code -

import * as React from "react";

import { isBrowser } from "../utils";

export const useSafeLayoutEffect = isBrowser
  ? React.useLayoutEffect
  : React.useEffect;
Enter fullscreen mode Exit fullscreen mode
  • And under hooks/index.ts paste the following code -
export * from "./use-safe-layout-effect";
Enter fullscreen mode Exit fullscreen mode
  • Now let us start with the useImage hook let me first paste the code for you under atoms/image/use-image.ts -
import * as React from "react";

import { useSafeLayoutEffect } from "../../../hooks";

type Status = "loading" | "failed" | "pending" | "loaded";

type ImageEvent = React.SyntheticEvent<HTMLImageElement, Event>;

export interface UseImageProps {
  src?: string;
  srcSet?: string;
  sizes?: string;
  onLoad?(event: ImageEvent): void;
  onError?(error: string | ImageEvent): void;
  ignoreFallback?: boolean;
  crossOrigin?: React.ImgHTMLAttributes<any>["crossOrigin"];
}

export function useImage(props: UseImageProps) {
  const { src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback } =
    props;

  const imageRef = React.useRef<HTMLImageElement | null>();

  const [status, setStatus] = React.useState<Status>("pending");

  React.useEffect(() => {
    setStatus(src ? "loading" : "pending");
  }, [src]);

  const flush = () => {
    if (imageRef.current) {
      imageRef.current.onload = null;
      imageRef.current.onerror = null;
      imageRef.current = null;
    }
  };

  const load = React.useCallback(() => {
    if (!src) return;

    flush();

    const img = new Image();

    img.src = src;

    if (crossOrigin) {
      img.crossOrigin = crossOrigin;
    }

    if (srcSet) {
      img.srcset = srcSet;
    }

    if (sizes) {
      img.sizes = sizes;
    }

    img.onload = (event) => {
      flush();
      setStatus("loaded");
      onLoad?.(event as unknown as ImageEvent);
    };

    img.onerror = (error) => {
      flush();
      setStatus("failed");
      onError?.(error as any);
    };

    imageRef.current = img;
  }, [src, crossOrigin, srcSet, sizes, onLoad, onError]);

  useSafeLayoutEffect(() => {
    if (ignoreFallback) return undefined;

    if (status === "loading") {
      load();
    }

    return () => {
      flush();
    };
  }, [status, load, ignoreFallback]);

  return ignoreFallback ? "loaded" : status;
}

export type UseImageReturn = ReturnType<typeof useImage>;
Enter fullscreen mode Exit fullscreen mode
  • The basic use of useImage hook is to return the status of our image, whether it is loading or loaded.

  • We can also pass some cool onError & onLoad callback functions as props to the Image component to handle those scenarios the useImage hook takes care of calling these.

  • More on useImage later, let us use it in the Image component.

Image Component

  • Under the utils/objects.ts file paste the following code -
export function omit<T extends Dict, K extends keyof T>(object: T, keys: K[]) {
  const result: Dict = {};

  Object.keys(object).forEach((key) => {
    if (keys.includes(key as K)) return;
    result[key] = object[key];
  });

  return result as Omit<T, K>;
}
Enter fullscreen mode Exit fullscreen mode
  • Under the folder atoms/image/image.tsx paste the following code -
import * as React from "react";
import styled from "styled-components";
import { system, BorderRadiusProps, LayoutProps } from "styled-system";

import { omit } from "../../../utils";
import { useImage, UseImageProps } from "./use-image";

interface ImageOptions {
  fallbackSrc?: string;
  fallback?: React.ReactElement;
  loading?: "eager" | "lazy";
  fit?: React.CSSProperties["objectFit"];
  align?: React.CSSProperties["objectPosition"];
  ignoreFallback?: boolean;
  boxSize?: LayoutProps["size"];
  borderRadius?: BorderRadiusProps["borderRadius"];
}

export interface ImageProps
  extends UseImageProps,
    ImageOptions,
    Omit<React.ComponentPropsWithoutRef<"img">, keyof UseImageProps> {}

const BaseImage = styled.img`
  ${system({
    boxSize: {
      properties: ["width", "height"],
    },
    borderRadius: {
      property: "borderRadius",
    },
  })}
`;

export const Image = React.forwardRef<HTMLImageElement, ImageProps>(
  (props, ref) => {
    const {
      fallbackSrc,
      fallback,
      src,
      align,
      fit,
      loading,
      ignoreFallback,
      crossOrigin,
      alt,
      ...delegated
    } = props;

    const shouldIgnore = loading != null || ignoreFallback;

    const status = useImage({
      ...props,
      ignoreFallback: shouldIgnore,
    });

    const shared = {
      objectFit: fit,
      objectPosition: align,
      ...(shouldIgnore ? delegated : omit(delegated, ["onError", "onLoad"])),
    };

    if (status !== "loaded") {
      if (fallback) return fallback;

      return <BaseImage ref={ref} src={fallbackSrc} alt={alt} {...shared} />;
    }

    return (
      <BaseImage
        ref={ref}
        src={src}
        alt={alt}
        crossOrigin={crossOrigin}
        loading={loading}
        {...shared}
      />
    );
  }
);
Enter fullscreen mode Exit fullscreen mode
  • First things to notice is that the Image component takes in boxSize & borderRadius props so we added these to the system().

  • There are 2 separate props namely fallback which is a React component and fallbackSrc, if the status != "loaded" we return the fallback if passed or a placeholder instead, whose src is passed using the fallbackSrc prop.

  • If we pass either the loading prop or ignoreFallback prop we ignoreFallback and useImage hook will return 'loaded' meaning we won't show any fallback.

  • Guys I know I am doing a pretty bad job of explaining this, but again try passing these props and write some console logs in the code you will understand it better.

Story

  • With the above our Image component is completed, let us create a story.
  • Under the src/components/atoms/image/image.stories.tsx file we add the below story code -
import * as React from "react";

import { Flex, Stack } from "../layout";
import { Image } from "./image";

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

export const Default = {
  render: () => (
    <Stack direction="row" spacing="3xl">
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        ignoreFallback
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallbackSrc="https://via.placeholder.com/150"
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallback={
          <Flex
            bg="orange500"
            align="center"
            justify="center"
            color="white"
            size="150px"
            borderRadius="9999px"
          >
            Loading...
          </Flex>
        }
        fit="cover"
        alt="Segun Adebayo"
      />
    </Stack>
  ),
};
Enter fullscreen mode Exit fullscreen mode
  • Now run npm run storybook check the stories. Try changing your browser net speed to slow 3G and check the fallback for the second and third image. For the first image it won't show anything as fallback because we passed in ignoreFallback.

Build the Library

  • Under the image/index.ts file paste the following -
export * from "./image";
export * from "./use-image";
Enter fullscreen mode Exit fullscreen mode
  • Under the /atom/index.ts file paste the following -
export * from "./layout";
export * from "./typography";
export * from "./feedback";
export * from "./icon";
export * from "./icons";
export * from "./form";
export * from "./image";
Enter fullscreen mode Exit fullscreen mode
  • Now npm run build.

  • Under the folder example/src/App.tsx we can test our Image component. Copy paste the following code and run npm run start from the example directory. Be sure to set network speed of your browser to slow 3G.

import * as React from "react";
import { Flex, Stack, Image } from "chakra-ui-clone";

export function App() {
  return (
    <Stack m="lg" direction="row" spacing="3xl">
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        ignoreFallback
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallbackSrc="https://via.placeholder.com/150"
        fit="cover"
        alt="Segun Adebayo"
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sa-adebayo"
        fallbackSrc="https://via.placeholder.com/150"
        fit="cover"
        alt="Segun Adebayo"
        onError={() => alert("File Failed to Load")}
      />
      <Image
        boxSize="150px"
        borderRadius="9999px"
        src="https://bit.ly/sage-adebayo"
        fallback={
          <Flex
            bg="orange500"
            align="center"
            justify="center"
            color="white"
            size="150px"
            borderRadius="9999px"
          >
            Loading...
          </Flex>
        }
        fit="cover"
        alt="Segun Adebayo"
      />
    </Stack>
  );
}
Enter fullscreen mode Exit fullscreen mode

Summary

There you go guys in this tutorial we created Image component just like chakra ui . You can find the code for this tutorial under the atom-image branch here. In the next tutorial we will create Avatar component. Until next time PEACE.

Discussion (0)