Drag to Select

Oct 15, 2024

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:

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299

And this is what the interaction feels like:

Basic markup

Let's start building our demo by rendering a grid of items.

  1. We can initialize a array of 30 items with values from 0 to 30

    const items = Array.from({ length: 30 }, (_, i) => i + '')
  2. 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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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.

  1. Let's start by creating state to hold this rectangle. We'll use the DOMRect class since it's the geometry type of the web, and we'll call this state selectionRect.

    const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
  2. Next we need to add an onPointerDown to the div surrounding our items. We'll call this div the "container div" since it contains our items. This event handler will initialize a DOMRect describing 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.

  3. Then in onPointerMove, we update our selectionRect based 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.

  4. In onPointerUp we reset our state.

    onPointerUp={() => {
      setSelectionRect(null)
    }}
  5. 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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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.

  1. Let's create our own DOMVector class with x, y, magnitudeX, and magnitudeY.

    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),
        )
      }
    }
  2. Next we need to update our selectionRect state to store a dragVector, and at render time we can derive the DOMRect of our selection from this state.

    
    const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
    
    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.

  3. Finally, we can replace our DOMRect constructor calls with DOMVector constructor calls, and update our onPointerMove to calculate magnitudeX and magnitudeY instead of width and height.

    
    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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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.

  1. 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)
  2. Then we can add a data-item attribute 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.

  3. 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
    }
  4. Once updateSelectedItems has looped through each of the items it pushes the local state to the selectedItems component state.

    const next: Record<string, boolean> = {}
    const containerRect = containerRef.current.getBoundingClientRect()
    containerRef.current.querySelectorAll('[data-item]').forEach(el => {
      /* ... */
    })
     
    setSelectedItems(next)
  5. 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>
  6. 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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

Drag and drop polish

Selection is working, which is great, but there are three glaring issues.

  1. Our pointer is triggering pointer events, which you can see below because of the temporary hover:bg-pink class I added.

  2. Our drag is triggering text selection.

  3. 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.

  1. First let's create some state for if we are dragging or not.

    
    const [isDragging, setIsDragging] = useState(false)
  2. Next, we need to be able to calculate how far the user has travelled by combining the magnitudeX and magnitudeY into 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),
        )
      }
    }
  3. And then we can update the onPointerMove to not update our drag state until the drag is longer than 10pxs.

    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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

Adding deselection

At this point, there isn't a good way to deselect items.

  1. Let's add pointer deselection by clearing selection in onPointerUp when there isn't a current event.

    
    if (!isDragging) {
      setSelectedItems({})
      setDragVector(null)
    } else {
      setDragVector(null)
      setIsDragging(false)
    
    }
  2. 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()
  3. Then we'll add an onKeyDown for "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.

  4. 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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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.

  1. 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 indepentantly in onScroll and onPointerMove.

    This keeps the math simple in both, and ensures we don't need the pointer's position in onScroll.

  2. Our drag state is now in two vectors, so we need a way to add them together when deriving our selectionRect.

    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.

  3. Next we need to create an onScroll event handler to update the scrollVector and 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)
    }}
  4. Now we can update how we derive our selectionRect to include our scrollVector.

    
    const selectionRect =
      dragVector && isDragging ? dragVector.toDOMRect() : null
    
    const selectionRect =
      dragVector && scrollVector && isDragging
        ?
          dragVector.add(scrollVector).toDOMRect()
        : null
  5. And finally, to make the container scrollable, we can use classes for max-height and grid-template-columns

    
    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',
      )
    }

    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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299

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.

  1. 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 DOMVector to a DOMRect. Let's add a clamp method to 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),
      )
    }
  2. Then we can use it with the scrollWidth and scrollHeight of our container to prevent the selectionRect from 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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299

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.

  1. Unfortunately, there isn't a "onDraggingCloseToTheEdge" event handler. We'll need to setup a requestAnimationFrame when 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])
  2. Within this RAF, we need to find the pointer's position relative to the container. Even though useEffect isn't an event handler with access to clientX and clientY, we can still get our pointer's position by calculating the terminal point of our dragVector.

    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.

  3. If we should scroll down, then we set a variable called top to 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.

  4. In the last part of our RAF, we use the calculated top value 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.

selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299

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 👋

Subscribe to the newsletter

A monthly no filler update.

Contact me at