How to Build Resizeable Panels Like Tailwind

Using React and Next.js, we'll recreate Tailwind's Resizeable Panels component.

Tailwind simple dropdown

Tailwind's Resizeable Panels component makes it easy to showcase responsive components. You don't have to worry about setting specific breakpoints; you can simply drag the resize handle to show your components at any size (useful for component prototyping).

Here's what we'll be building (drag the handle to resize the panel):

To follow along, create a new React project using create-react-app.

Building the Component Structure

To build the component, we need three things:

  1. a container (for containing the resizeable panel and resize handle)
  2. a resizeable panel
  3. a resize handle (for resizing the resizeable panel)

Creating the Container

The container is going to be a single div.

To start off, we're going to give the container a width and a height (and a background color, so that it's visible).

We'll set the container's width property to 100%. If you want the container to have a fixed width, say 736px for example, then set the maxWidth property to 736px. That way, when you're on screens smaller than 736px, the container's width will scale down responsively instead of staying at 736px, which would be too large.

We can set the container's height property to any value, so I'll choose 400px.

Here's what the code looks like:

const ResizablePanels = () => {
  return (
    <div
      style={{
        width: "100%",
        maxWidth: "736px",
        height: "400px",
        backgroundColor: "#6b7280",
      }}
    >
    </div>
  )
}

And here's our container:

Creating the Resizeable Panel

The resizeable panel is going to be one div that contains both the panel's content (e.g. a responsive component prototype) and the div for the resize handle (which will be positioned absolutely, relative to the resizeable panel).

We'll make the panel's position property relative so that when we resize the panel, we don't have to write any additional code for moving the resize handle; the absolutely positioned resize handle will automatically shift so that it's always "attached" to the right side of the panel.

We can give the resizeable panel a height of 100% so that it fills up the container, and we can also give it a background color so that it's visible.

Since we're going to be changing the resizeable panel's width, let's give it a maxWidth of 100% so that it's never larger than its container.

For the width, the panel needs to fill up the container, while leaving space for the resize handle. Since the resize handle is positioned absolutely relative to the resizeable panel, we can give the panel a width of 100% and a paddingRight of the resize handle's width to make sure that the container can fully contain both the resizeable panel and the resize handle.

Note: For the resizeable panel to fit in the container, we need to set box-sizing to border-box in index.css:

/* index.css */

* {
  box-sizing: border-box;
}

We can store the value of the resize handle's width in a variable named handleWidth so that it's easy to reuse.

Here's what the code looks like:

const ResizablePanels = () => {
  const handleWidth = 16;
  return (
    <div
      style={{
        width: "100%",
        maxWidth: "736px",
        height: "400px",
        backgroundColor: "#6b7280",
      }}
    >
      <div
        style={{
          position: "relative",
          width: "100%",
          maxWidth: "100%",
          height: "100%",
          paddingRight: `${handleWidth}px`,
          backgroundColor: "#f1f2f4",
        }}
      >
      </div>
    </div>
  )
}

And here's our container with our resizeable panel inside:

Creating the Resize Handle

The resize handle is going to be a single div.

We'll set the handle's position property to absolute then set it's top and right properties to 0 so that the handle remains "attached" to the resizeable panel.

The handle's height will be 100% and its width will be the handleWidth variable that we defined earlier.

We'll give the handle a background color so that it's visible and also a left border to visually separate it from the resize handle.

We'll also set the handle's cursor property to ew-resize to indicate that the handle is draggable.

Here's what the code looks like:

const ResizablePanels = () => {
  const handleWidth = 16;
  return (
    <div
      style={{
        width: "100%",
        maxWidth: "736px",
        height: "400px",
        backgroundColor: "#6b7280",
      }}
    >
      <div
        style={{
          position: "relative",
          width: "100%",
          maxWidth: "100%",
          height: "100%",
          paddingRight: `${handleWidth}px`,
          backgroundColor: "#f1f2f4",
        }}
      >
        <div
          style={{
            position: "absolute",
            top: "0",
            right: "0",
            height: "100%",
            width: `${handleWidth}px`,
            backgroundColor: "#f3f4f6",
            borderLeft: "1px solid #e5e7eb",
            // borderBottom: "1px solid #e5e7eb",
            cursor: "ew-resize",
          }}
        ></div>
      </div>
    </div>
  )
}

And here's our container with our resizeable panel and resize handle inside:

Implementing the Resize Functionality

Now that we have the component set up, it's time to make it so that when you drag the resize handle, the resizeable panel gets resized.

To accomplish this resizing effect, we're going to create a useResize hook (adapted from this StackOverflow answer).

The hook is going to accept four arguments:

  1. containerRef: a reference to the container
  2. panelRef: a reference to the resizeable panel
  3. initialWidth: the resizeable panel's inital width
  4. minWidth: an optional argument for the resizeable panel's minimum width (will have a default value of 0)

Inside the hook, we're going to create a variable called panelWidth which we'll eventually set as the resizeable panel's width. Since we want the resizeable panel's width in the UI to be updated when the panelWidth variable is updated, we'll create the panelWidth variable using the useState hook and give it an initial value of initialWidth.

To help us handle the different stages of resizing, we're going to create three functions inside the hook: onResizeStart, onResize, and onResizeEnd.

The hook will return the panelWidth variable and the onResizeStart function. We'll set the resizeable panel's width to the panelWidth variable and we'll use the onResizeStart function in the resize handle so that when the handle is clicked, onResizeStart will be called and resizing can occur.

Here's what the code for the useResize hook looks like so far:

