Drag to Select
React Drag to Select
This past summer, I led a project at Makeswift to rework our file manager. Makeswift is a website builder and many of our users have hundreds of files. To manage hundreds of anything you need bulk operations, but bulk operations aren't helpful if selecting things is cumbersome, so drag selection was a key part of my vision for making Makeswift's file manager feel native.
But creating drag selection was harder than I thought it would be. There was something fundamentally wrong with how I was representing user interactions in state. In this post, we'll recreate drag selection, and along the way I'll share what I learned about state management that dramatically simplified the solution.
Basic Markup
Let's start building our demo by rendering a grid of items.
- We can initialize a array of 30 items with values from 0 to 30
const items = Array.from({ length: 30 }, (_, i) => i + '')
- And then map through them rendering divs like so:
items.map(item => ( <div className={clsx( 'border-2 size-10 border-black flex justify-center items-center', )} key={item}> {item} </div> ))
const items = Array.from({ length: 30 }, (_, i) => i + '') function Root() { return ( <div> <div className="px-2 border-2 border-black">selectable area</div> <div className="relative z-0 grid grid-cols-8 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5"> {items.map(item => ( <div className="border-2 size-10 border-black flex justify-center items-center" key={item} > {item} </div> ))} </div> </div> ) }
This gives us a simple grid.
Drawing the Selection Box
Now that we have a grid of items, let's render a "selection rectangle" on drag. This rectangle is the indicator of what a user is selecting.
- Let's start by creating state to hold this rectangle. We'll use the
DOMRectclass since it's the geometry type of the web, and we'll call this stateselectionRect.
const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
- Next we need to add an
onPointerDownto thedivsurrounding our items. We'll call thisdivthe "containerdiv" since it contains our items. This event handler will initialize aDOMRectdescribing the area of our drag.
onPointerDown={e => { if (e.button !== 0) return const containerRect = e.currentTarget.getBoundingClientRect() setSelectionRect( new DOMRect( e.clientX - containerRect.x, e.clientY - containerRect.y, 0, 0, ), ) }}
Since the selectionRect will be positioned absolutely to the container div, we want to store it relative to the container's position. We do this by subtracting the container's x/y coordinates from our cursor's x/y coordinates.
Since we only want to start drag events from the left pointer button, we can early return when e.button !== 0.
- Then in
onPointerMove, we update ourselectionRectbased on the next position of the pointer.
onPointerMove={e => { if (selectionRect == null) return const containerRect = e.currentTarget.getBoundingClientRect() const x = e.clientX - containerRect.x const y = e.clientY - containerRect.y const nextSelectionRect = new DOMRect( Math.min(x, selectionRect.x), Math.min(y, selectionRect.y), Math.abs(x - selectionRect.x), Math.abs(y - selectionRect.y), ) setSelectionRect(nextSelectionRect) }}
This new x/y position is also relative to the container, so we offset selectionRect's position based on the container.
- In
onPointerUpwe reset our state.
onPointerUp={() => { setSelectionRect(null) }}
- And finally we render the
selectionRect.
{ selectionRect && ( <div className="absolute border-black border-2 bg-black/30" style={{ top: selectionRect.y, left: selectionRect.x, width: selectionRect.width, height: selectionRect.height, }} /> ) }
Complete implementation:
import { useState } from 'react' const items = Array.from({ length: 30 }, (_, i) => i + '') function Root() { const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null) return ( <div> <div className="px-2 border-2 border-black">selectable area</div> <div onPointerDown={e => { if (e.button !== 0) return const containerRect = e.currentTarget.getBoundingClientRect() setSelectionRect( new DOMRect( e.clientX - containerRect.x, e.clientY - containerRect.y, 0, 0, ), ) }} onPointerMove={e => { if (selectionRect == null) return const containerRect = e.currentTarget.getBoundingClientRect() const x = e.clientX - containerRect.x const y = e.clientY - containerRect.y const nextSelectionRect = new DOMRect( Math.min(x, selectionRect.x), Math.min(y, selectionRect.y), Math.abs(x - selectionRect.x), Math.abs(y - selectionRect.y), ) setSelectionRect(nextSelectionRect) }} onPointerUp={() => { setSelectionRect(null) }} className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5" > {items.map(item => ( <div className="border-2 size-10 border-black flex justify-center items-center" key={item} > {item} </div> ))} {selectionRect && ( <div className="absolute border-black border-2 bg-black/30" style={{ top: selectionRect.y, left: selectionRect.x, width: selectionRect.width, height: selectionRect.height, }} /> )} </div> </div> ) }
On drag we now have a DOMRect representing our selection area.
Using a Vector
From a cursory look, our demo seems to be working, but there is an edge case.
The x and y of our DOMRect together represent the start of a drag, and height and width are non negative values that together represent how far has been dragged.
When we drag left and up we have to reset the x and y of our DOMRect since width and height can't be negative.
This causes our starting point to reset.
The root issue is height and width are expressions of magnitude without direction.
We need a datatype that expresses both the magnitude and direction of the user's action.
This concept of magnitude + direction is called a vector.
A vector quantity is one that can't be expressed as a single number. Instead it is comprised of direction and magnitude.
DOMRects are so close to being vector quantities but the names width and height limit your thinking to one quadrant.
The DOMRect constructor doesn't throw when you pass negative width and height values, but names are important.
Having better names will make reasoning about this interaction easier.
- Let's create our own
DOMVectorclass withx,y,magnitudeX, andmagnitudeY.
class DOMVector { constructor( readonly x: number, readonly y: number, readonly magnitudeX: number, readonly magnitudeY: number, ) { this.x = x this.y = y this.magnitudeX = magnitudeX this.magnitudeY = magnitudeY } toDOMRect(): DOMRect { return new DOMRect( Math.min(this.x, this.x + this.magnitudeX), Math.min(this.y, this.y + this.magnitudeY), Math.abs(this.magnitudeX), Math.abs(this.magnitudeY), ) } }
- Next we need to update our
selectionRectstate to store adragVector, and at render time we can derive theDOMRectof our selection from this state.
const [dragVector, setDragVector] = useState<DOMVector | null>(null) const selectionRect = dragVector ? dragVector.toDOMRect() : null
I generally try to avoid components that derive values on render. I think this is why I have tried to store drag interactions as DOMRects for so long. A DOMRect is what should be rendered, but a DOMRect is a lossy form of storing the data, so this derivation can't be avoided.
- Finally, we can replace our
DOMRectconstructor calls withDOMVectorconstructor calls, and update ouronPointerMoveto calculatemagnitudeXandmagnitudeYinstead ofwidthandheight.
const nextDragVector = new DOMVector( dragVector.x, dragVector.y, e.clientX - containerRect.x - dragVector.x, e.clientY - containerRect.y - dragVector.y, ) setDragVector(nextDragVector)
Our selection rect is now being rendered in all directions without being reset.
Intersection State
Now that we are drawing the selectionRect, we need to actually select things.
We'll do this by iterating each item's DOMRect to see if it intersects with our selectionRect.
The most common way of getting a DOMRect in React is by storing a ref and using getBoundingClientRect on that ref when you need its DOMRect.
In our case, this would mean storing an array of refs to each item.
Storing a data structure of refs has always seemed unwieldy to me. The structure of our data is already expressed in the structure of the DOM, and when you represent that structure in two places, your component becomes harder to iterate.
To avoid this issue, libraries like RadixUI use data attributes and querySelector to find the related DOM node at event time.
This is what we'll be doing as well.
- Let's start by creating state for selection
const [selectedItems, setSelectedItems] = useState<Record<string, boolean>>({})
and getting a ref to the container div.
const containerRef = useRef<HTMLDivElement>(null)
- Then we can add a
data-itemattribute to each item.
items.map(item => ( <div data-item={item} /> ))
This attribute should uniquely identify each item. For the demo, we will use the index of the item within our array, but in the Makeswift's files manager these boxes are actual files and folders, so we used actual ids.
- Now let's create a helper function called
updateSelectedItems.
const updateSelectedItems = useCallback(function updateSelectedItems( dragVector: DOMVector, ) { /* ... */ }, [])
This function finds all items,
containerRef.current.querySelectorAll('[data-item]').forEach(el => { if (containerRef.current == null || !(el instanceof HTMLElement)) return /* ... */ })
get's their DOMRect relative to the container,
const itemRect = el.getBoundingClientRect() const x = itemRect.x - containerRect.x const y = itemRect.y - containerRect.y const translatedItemRect = new DOMRect(x, y, itemRect.width, itemRect.height)
and checks for intersection with the selectionRect.
if (!intersect(dragVector.toDOMRect(), translatedItemRect)) return if (el.dataset.item && typeof el.dataset.item === 'string') { next[el.dataset.item] = true }
- Once
updateSelectedItemshas looped through each of the items it pushes the local state to theselectedItemscomponent state.
const next: Record<string, boolean> = {} const containerRect = containerRef.current.getBoundingClientRect() containerRef.current.querySelectorAll('[data-item]').forEach(el => { /* ... */ }) setSelectedItems(next)
- To make it obvious that we selected something, let's create an indicator for the number of selected items.
<div className="flex flex-row justify-between"> <div className="px-2 border-2 border-black">selectable area</div> {Object.keys(selectedItems).length > 0 && ( <div className="px-2 border-2 border-black"> count: {Object.keys(selectedItems).length} </div> )} </div>
- And update the items to have different styles when they are selected.
<div data-item={item} className={clsx( 'border-2 size-10 border-black flex justify-center items-center', selectedItems[item] ? 'bg-black text-white' : 'bg-white text-black', )} key={item} > {item} </div>
Try dragging around our container. Our items are now selectable.
Drag and Drop Polish
Selection is working, which is great, but there are three glaring issues.
- Our pointer is triggering pointer events
- Our drag is triggering text selection
- And our drag is triggered on click
Preventing Pointer Events During Drag with setPointerCapture
To solve the first issue we can simply use setPointerCapture.
onPointerDown={e => { if (e.button !== 0) return const containerRect = e.currentTarget.getBoundingClientRect() setDragVector( new DOMVector( e.clientX - containerRect.x, e.clientY - containerRect.y, 0, 0, ), ) e.currentTarget.setPointerCapture(e.pointerId) }}
This tells the browser: "Until this pointer cycle is complete only trigger pointer events from this element."
In our case, setPointerCapture prevents the hover styles from being applied during a drag.
Preventing Text Selection with user-select: none
To solve our second issue of accidental text selection I recommend using user-select: none.
When I originally wrote this blog post I had a fancy way of trying to guess if the user was selecting text or items.
But across browsers the behavior wasn't consistent, and I decided to simplify this section.
Making drag and text selection work together is an unsolved problem, but there are some pretty creative solutions. In Notion, if you drag from outside the block area, a drag selection is started, but if you drag from inside the block area, a text selection is started.
Depending on your situation you may be able to do something similar or come up with another creative solution. In Makeswift, I ended up blocking text selection, but clicking your selected file's name opens a rename option where you can copy your file's name.
Back in our demo, let's use select-none on the container to prevent text selection.
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none -translate-y-0.5"
Preventing Premature Drags by Adding a Threshold
Our final issue is due to the code assuming all onPointerDown events are for dragging.
In reality, the user might be clicking a button or focusing an input.
So let's start dragging in our onPointerMove and only after the user has dragged a threshold distance.
- First let's create some state for if we are dragging or not.
const [isDragging, setIsDragging] = useState(false)
- Next, we need to be able to calculate how far the user has travelled by combining the
magnitudeXandmagnitudeYinto a diagonal distance. We can use Pythagorean theorem to find this distance.
class DOMVector { /* ... */ getDiagonalLength(): number { return Math.sqrt( Math.pow(this.magnitudeX, 2) + Math.pow(this.magnitudeY, 2), ) } }
- And then we can update the
onPointerMoveto not update our drag state until the drag is longer than10pxs.
onPointerMove={e => { /* ... */ if (!isDragging && nextDragVector.getDiagonalLength() < 10) return setIsDragging(true) setDragVector(nextDragVector) updateSelectedItems(nextDragVector) }}
These little bits of polish have really added up, and our interaction is looking much better.
Adding Deselection
At this point, there isn't a good way to deselect items.
- Let's add pointer deselection by clearing selection in
onPointerUpwhen there isn't a current event.
if (!isDragging) { setSelectedItems({}) setDragVector(null) } else { setDragVector(null) setIsDragging(false) }
- It would also be great to clear selection when the user clicks "Escape."
For that, we'll need to focus the container in our onPointerMove.
containerRef.current?.focus()
- Then we'll add an
onKeyDownfor "Escape" that clears the selection.
tabIndex={-1} onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault() setSelectedItems({}) setDragVector(null) } }}
Without a tabIndex our container is not focusable and using -1 prevents our container from being in the tab order. Adding preventDefault() will prevent the escape key press from closing any dialogs or resulting in unintentional behavior.
- And finally we can update the focus styles of the container so our focus and selection styles are distinct.
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none focus:outline-none focus:border-dashed -translate-y-0.5"
Deselection is now operational.
Scrolling
Trying to update drag selection to work in a scrollable region was the forcing function that made me create DOMVector.
Up to that point I had created a dragStartPoint ref to prevent my starting point from being reset.
But scroll events don't include clientX and clientY, so I couldn't easily derive my selectionRect in my onScroll event handler.
My only option was to cache the last pointer event so I could still update selectionRect within onScroll.
My state was a collection of refs, derived state, and a cached event. Not very approachable. Switching to vectors fixed these issues.
- Let's start by representing our scroll as a vector.
const [scrollVector, setScrollVector] = useState<DOMVector | null>(null)
We want to separate scrollVector from dragVector so that we can update them independently in onScroll and onPointerMove.
This keeps the math simple in both, and ensures we don't need the pointer's position in onScroll.
- Our drag state is now in two vectors, so we need a way to
addthem together when deriving ourselectionRect.
add(vector: DOMVector): DOMVector { return new DOMVector( this.x + vector.x, this.y + vector.y, this.magnitudeX + vector.magnitudeX, this.magnitudeY + vector.magnitudeY, ) }
We can create an add method on DragVector.
- Next we need to create an
onScrollevent handler to update thescrollVectorand our selection.
onScroll={e => { if (dragVector == null || scrollVector == null) return const { scrollLeft, scrollTop } = e.currentTarget const nextScrollVector = new DOMVector( scrollVector.x, scrollVector.y, scrollLeft - scrollVector.x, scrollTop - scrollVector.y, ) setScrollVector(nextScrollVector) updateSelectedItems(dragVector, nextScrollVector) }}
- Now we can update how we derive our
selectionRectto include ourscrollVector.
const selectionRect = dragVector && scrollVector && isDragging ? dragVector.add(scrollVector).toDOMRect() : null
- And finally, to make the container scrollable, we can use classes for
max-heightandgrid-template-columns
className={clsx( 'relative max-h-96 overflow-auto z-10 grid grid-cols-[repeat(20,min-content)] gap-4 p-4', 'border-2 border-black select-none -translate-y-0.5 focus:outline-none focus:border-dashed', )}
and render a bunch more items to cause overflow.
const items = Array.from({ length: 300 }, (_, i) => i + '')
You can now scroll to select more items.
Preventing Scroll Overflow
At this point scroll is working, but nothing is preventing our selectionRect from overflowing the container.
The selectionRect overflows, so the scrollable area grows, so the selectionRect grows and overflows in a cycle.
Let's fix this by clamping the vector to the bounds of the scroll area.
- A "clamp" function is for keeping a value within certain bounds. Most of the time you are clamping a number but the concept also works for clamping our
DOMVectorto aDOMRect. Let's add aclampmethod toDOMVector.
clamp(vector: DOMRect): DOMVector { return new DOMVector( this.x, this.y, Math.min(vector.width - this.x, this.magnitudeX), Math.min(vector.height - this.y, this.magnitudeY), ) }
- Then we can use it with the
scrollWidthandscrollHeightof our container to prevent theselectionRectfrom causing overflow.
dragVector .add(scrollVector) .clamp( new DOMRect( 0, 0, containerRef.current.scrollWidth, containerRef.current.scrollHeight, ), ) .toDOMRect()
The selectionRect is now clamped to prevent overflowing the container.
Auto Scrolling
There is one feature of text selection that we are still missing.
When a user drags to the edge of our scrollable container it should scroll automatically.
- Unfortunately, there isn't a "onDraggingCloseToTheEdge" event handler. We'll need to setup a
requestAnimationFramewhen the user is dragging so that we can check if they are dragging to the edge.
requestAnimationFrame, sometimes called RAF, is an API for doing something every time your browser renders. In our case we want to setup a RAF that checks if the user is dragging close to the container's edge. Our demo does this for each side, but we'll focus on the logic for auto scrolling down to keep things simple.
We'll start by creating a useEffect to sets up our RAF.
useEffect(() => { if (!isDragging) return let handle = requestAnimationFrame(scrollTheLad) return () => cancelAnimationFrame(handle) function scrollTheLad() { /* ... */ handle = requestAnimationFrame(scrollTheLad) } }, [isDragging, dragVector, updateSelectedItems])
- Within this RAF, we need to find the pointer's position relative to the container. Even though
useEffectisn't an event handler with access toclientXandclientY, we can still get our pointer's position by calculating the terminal point of ourdragVector.
Let's create a method on DOMVector for finding the terminal point.
toTerminalPoint(): DOMPoint { return new DOMPoint(this.x + this.magnitudeX, this.y + this.magnitudeY) }
And we can use this terminal point to decide whether or not to auto scroll.
const currentPointer = dragVector.toTerminalPoint() const containerRect = containerRef.current.getBoundingClientRect() const shouldScrollDown = containerRect.height - currentPointer.y < 20
When your pointer is within the container and not within 20px of the edge, this value is greater than 20px, so we won't scroll. Otherwise, it's less than 20px, so we scroll.
- If we should scroll down, then we set a variable called
topto a positive value.
const top = shouldScrollDown ? clamp(20 - containerRect.height + currentPointer.y, 0, 15) : // other cases
If the pointer is 19px from the edge of the container and the container has a height of 100px then this value is 1 (20 - 100 + 81 = 1).
As you approach the edge this value increases.
If you are 1px from the edge of the container with a 100px height then this value is 19px (20 - 100 + 99 = 19).
This allows the user to control the speed of the scroll, while capping the value at 15px to prevent over-scrolling.
- In the last part of our RAF, we use the calculated
topvalue to scroll the container.
containerRef.current.scrollBy({ left, top, })
Our drag selection now autoscrolls.
I would like to formally propose -TheLad naming convention for functions local to a useEffect.
The limited scope and the random nature of useEffect functions makes them the perfect spot to be a goof ball.
Conclusion
In actuality though my big takeaways from writing this post are that vectors are a great way to model user interaction, and more broadly speaking, having rich data types can greatly improve the readability of your code.
That's all for this one folks 👋