This article provides an in-depth exploration of the implementation process with detailed explanations.

Modal in React and Tailwind

In every web app, modals play a crucial role. Crafting a Modal component that integrates well, maintains proper positioning, and ensures smooth scrolling is quite challenging. Today, we'll build one, providing code you can easily copy (ctrl + c and ctrl + v) without needing extra libraries.

We'll explore popular React patterns such as slot and content projection, then implement custom hooks to create production-ready modal.

Requirements and Challenges

If you've used Trello, Facebook, Instagram, or similar platforms, you'll notice that modals with too much content don't scroll internally. What's the alternative approach?

In these apps, they employ a clever technique: hiding the browser window scroll and adding their scroll within the modal container. Achieving this effect requires JavaScript.

Additionally, if there's enough space for content, no scroll is added. However, the window scroll is hidden to prevent duplicate scrolling on mobile devices, which can be frustrating! Here's how it looks:

Trello Modal Trello Modal

We want to achieve the same effect.

Modal API Design

The component should be as generic as possible, allowing us to render any content inside and providing default styling that we can easily override. Here's an example of how we'll use it:

<Modal className="some-custom-class pt-2">
  <ReallyLongComponentInside />
</Modal>

Let's define props interface:

// Inside modal.tsx file.
import React from 'react';

interface ModalProps {
  className?: string;
  children: React.ReactNode;
}

Modal Implementation

Now, it's time to add core implementation:

import React from 'react';
// This simplifies class concatenation.
import c from 'classnames';

interface ModalProps {
  className?: string;
  children: React.ReactNode;
}

const Modal = ({ className, children }: ModalProps) => {
  return (
    <div className="bg-black/40 dark:bg-white/20 fixed items-center justify-center flex p-4 z-20 h-[100svh] w-[100svw] left-0 top-0 overflow-y-auto">
      <div
        className={c(
          `bg-white m-auto max-w-[420px] dark:bg-black rounded-lg shadow-xl p-4`,
          className,
        )}
      >
        {children}
      </div>
    </div>,
  );
};

export default Modal;

Nothing fancy here. We've integrated the classnames library to streamline the process of creating complex classes and included some Tailwind classes. So, instead of cumbersome operations like this:

className={className ? className : " " + "my-classes"}

We can achieve the same results more easily and cleanly:

import c from 'classnames';
className={c("my-classes", className)}

While the library isn't strictly necessary, in larger applications with numerous components, incorporating this logic into each one can lead to an unwieldy codebase. So, why not improve it? Especially when the library is so lightweight - https://www.npmjs.com/package/classnames.

Hiding Window Scroll

This is the perfect case for a custom hook! Let's name it useScrollHide and implement it:

import React from 'react';

const useScrollHide = <T extends HTMLElement>() => {
  const ref = React.useRef<T>(null);
  const initialStyle = React.useRef<
    Required<Pick<React.CSSProperties, 'overflow'>>
  >({
    overflow: `auto`,
  });

  React.useLayoutEffect(() => {
    const getElement = (): HTMLElement => ref.current ?? document.body;

    const show = (): void => {
      const element = getElement();
      const style = initialStyle.current;
      element.style.overflow = style.overflow;
    };

    const hide = (): void => {
      const element = getElement();
      initialStyle.current.overflow = element.style.overflow;
      element.style.overflow = `hidden`;
    };

    hide();

    return show;
  }, []);

  return [ref] as const;
};

export { useScrollHide };

With Required<Pick<React.CSSProperties, 'overflow'>>, we've created a utility type that contains only the overflow property, which is required, while retaining the original typings.

Inside useLayoutEffect, we check for the existence of the ref. This hook also supports hiding any other scroll for HTML tag.

Afterward, we implemented show and hide functions, which set the required style for either the document.body or the ref element if it's utilized. We're returning it from the hook at [ref] as const (as const creates a tuple from an array).

Inside useLayoutEffect, we're returning the show function. After the component is unmounted, this function will ensure that the scroll is shown again.

Now, we use to attach this hook inside our modal.tsx component:

import { useScrollHide } from 'development-kit/use-scroll-hide';

const Modal = ({ className, children }: ModalProps) => {
  // Just call it!
  useScrollHide();
};

Using Portal

The modal is functioning as expected, but there's still a significant issue. The generated HTML by React will be rendered at the level of the component where it's used. Typically, modals should be added at the top of the component tree to avoid z-index problems and ensure it always stays on top.

Moreover, using position: fixed within a nested tree structure may lead to blurred fonts on some older browsers. You can find more information about this issue here: https://stackoverflow.com/questions/27385126/chrome-font-appears-blurry.