const useResize = ({
  containerRef,
  panelRef,
  initialWidth,
  minWidth = 0,
}): => {
  const [panelWidth, setPanelWidth] = useState(initialWidth);

  // TODO
  const onResizeStart = () => {};

  // TODO
  const onResize = (e) => {};

  // TODO
  const onResizeEnd = () => {};

  return { panelWidth, onResizeStart };
}

Creating the onResizeStart Function

When resizing starts, we want to make sure that resizing occurs when the user drags their mouse and that resizing stops when the user releases their mouse. So, in our onResizeStart function, we'll add event listeners that will call onResize when dragging occurs (pointermove event) and call onResizeEnd when the mouse is released (pointerup event).

We'll also set the resizeable panel's pointerEvents and userSelect properties to none so that nothing in the panel gets selected while the panel is being resized.

In the component that we have so far, you'll notice that when you click the resize handle and drag to the left, the cursor changes from ew-resize to the default cursor when you move your mouse from the panel to the resizeable panel. To fix this shift in cursors, we'll set the container's cursor to ew-resize when resizing starts.

Here's what the code looks like:

const onResizeStart = () => {
  if (panelRef.current) {
    panelRef.current.style.pointerEvents = "none";
    panelRef.current.style.userSelect = "none";
  }
  if (containerRef.current) {
    containerRef.current.style.cursor = "ew-resize";
  }
  window.addEventListener("pointermove", onResize);
  window.addEventListener("pointerup", onResizeEnd);
};

Creating the onResize Function

While resizing is happening, the newWidth of the resizeable panel if going to be equal to the mouse's position – the leftmost part of the resizeable panel.

We'll only set the panel's width to the newWidth if the newWidth is greater than the panel's minWidth.

Here's what the code looks like:

const onResize = (e) => {
  if (containerRef.current) {
    const bounds = containerRef.current.getBoundingClientRect();
    const newWidth = e.clientX - bounds.left;
    if (newWidth >= minWidth) {
      setPanelWidth(newWidth);
    } else {
      setPanelWidth(minWidth);
    }
  }
};

Creating the onResizeEnd Function

When resizing ends, we want to remove the pointermove and pointerup event listeners that we added in our onResizeStart function, since we're no longer resizing.

We'll also set the resizeable panel's pointerEvents and userSelect properties to auto so that the panel's contents are selectable again, and we'll reset the container's cursor to auto.

Here's what the code looks like:

const onResizeEnd = () => {
  if (panelRef.current) {
    panelRef.current.style.pointerEvents = "auto";
    panelRef.current.style.userSelect = "auto";
  }
  if (containerRef.current) {
    containerRef.current.style.cursor = "auto";
  }
  window.removeEventListener("pointermove", onResize);
  window.removeEventListener("pointerup", onResizeEnd);
};

Using the useResize Hook in the Resizeable Panel

Here's what the code for the useResize hook looks like:

const useResize = ({
  containerRef,
  panelRef,
  initialWidth,
  minWidth = 0,
}): => {
  const [panelWidth, setPanelWidth] = useState(initialWidth);

  const onResizeStart = () => {
    if (panelRef.current) {
      panelRef.current.style.pointerEvents = "none";
      panelRef.current.style.userSelect = "none";
    }
    if (containerRef.current) {
      containerRef.current.classList.add("resizing");
      containerRef.current.style.cursor = "ew-resize";
    }
    window.addEventListener("pointermove", onResize);
    window.addEventListener("pointerup", onResizeEnd);
  };

  const onResize = (e) => {
    if (containerRef.current) {
      const bounds = containerRef.current.getBoundingClientRect();
      const newWidth = e.clientX - bounds.left;
      if (newWidth >= minWidth) {
        setPanelWidth(newWidth);
      } else {
        setPanelWidth(minWidth);
      }
    }
  };

  const onResizeEnd = () => {
    if (panelRef.current) {
      panelRef.current.style.pointerEvents = "auto";
      panelRef.current.style.userSelect = "auto";
    }
    if (containerRef.current) {
      containerRef.current.classList.remove("resizing");
      containerRef.current.style.cursor = "auto";
    }
    window.removeEventListener("pointermove", onResize);
    window.removeEventListener("pointerup", onResizeEnd);
  };

  return { panelWidth, onResizeStart };
}

Here's what the updated code for the ResizeablePanels component looks like:

const ResizablePanels = () => {
  const containerRef = useRef(null);
  const panelRef = useRef(null);
  const handleWidth = 16;
  const maxContainerWidth = 736;

  const { panelWidth, onResizeStart } = useResize(
    containerRef,
    panelRef,
    maxContainerWidth,
  );

  return (
    <div
      ref={containerRef}
      style={{
        width: "100%",
        maxWidth: `${maxContainerWidth}px`,
        height: "400px",
        backgroundColor: "#6b7280",
      }}
    >
      <div
        ref={panelRef}
        style={{
          position: "relative",
          width: `${panelWidth}px`,
          maxWidth: "100%",
          height: "100%",
          paddingRight: `${handleWidth}px`,
          backgroundColor: "#f1f2f4",
        }}
      >
        <div
          style={{
            position: "absolute",
            top: "0",
            right: "0",
            height: "100%",
            width: `${handleWidth}px`,
            backgroundColor: "#f3f4f6",
            borderLeft: "1px solid #e5e7eb",
            cursor: "ew-resize",
          }}
          onPointerDown={onResizeStart}
        ></div>
      </div>
    </div>
  )
}

And here's the CodeSandbox our component with resize working:

Further Development

Hopefully you learned something new by recreating Tailwind's Resizeable Panels component.

If you want to learn more, you could try to extend the component by:

  • adding a second resizeable panel
  • supporting iFrames in the panels

Thanks for reading! If anything in this article was unclear or if you have any questions, you can reach me on Twitter @qildev.