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.
Here is a sneak peak at the final demo:
And this is what the interaction feels like:
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
selectionRectwill be positioned absolutely to the containerdiv, we want to store it relative to the container's position. We do this by subtracting the container'sx/ycoordinates from our cursor'sx/ycoordinates.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/yposition is also relative to the container, so we offsetselectionRect'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, }} /> ) }
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 [1].
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 [selectionRect, setSelectionRect] = useState<DOMRect | null>(null) const [dragVector, setDragVector] = useState<DOMVector | null>(null) const selectionRect = dragVector ? dragVector.toDOMRect() : nullI 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. ADOMRectis what should be rendered, but aDOMRectis 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 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) const nextDragVector = new DOMVector( dragVector.x, dragVector.y, e.clientX - containerRect.x - dragVector.x, e.clientY - containerRect.y - dragVector.y, ) setDragVector(nextDragVector)
/* ... */
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),
)
}
}
function Root() {
const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
const [dragVector, setDragVector] = useState<DOMVector | null>(null)
const selectionRect = dragVector ? dragVector.toDOMRect() : 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(
setDragVector(
new DOMVector(
e.clientX - containerRect.x,
e.clientY - containerRect.y,
0,
0,
),
)
}}
onPointerMove={e => {
if (selectionRect == null) return
if (dragVector == 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)
const nextDragVector = new DOMVector(
dragVector.x,
dragVector.y,
e.clientX - containerRect.x - dragVector.x,
e.clientY - containerRect.y - dragVector.y,
)
setDragVector(nextDragVector)
}}
onPointerUp={() => {
setSelectionRect(null)
setDragVector(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"
>
{/* ... */}
</div>
</div>
)
}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
DOMRectrelative 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>
import { useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import clsx from 'clsx'
/* ... */
function intersect(rect1: DOMRect, rect2: DOMRect) {
if (rect1.right < rect2.left || rect2.right < rect1.left) return false
if (rect1.bottom < rect2.top || rect2.bottom < rect1.top) return false
return true
}
function Root() {
const [dragVector, setDragVector] = useState<DOMVector | null>(null)
const [selectedItems, setSelectedItems] = useState<Record<string, boolean>>(
{},
)
const containerRef = useRef<HTMLDivElement>(null)
const updateSelectedItems = useCallback(function updateSelectedItems(
dragVector: DOMVector,
) {
if (containerRef.current == null) return
const next: Record<string, boolean> = {}
const containerRect = containerRef.current.getBoundingClientRect()
containerRef.current.querySelectorAll('[data-item]').forEach(el => {
if (containerRef.current == null || !(el instanceof HTMLElement)) return
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,
)
if (!intersect(dragVector.toDOMRect(), translatedItemRect)) return
if (el.dataset.item && typeof el.dataset.item === 'string') {
next[el.dataset.item] = true
}
})
setSelectedItems(next)
}, [])
const selectionRect = dragVector ? dragVector.toDOMRect() : null
return (
<div>
<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>
<div
ref={containerRef}
onPointerMove={e => {
/* ... */
updateSelectedItems(nextDragVector)
}}
/* ... */
>
{items.map(item => (
<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>
))}
{/* ... */}
</div>
</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, which you can see below because of the temporary
hover:bg-pinkclass I added. -
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 accross 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 -translate-y-0.5'
'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 [2] 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) }}
class DOMVector {
/* ... */
getDiagonalLength(): number {
return Math.sqrt(
Math.pow(this.magnitudeX, 2) + Math.pow(this.magnitudeY, 2),
)
}
}
/* ... */
function Root() {
/* ... */
const [isDragging, setIsDragging] = useState(false)
/* ... */
const selectionRect = dragVector ? dragVector.toDOMRect() : null
const selectionRect = dragVector && isDragging ? dragVector.toDOMRect() : null
return (
<div>
{/* ... */}
<div
ref={containerRef}
onPointerMove={e => {
/* ... */
if (!isDragging && nextDragVector.getDiagonalLength() < 10) return
setIsDragging(true)
setDragVector(nextDragVector)
updateSelectedItems(nextDragVector)
}}
onPointerUp={() => {
setDragVector(null)
setIsDragging(false)
}}
>
{/* ... */}
</div>
</div>
)
}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
tabIndexour container is not focusable and using-1prevents our container from being in the tab order. AddingpreventDefault()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.
'relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5' 'relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black focus:outline-none focus:border-dashed -translate-y-0.5'
/* ... */
function Root() {
/* ... */
return (
<div>
{/* ... */}
<div
ref={containerRef}
onPointerDown={e => {
/* ... */
}}
onPointerMove={e => {
/* ... */
selection?.removeAllRanges()
setIsDragging(true)
containerRef.current?.focus()
setDragVector(nextDragVector)
updateSelectedItems(nextDragVector)
}}
onPointerUp={() => {
if (!isDragging) {
setSelectedItems({})
setDragVector(null)
} else {
setDragVector(null)
setIsDragging(false)
}
}}
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"
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"
tabIndex={-1}
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault()
setSelectedItems({})
setDragVector(null)
}
}}
>
{/* ... */}
</div>
</div>
)
}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
scrollVectorfromdragVectorso that we can update them indepentantly inonScrollandonPointerMove.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
addmethod onDragVector. -
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 && isDragging ? dragVector.toDOMRect() : null 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-columnsclassName="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" 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: 30 }, (_, i) => i + '') const items = Array.from({ length: 300 }, (_, i) => i + '')
const items = Array.from({ length: 30 }, (_, i) => i + '')
const items = Array.from({ length: 300 }, (_, i) => i + '')
class DOMVector {
/* ... */
add(vector: DOMVector): DOMVector {
return new DOMVector(
this.x + vector.x,
this.y + vector.y,
this.magnitudeX + vector.magnitudeX,
this.magnitudeY + vector.magnitudeY,
)
}
}
function Root() {
const [scrollVector, setScrollVector] = useState<DOMVector | null>(null)
/* ... */
const updateSelectedItems = useCallback(function updateSelectedItems(
dragVector: DOMVector,
scrollVector: DOMVector,
) {
if (containerRef.current == null) return
const next: Record<string, boolean> = {}
const containerRect = containerRef.current.getBoundingClientRect()
containerRef.current.querySelectorAll('[data-item]').forEach(el => {
if (containerRef.current == null || !(el instanceof HTMLElement)) return
const itemRect = el.getBoundingClientRect()
const x = itemRect.x - containerRect.x
const y = itemRect.y - containerRect.y
const x = itemRect.x - containerRect.x + containerRef.current.scrollLeft
const y = itemRect.y - containerRect.y + containerRef.current.scrollTop
const translatedItemRect = new DOMRect(
x,
y,
itemRect.width,
itemRect.height,
)
if (
!intersect(dragVector.add(scrollVector).toDOMRect(), translatedItemRect)
)
return
if (el.dataset.item && typeof el.dataset.item === 'string') {
next[el.dataset.item] = true
}
})
setSelectedItems(next)
}, [])
const selectionRect = dragVector && isDragging ? dragVector.toDOMRect() : null
const selectionRect =
dragVector && scrollVector && isDragging
?
dragVector.add(scrollVector).toDOMRect()
: null
return (
<div>
{/* ... */}
<div
ref={containerRef}
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)
}}
onPointerDown={e => {
/* ... */
setScrollVector(
new DOMVector(
e.currentTarget.scrollLeft,
e.currentTarget.scrollTop,
0,
0,
),
)
}}
onPointerMove={e => {
if (dragVector == null) return
if (dragVector == null || scrollVector == null) return
/* ... */
updateSelectedItems(nextDragVector)
updateSelectedItems(nextDragVector, scrollVector)
}}
onPointerUp={() => {
/* ... */
setScrollVector(null)
}}
onKeyDown={e => {
/* ... */
setScrollVector(null)
}}
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"
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',
)}
>
{/* ... */}
</div>
</div>
)
}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()
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
class DOMVector {
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),
)
}
}
function Root() {
const containerRef = useRef<HTMLDivElement>(null)
const selectionRect =
dragVector && scrollVector && isDragging
dragVector && scrollVector && isDragging && containerRef.current
? dragVector
.add(scrollVector)
.clamp(
new DOMRect(
0,
0,
containerRef.current.scrollWidth,
containerRef.current.scrollHeight,
),
)
.toDOMRect()
: null
return {
<div
/* ... */
ref={containerRef}
>
{/* ... */}
</div>
}
}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
useEffectto 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
DOMVectorfor 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 < 20When your pointer is within the container and not within
20pxof the edge, this value is greater than20px, so we won't scroll. Otherwise, it's less than20px, 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 casesIf the pointer is
19pxfrom the edge of the container and the container has a height of100pxthen this value is1(20 - 100 + 81 = 1).As you approach the edge this value increases.
If you are
1pxfrom the edge of the container with a100pxheight then this value is19px(20 - 100 + 99 = 19).This allows the user to control the speed of the scroll, while capping the value at
15pxto prevent over-scrolling. -
In the last part of our RAF, we use the calculated
topvalue to scroll the container.containerRef.current.scrollBy({ left, top, })
class DOMVector {
/* ... */
toTerminalPoint(): DOMPoint {
return new DOMPoint(this.x + this.magnitudeX, this.y + this.magnitudeY)
}
}
/* ... */
function Root(){
/* ... */
useEffect(() => {
if (!isDragging || containerRef.current == null) return
let handle = requestAnimationFrame(scrollTheLad)
return () => cancelAnimationFrame(handle)
function clamp(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max)
}
function scrollTheLad() {
if (containerRef.current == null || dragVector == null) return
const currentPointer = dragVector.toTerminalPoint()
const containerRect = containerRef.current.getBoundingClientRect()
const shouldScrollRight =
containerRect.width - currentPointer.x < 20
const shouldScrollLeft = currentPointer.x < 20
const shouldScrollDown =
containerRect.height - currentPointer.y < 20
const shouldScrollUp = currentPointer.y < 20
const left = shouldScrollRight
? clamp(20 - containerRect.width + currentPointer.x, 0, 15)
: shouldScrollLeft
? -1 * clamp(20 - currentPointer.x, 0, 15)
: undefined
const top = shouldScrollDown
? clamp(20 - containerRect.height + currentPointer.y, 0, 15)
: shouldScrollUp
? -1 * clamp(20 - currentPointer.y, 0, 15)
: undefined
if (top === undefined && left === undefined) {
handle = requestAnimationFrame(scrollTheLad)
return
}
containerRef.current.scrollBy({
left,
top,
})
handle = requestAnimationFrame(scrollTheLad)
}
}, [isDragging, dragVector, updateSelectedItems])
return (/* ... */)
}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.
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 👋