Lastly, utilizing Portals will eliminate the need to lift the state up if you want to control the modal appearance. You can simply render it inside any level, and it will be added to the top of your HTML tree.

After knowing that, let's create a hook:

import React from 'react';
import { createPortal } from 'react-dom';

const isServer = (): boolean => typeof window === `undefined`;

const usePortal = () => {
  const wrapper = React.useMemo(
    () => (isServer() ? null : document.createElement(`div`)),
    [],
  );

  React.useLayoutEffect(() => {
    if (!wrapper) return;

    document.body.appendChild(wrapper);

    return () => {
      document.body.removeChild(wrapper);
    };
  }, [wrapper]);

  return {
    render: (children: React.ReactNode): React.ReactPortal | null =>
      wrapper ? createPortal(children, wrapper) : null,
  };
};

export { usePortal };

We need to exercise caution when using browser APIs inside the body of a function for hooks. To prevent exceptions on the server side (e.g., when used with Next.js), we should add a check to verify the environment where the code is executed. The isServer function handles this.

Next, we need to create a wrapper div on the client-side only once for all added components. We can achieve this using the isServer() ? null : document.createElement('div') statement.

Then, in useLayoutEffect, we manage the addition and removal of the HTML node when the component is mounted and unmounted.

In the end, we return a render function to utilize React's portal API. All that's left is to use it in our modal.tsx file!

import React from 'react';
import { usePortal } from 'development-kit/use-portal';
import c from 'classnames';

const Modal = ({ className, children }: ModalProps) => {
  const { render } = usePortal();

  return render(
    <div className="bg-black/40 dark:bg-white/20 fixed items-center justify-center flex p-4 z-20 h-[100svh] w-[100svw] left-0 top-0 overflow-y-auto">
      <div
        className={c(
          `bg-white m-auto max-w-[420px] dark:bg-black rounded-lg shadow-xl p-4`,
          className,
        )}
      >
        {children}
      </div>
    </div>,
  );
};

This is how it works in the app:

Our Modal Our Modal

As you saw in the GIF, the modal component is placed outside of the main <div id="root"> and is positioned just after.

Full Code

import React from 'react';
import { createPortal } from 'react-dom';
import c from 'classnames';

// useScrollHide.ts

const useScrollHide = <T extends HTMLElement>() => {
  const ref = React.useRef<T>(null);
  const initialStyle = React.useRef<
    Required<Pick<React.CSSProperties, 'overflow'>>
  >({
    overflow: `auto`,
  });

  React.useLayoutEffect(() => {
    const getElement = (): HTMLElement => ref.current ?? document.body;

    const show = (): void => {
      const element = getElement();
      const style = initialStyle.current;

      element.style.overflow = style.overflow;
    };

    const hide = (): void => {
      const element = getElement();
      initialStyle.current.overflow = element.style.overflow;

      element.style.overflow = `hidden`;
    };

    hide();

    return show;
  }, []);

  return [ref] as const;
};

// useScrollHide.ts

// usePortal.ts

const isServer = (): boolean => typeof window === `undefined`;

const usePortal = () => {
  const wrapper = React.useMemo(
    () => (isServer() ? null : document.createElement(`div`)),
    [],
  );

  React.useLayoutEffect(() => {
    if (!wrapper) return;

    document.body.appendChild(wrapper);

    return () => {
      document.body.removeChild(wrapper);
    };
  }, [wrapper]);

  return {
    render: (children: React.ReactNode): React.ReactPortal | null =>
      wrapper ? createPortal(children, wrapper) : null,
  };
};

export { usePortal };

// usePortal.ts

// modal.tsx

interface ModalProps {
  className?: string;
  children: React.ReactNode;
}

const Modal = ({ className, children }: ModalProps) => {
  useScrollHide();
  const { render } = usePortal();

  return render(
    <div className="bg-black/40 dark:bg-white/20 fixed items-center justify-center flex p-4 z-20 h-[100svh] w-[100svw] left-0 top-0 overflow-y-auto">
      <div
        className={c(
          `bg-white m-auto max-w-[420px] dark:bg-black rounded-lg shadow-xl p-4`,
          className,
        )}
      >
        {children}
      </div>
    </div>,
  );
};

export default Modal;

// modal.tsx

Summary

As you saw, creating a robust modal requires some tricky stuff in JavaScript. It's not rocket science, but it's important to know how to implement it correctly.

Fortunately, there's a proposal for a native modal, and here's how the support currently looks: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog.

So, in the future, there may be no need to implement such components manually.