A SplitPane is a collection of 2 elements whose heights can be changed by dragging a line that separates them. Based on its layout, a SplitPane can be horizontal or vertical. If you've ever used the Chrome dev tools, you probably came across both versions of the SplitPane. For example, the Elements section of the dev tools contains a horizontal SplitPane:
You can also find a vertical SplitPane in the Sources section of the dev tools:
In this article, we'll create our own SplitPane component without any dependencies. Although we'll only build a vertical version, you can apply the same logic and slightly change it to build the horizontal one.
This is how our final result will look like:
The final source code can be found here.
This article will touch the following React concepts:
If you're not familiar with these concepts, this is a good pragmatic guide to get yourself familiar with them.
Our vertical SplitPane has 1 requirement: whenever the separator is dragged, the sizes of the top and bottom panes should be altered accordingly. Although that's true, there is another way to look at it and that will make our implementation simpler. If we think about it, dragging the separator is modifying the size of 1 element only. The other element is only taking the remaining space. When we move separator up, we are only decreasing the size of the top pane. The bottom pane will take whatever remaining space.
Note: It's very important that we take some time to understand this since it's the core of our logic later.
Let's first have a very basic setup that we can build on top of. To start with, our SplitPane component is merely a flex container div. It renders its first child, a separator div, and a second child. SplitPane has 2 sub-components: Top and Bottom. The sub-components are simple divs that accept children so that we can populate them as we like. Initially, Top & Bottom split equally the height of SplitPane. To make that happen, they both have a flex: 1 in their styling (c.f. /style.css ).
// SplitPlane.jsexport default function SplitPane({ children, ...props }) {return (<div {...props} className="split-pane">{children[0]}<div className="separator" />{children[1]}</div>);}SplitPane.Top = function SplitPaneTop(props) {return <div {...props} className="split-pane-top" />;};SplitPane.Bottom = function SplitPaneBottom(props) {return <div {...props} className="split-pane-bottom" />;};
/* styles.css */.split-pane {display: flex;flex-direction: column;}.split-pane-top,.split-pane-bottom {flex: 1;overflow: hidden;}.split-pane-top {background: ...;}.split-pane-bottom {background: ...;}.separator {border: 5px solid black;cursor: row-resize;}
// index.jsfunction App() {return (<div className="App"><SplitPane><SplitPane.Top /><SplitPane.Bottom /></SplitPane></div>);}
Our logic is based on the Top pane height. Without it, we can't do any calculation.
We have 2 options:
If you chose the first option, you can skip the rest of this step.
Our logic will be based in the SplitPane component. Thus, the height of Top needs to be available in the state of SplitPane. We can then create a context for the Splitpane component to hold:
// SplitPlane.jsconst splitPaneContext = React.createContext();export default function SplitPane({ children, ...props }) {const [topHeight, setTopHeight] = React.useState(null);return (<div {...props} className="split-pane"><splitPaneContext.Provider value={{ topHeight, setTopHeight }}>{children[0]}<div className="separator" />{children[1]}</splitPaneContext.Provider></div>);}SplitPane.Top = function SplitPaneTop(props) {return <div {...props} className="split-pane-top" />;};SplitPane.Bottom = function SplitPaneBottom(props) {return <div {...props} className="split-pane-bottom" />;};
We can obtain the height of the top pane in the component. To do so, we create a reference to the DOM element and get its clientHeight after the first render using React.useEffect聽. To detect the first render, we can just see if our height variable has its initial null value. We can consume both the height of the Top pane and its setter function using the React.useContext method. Using the setter function, we can set the height of the Top pane in the state of the SplitPane component.
// SplitPlane.jsconst splitPaneContext = React.createContext();export default function SplitPane({ children, ...props }) {const [topHeight, setTopHeight] = React.useState(null);return (<div {...props} className="split-pane"><splitPaneContext.Provider value={{ topHeight, setTopHeight }}>{children[0]}<div className="separator" />{children[1]}</splitPaneContext.Provider></div>);}SplitPane.Top = function SplitPaneTop(props) {const topRef = React.createRef();const { topHeight, setTopHeight } = React.useContext(splitPaneContext);React.useEffect(() => {if (!topHeight) {setTopHeight(topRef.current.clientHeight);topRef.current.style.flex = "none";return;}});return <div {...props} className="split-pane-top" ref={topRef} />;};SplitPane.Bottom = function SplitPaneBottom(props) {return <div {...props} className="split-pane-bottom" />;};
So far, we managed to calculate the height of the Top pane DOM element and store it in the state of SplitPane. Now, we need to handle the opposite case. Whenever the topHeight state value changes, we need to change the height of the Top pane DOM element. We can do that in our previously created effect:
// SplitPlane.jsSplitPane.Top = function SplitPaneTop(props) {const topRef = React.createRef();const { topHeight, setTopHeight } = React.useContext(splitPaneContext);React.useEffect(() => {if (!topHeight) {setTopHeight(topRef.current.clientHeight);topRef.current.style.flex = "none";return;}topRef.current.style.height = `${topHeight}px`;});return <div {...props} className="split-pane-top" ref={topRef} />;};
The effect we created runs every time the component renders. We don't want to unnecessarily update the height of the Top pane DOM element at every render. To avoid this, we can pass [topHeight] as a second argument to React.useEffect. By doing so, this effet will only run if the topHeight state value changes:
// SplitPlane.jsSplitPane.Top = function SplitPaneTop(props) {const topRef = React.createRef();const { topHeight, setTopHeight } = React.useContext(splitPaneContext);React.useEffect(() => {if (!topHeight) {setTopHeight(topRef.current.clientHeight);topRef.current.style.flex = "none";return;}topRef.current.style.height = `${topHeight}px`;}, [topHeight]);return <div {...props} className="split-pane-top" ref={topRef} />;};
Now that we are detecting the height of the Top pane and updating its DOM element accordingly, it's time to implement the necessary changes that should occur when the user drags the separator element.
Whenever the user drags the separator, we want to detect the vertical distance by which the separator has been dragged. To do so, we need to track 3 mouse events:
Our component function is run every time it renders. So how do we maintain across renders and mutate the initial drag position? For that, we can use React.useRef()聽. Yes, React refs are not only used to refer to DOM elements. They can be used to maintain mutable values across renders (pretty cool, hein?):
// SplitPane.jsconst splitPaneContext = React.createContext();export default function SplitPane({ children, ...props }) {const [topHeight, setTopHeight] = React.useState(null);const separatorYPosition = React.useRef(null);const splitPaneRef = React.createRef();const onMouseDown = (e) => {separatorYPosition.current = e.clientY;};const onMouseMove = (e) => {if (!separatorYPosition.current) {return;}const newTopHeight = topHeight + e.clientY - separatorYPosition.current;separatorYPosition.current = e.clientY;setTopHeight(newTopHeight);};const onMouseUp = () => {separatorYPosition.current = null;};React.useEffect(() => {document.addEventListener("mousemove", onMouseMove);document.addEventListener("mouseup", onMouseUp);return () => {document.removeEventListener("mousemove", onMouseMove);document.removeEventListener("mouseup", onMouseUp);};});return (<div {...props} className="split-pane" ref={splitPaneRef}><splitPaneContext.Provider value={{ topHeight, setTopHeight }}>{children[0]}<div className="separator" onMouseDown={onMouseDown} />{children[1]}</splitPaneContext.Provider></div>);}
The SplitPane has a top and bottom limit. Once the user drags beyond those two borders, it's not necessary anymore to update the Top pane height. Thus, one improvement we can do is to avoid making any state changes when: The new height of the top pane is equal to the height of the entire SplitPane component. The new height of the top pane is less than 0.
We can get the height of the SplitPane DOM element using a reference to it that we can create using React.createRef聽. Then, before making any state update to the top height, we check for our previous 2 conditions:
// SplitPane.jsconst splitPaneContext = React.createContext();export default function SplitPane({ children, ...props }) {const [topHeight, setTopHeight] = React.useState(null);const separatorYPosition = React.useRef(null);const splitPaneRef = React.createRef();const onMouseDown = (e) => {separatorYPosition.current = e.clientY;};const onMouseMove = (e) => {if (!separatorYPosition.current) {return;}const newTopHeight = topHeight + e.clientY - separatorYPosition.current;separatorYPosition.current = e.clientY;if (newTopHeight <= 0) {return topHeight !== 0 && setTopHeight(0);}const splitPaneHeight = splitPaneRef.current.clientHeight;if (newTopHeight >= splitPaneHeight) {return topHeight !== splitPaneHeight && setTopHeight(splitPaneHeight);}setTopHeight(newTopHeight);};const onMouseUp = () => {separatorYPosition.current = null;};React.useEffect(() => {document.addEventListener("mousemove", onMouseMove);document.addEventListener("mouseup", onMouseUp);return () => {document.removeEventListener("mousemove", onMouseMove);document.removeEventListener("mouseup", onMouseUp);};});return (<div {...props} className="split-pane" ref={splitPaneRef}><splitPaneContext.Provider value={{ topHeight, setTopHeight }}>{children[0]}<div className="separator" onMouseDown={onMouseDown} />{children[1]}</splitPaneContext.Provider></div>);}