How do you connect grid or flexbox positioned elements with a curvy line?
Here's a technique I have to re-learn every time. Writing it down for next time π
PS: if you're into this sort of thing, check out React for Dataviz where I teach how to expertly wield React for playfully visual components
The problem
You have complex interactive elements on the page. You don't know how big they are or how complex. Rendering with SVG is out of the question.
You used flexbox or grid to neatly position them in a <div>
. Lets you leverage the browser's layouting abilities and renders perfectly on every screen.
Like this:
They're buttons in this example. Later we'll use that to make the example harder βΒ dynamic elements π
Now assume this is a data visualization of some sort. The elements are points on a graph, a tree data structure, or you need visual flourish to make it pretty.
How do you draw complex lines between elements you can't control? SVG hates uncertainty. π€
SVG drawing synced to DOM elements
To draw an SVG line between elements you don't control, you'll need a couple ingredients:
- An SVG canvas that sits behind your elements
- DOM Refs to the elements
- Use
getBoundingClientRect()
to measure size and position
getBoundingClientRect measures the size and position of your element relative to the screen. This part gets me every time.
You have to subtract the SVG's getBoundingClientRect()
position to get coordinates relative to your canvas.
CurvyLine component
A component that draws a line between 2 elements looks like this:
const CurvyLine = ({ sourceRef, targetRef, canvasRef }) => {const sourceRect = sourceRef?.current?.getBoundingClientRect()const targetRect = targetRef?.current?.getBoundingClientRect()const canvasRect = canvasRef?.current?.getBoundingClientRect()if (!sourceRect || !targetRect || !canvasRect) {// avoid render when refs undefinedreturn null}// bezier curve generatorconst curve = d3.linkHorizontal()// calculate positionsconst source = [sourceRect.left + sourceRect.width / 2 - canvasRect.left,sourceRect.top + sourceRect.height / 2 - canvasRect.top,]const target = [targetRect.left + targetRect.width / 2 - canvasRect.left,targetRect.top + targetRect.height / 2 - canvasRect.top,]return <path d={curve({ source, target })} stroke="black" fill="none" />}
We draw the curve in 5 steps:
- Measure all the elements we need β source, target, and canvas
- Bail if the refs aren't ready. React doesn't have the DOM elements on its first rendering pass. This is important later, you'll see π‘
- Use
d3.linkHorizontal
to generate the curve - Prepare SVG coordinates for the curve β making sure to subtract the canvas coordinates
- Render a
<path>
element
Rendering the CurvyLine
We use an absolutely positioned SVG element to create a canvas that sits behind our elements.
<div className="grid-2-cols"><!-- position: absolute takes it out of the grid --><svg className="absolute-svg"></svg><div className="column"><button>Button 1</button><button>Button 2</button></div><div className="column"><button>Button 3</button><button>Button 4</button></div></div>
We then add our <CurvyLine>
component and pass the refs it needs:
// prepare the refsconst button1Ref = useRef(null)const button4Ref = useRef(null)const canvasRef = useRef(null)// ...;<div className="grid-2-cols"><svg className="absolute-svg" ref={canvasRef}><CurvyLinesourceRef={button1Ref}targetRef={button4Ref}canvasRef={canvasRef}/></svg><div className="column"><button ref={button1Ref}>Button 1</button><button>Button 2</button></div><div className="column"><button>Button 3</button><button ref={button4Ref}>Button 4</button></div></div>
useRef
creates a piece of mutable state and passing it to a DOM element as ref={}
creates a reference to that node. We can use that to call any DOM-level APIs βΒ getBoundingClientRect
in our case.
But nothing happens π€
Stubbornly, our curvy line doesn't show up.
That's because we're 1 render behind. CodeSandbox re-renders on code changes so you can make the line show up by poking the code.
Another way to show you we're behind is to add a piece of state that re-renders when you click a button.
const [counter, setCounter] = useState(0)function clicked() {setCounter(counter + 1)}// ...;<button ref={button1Ref} onClick={clicked}>Click me</button>
Click the button and trigger a re-render. We don't even use the state anywhere!
Try it yourself π
Dynamic elements make the situation worse. Sometimes rendered, sometimes not.
Look at that SVG curve staying 1 render behind!
That's because React can't know the element a ref
is going to represent until a rendering pass is complete. And when the pass is complete, React doesn't go back to re-render your component that depends on a ref.
useEffect
is a common attempt to solve this:
const ref = useRef(null)useEffect(() => {if (ref.current) {// measure and save into state}}, [ref])
But that doesn't work. The effect runs too late and you may end up with stale measurements in your state.
Make it perfect with useLayoutEffect
The perfect solution I've found is to combine useLayoutEffect
and a dirty forced re-render. Dirty because it feels wrong.
const [forceRender, setForceRender] = useState(0)useLayoutEffect(() => {setForceRender(forceRender + 1)}, [ref])
Like the button before, we force a re-render by updating state. Except now that happens when the ref
updates instead of when a user clicks something.
And because it's a useLayoutEffect
, the update runs synchronously during initial render. You get zero delay:
We put the trick in <CurvyLine>
to make the component more self-contained and easier to use.
const CurvyLine = ({ sourceRef, targetRef, canvasRef }) => {const sourceRect = sourceRef?.current?.getBoundingClientRect();const targetRect = targetRef?.current?.getBoundingClientRect();const canvasRect = canvasRef?.current?.getBoundingClientRect();// force a 2nd-pass re-render when refs become availableconst [forceRender, setForceRender] = useState(0);useLayoutEffect(() => {setForceRender(forceRender + 1);}, [sourceRef, targetRef, canvasRef]);if (!sourceRect || !targetRect || !canvasRect) {// avoid render when refs undefinedreturn null;}// ...
React doesn't know this component changed when you un-render a button because refs are stable. You have to prompt it with a conditional rendering:
{showButton1 && (<CurvyLinesourceRef={button1Ref}targetRef={button4Ref}canvasRef={canvasRef}/>)}
Now you may think this is silly β why did we use useLayoutEffect
, if we're un-rendering the line anyway? Because without the effect you run into the 1-step-behind issue and never render the line.
Conclusion
You can use refs and getBoundingClientRect to adjust your drawing to responsive layouts and dynamically positioned elements. Use a forced 2nd pass re-render to ensure it stays in sync.
It's not stupid if it works βοΈ
Cheers,
~Swizec
PS: to make your SVG follow responsive layout changes live as you resize the screen (only designers and developers do that), you'll have to trigger a re-render when screen size changes
About the Author
Hi, Iβm Swizec Teller. I help coders become software engineers.
Story time π
React+D3 started as a bet in April 2015. A friend wanted to learn React and challenged me to publish a book. A month later React+D3 launched with 79 pages of hard earned knowledge.
In April 2016 it became React+D3 ES6. 117 pages and growing beyond a single big project it was a huge success. I kept going, started live streaming, and publishing videos on YouTube.
In 2017, after 10 months of work, React + D3v4 became the best book I'd ever written. At 249 pages, many examples, and code to play with it was designed like a step-by-step course. But I felt something was missing.
So in late 2018 I rebuilt the entire thing as React for Data Visualization β a proper video course. Designed for busy people with real lives like you. Over 8 hours of video material, split into chunks no longer than 5 minutes, a bunch of new chapters, and techniques I discovered along the way.
React for Data Visualization is the best way to learn how to build scalable dataviz components your whole team can understand.
Some of my work has been featured in π