Drag to Select

Oct 11, 2024

I recently reworked the files manager within Makeswift adding drag selection similiar to the pointer multi-selection I am used to in Finder. //todo But I discovered that storing these interactions as vectors is key to creating that sweet sweet lasangna.

Here is the final example we will build together:

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 here is what the demo feels like:

Basic markup

Drag to select is the pointer driven variant of multi-selection in a grid. Let's start building our example, by rendering a grid of items.

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

    const items = new Array(30).fill(null).map((_, 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 = new Array(30).fill(null).map((_, 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 create some state to hold our 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". This event handler will initialize a rectangle 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.

    To prevent drag events from anything other than the left mouse 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, dragStartPoint.current.x),
         Math.min(y, dragStartPoint.current.y),
         Math.abs(x - dragStartPoint.current.x),
         Math.abs(y - dragStartPoint.current.y),
       )
     
       setSelectionRect(nextSelectionRect)
     }}

    This new x/y position is again 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,
          }}
        />
      )
    }

Here is the diff:


import { useState } from 'react'
 
const items = new Array(30).fill(null).map((_, 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-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>
        ))}

        {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>
  )
}

And a demo of the interaction so far. Give it a try!

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 example seems to be working, but it only works in one direction.

When dragging left/up, the starting point of our drag moves left/up. This is because the x/y of our DOMRect is the start of our drag, but height and width can't be negative. So when we drag left/up we have to reset the x/y of our DOMRect.

The root issue is width and height are expressions of magnitude not direction. We need a datatype that expresses both the magnitude and direction of the users 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 height and width 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 more descriptive names will help us make reasoning about this interaction easy.

  1. To start let's create our own DOMVector class with x, y, magnitudeX, 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. We then need to update our selectionRect state to instead store a dragVector. At render time we will derive the DOMRect of our selection from the dragVector.

    
    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 operations as DOMRects for so long. A DOMRect is what should be rendered, but a DOMRect is a lossy form of storing the state, so logically I don't think this derivation can 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)

And here is the full diff:

/* ... */
 

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 DOMRect(
              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-8 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5"
      >
        {/* ... */}
      </div>
    </div>
  )
}

Our selection now works in all directions.

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 the items in our selection. We need to iterate each item's DOMRect and see if it intersects with our selectionRect.

The most common way of getting a DOMRect in React is by storing a ref to that item. In our case, this would mean storing an array of refs to each of our items.

But storing a data structure of refs has always seemed unwieldy to me. The datastructure is usually already expressed in the structure of the DOM.
When you represent that structure in two places, your component iinevitably becomes harder to iterate on.

To avoid this problem, libraries like RadixUI add data attributes to each items and use querySelector to find the related DOM node at event time. This is what we'll be doing.

  1. Let's start by creating some state for holding our selected items

    const [selectedItems, setSelectedItems] = useState<Record<string, boolean>>(
      {},
    )

    and get a ref to the continer 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}
      />
    ))

    We'll use it to store the "identifier" of our item. In the real world, these boxes would be entities in your system so you would use ids.

  3. Now let's create a helper function called updateSelectedItems.

    const updateSelectedItems = useCallback(function updateSelectedItems(
      dragVector: DOMVector,
    ) {
      /* ... */
    }, [])

    This function finds all our items,

    containerRef.current.querySelectorAll('[data-item]').forEach(el => {
      if (containerRef.current == null || !(el instanceof HTMLElement)) return
     
      /* ... steps 3-5 ... */
    })

    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 if they intersect 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 will push the local state to the selectedItems 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>

Here is the full diff:


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(nextSelectionRect)
        }}
        /* ... */
      >
        {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>
  )
}
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

There are two problems with our DND currently.

  1. Our pointer is triggering pointer events during drag.

    // video

  2. Our dragging is triggering text selected during drag.

  3. // todo add point here about drag to select starting from context menu

Preventing pointer events during drag with setPointerCapture

To solve the first issue we can simply add setPointerCapture.

onPointerDown={e => {
    if (e.button !== 0) return
 
    const containerRect =
        e.currentTarget.getBoundingClientRect()
 
    setDragVector(
      new DOMRect(
        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 on this element directly.

Even with the goofy hover animation setPointerCapture prevents it from being triggered on drag.

Preventing text selection during drag

One way to prevent text selection would be to use select-none on the container. This shotgun approach to the problem works, but we lose the ability to select text at all.
You can imagine a scenario where each item has a URL you might want to copy or some identifier that is useful to reference this entity in another system. To preserve text selection while preventing text selection during our drag we should do two things.

  1. Only start dragging if the drag has reached a certain threshold distance
  2. If text has been selected during that time prevent our drag from occuring

Only start selection after a drag threshold

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(nextSelectionRect)
        }}
        onPointerUp={() => {
          setDragVector(null)

          setIsDragging(false)
        }}
      >
        {/* ... */}
      </div>
    </div>
  )
}
  1. the distance between two opposite corners of a rectangle is a diagonal and we can use pythagorean theorem to find this distance
  2. if our potential selection rectangle's diagonal is less than 10 we don't want to start dragging yet

Prevent drag on text selection

/* ... */
 
function Root() {
  /* ... */
  return (
    <div>
      {/* ... */}
      <div
        ref={containerRef}
        onPointerMove={e => {
          /* ... */
 
          if (!isDragging && nextDragVector.getDiagonalLength() < 10) return
 

          const selection = document.getSelection()
          const elementFromPoint = document.elementFromPoint(
            e.clientX,
            e.clientY,
          )
 
          if (
            !selection?.isCollapsed &&
            selection?.focusNode?.textContent === elementFromPoint?.textContent
          ) {
            setIsDragging(false)
            setDragVector(null)
            return
          }
 
          selection?.removeAllRanges()
          setIsDragging(true)
 
          setDragVector(nextDragVector)
          updateSelectedItems(nextDragVector)
        }}
      >
        {/* ... */}
      </div>
    </div>
  )
}

To prevent selection from occuring if text is being selected we

  1. Get the users current text selection with getSelection

  2. Get the current DOM Element that is under our cursor with elementFromPoint

  3. Then if the selection is not empty && the current hovered element text content matches the selection text content

    /// code

    we cancel the drag.

  4. Otherwise we drag as normal and remove any selection that can happen as we do

    /// code

This combination distinguishes text selection from item selection in drag.

Adding deselection

At this point, there isn't a good way to deselect items. Let's fix that.

/* ... */
 
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-8 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5"

        className="relative z-0 grid grid-cols-8 sm:grid-cols-10 gap-4 p-4 border-2 border-black 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>
  )
}

Drag to select selection is usually preserved when a user clicks outside of the container but cleared when the user clicks within the container.

We can model that by updating our onPointerUp to clear selection when there isn't a current drag.


if (!isDragging) {
  setSelectedItems({})
  setDragVector(null)
} else {
  setDragVector(null)
  setIsDragging(false)

}

Drag to select selection is also usually cleared when the esc key is hit.

To model this we can focus the container element when our drag is started and add an keyboard event listener for "Escape" that clears our selection.


tabIndex={-1}
onKeyDown={e => {
  if (e.key === 'Escape') {
    e.preventDefault()
    setSelectedItems({})
    setDragVector(null)
  }
}}
  1. We use tabIndex -1 to prevent this input from showing up in the tab order
  2. We preventDefault() to prevent this escape key press from closing any dialogs or resulting in any unintention behavior.
  3. And we update the focus styles to match our design system and to keep the focus and selection styles unique

Focusing the container, but hiding it from the tab order is a hack. Ideally we would use active descendant to enable keyboard driven multi select. Users could keyboard navigate to our container and with proper aria attributes it would indicate how to interact and which elements are selected.

Since this keyboard interaction is only for clearing drag to select operations, I am using tabIndex === -1 to prevent keyboard navigation.

I may research doing a keyboard interaction like this. Subscribe if you are interested!

Scrolling

Up until this point we have been working on a small group of items. Often times though, grids like this are scrollable and contain many items. This is where modelling our drag as a vector is going to really pay off. I wrote and rewrote this example many times but once scroll entered the picture this would iinevitably become spagetti. Having a type with descript nomenclature really pays off in the readability of this code. Ok enough hyping it up. Let's walk through it.


const items = new Array(30).fill(null).map((_, i) => i)

const items = new Array(300).fill(null).map((_, 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-8 sm:grid-cols-10 gap-4 p-4 border-2 border-black 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 -translate-y-0.5 focus:outline-none focus:border-dashed',
          )
        }
      >
        {/* ... */}
      </div>
    </div>
  )
}
  1. To start, let's represent our scroll as a vector.

    const [scrollVector, setScrollVector] = useState<DOMVector | null>(null)

    Just like our drag it has a magnitude and direction.

  2. Then we can create a add method on DragVector

     
    add(vector: DOMVector): DOMVector {
      return new DOMVector(
        this.x + vector.x,
        this.y + vector.y,
        this.magnitudeX + vector.magnitudeX,
        this.magnitudeY + vector.magnitudeY,
      )
    }

    and use it to combine our drag and scroll to create our selection Rect.

    Something I learned when making this example is that you can only get clientX and clientY from pointer events. Before I stored drag and scroll as distinct values, this meant I had to cache the last pointer event, so I could recreate the selection Rect within onScroll. That whole class of problems is avoided by storing them as separate values.

  3. Next need to create an onScroll event that capture user scroll, updates the scrollVector, and calls our updateSelectedItems helper.

    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. The DOMRect our items need to take into account the scroll of the container.

    const selectionRect = dragVector.add(scrollVector).toDOMRect()
  5. And finally, we can add a max height and col configuration for both vertical and horizontal overflow

    clsx(
      'relative max-h-96 overflow-auto z-10 grid grid-cols-[repeat(20,min-content)] gap-4 p-4',
      'border-2 border-black -translate-y-0.5 focus:outline-none focus:border-dashed',
    )

    and render a bunch more items to cause overflow

    
    const items = new Array(30).fill(null).map((_, i) => i)
    
    const items = new Array(300).fill(null).map((_, i) => i)
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

This is great. But now our container has this odd overflow.


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>
  }
}

To fix that we can clamp the selection Rect to the size of the container.

A clamp function in animation is a function that keeps you within certain bounds. Most of the time it's for a number but the concept also works for our DOMVector within a DOMRect.

  1. Let's create a clamp method on our 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

    dragVector
      .add(scrollVector)
      .clamp(
        new DOMRect(
          0,
          0,
          containerRef.current.scrollWidth,
          containerRef.current.scrollHeight,
        ),
      )
      .toDOMRect()
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

Scrolling at the edge

This is great, but there is a nice feature of text selection where it auto scrolls for you when you drag to the edge of the container.
This autoscrolls close to the edge is also a key part of making drag interactions.

// don't forget to call out that you created the terminal point method

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 (/* ... */)
}

We need a way to trigger a scroll when there is a current drag, but no user scroll or pointer events are taking place.

To accomplish this we can setup a requestAnimationFrame when the user is dragging

useEffect(() => {
  let handle = requestAnimationFrame(scrollTheLad)
 
  return () => cancelAnimationFrame(handle)
 
  function scrollTheLad() {
    /* ... */
 
    handle = requestAnimationFrame(scrollTheLad)
  }
}, [isDragging, dragVector, updateSelectedItems])

Conceptually, we are saying "When the user is dragging, run this code on every frame and scroll our container if certain criteria are met.

The math here is a bit hard to read, so let's walk through the case of scrolling down.

  1. First we create a variable for when we should scroll down

    const shouldScrollDown = containerRect.height - currentPointer.y < 20

    When your pointer is within the container and not within 20 pixels of the edge, this value is greater than 20 and we shouldn't scroll.

    //todo diagram

    Otherwise, It's less than 20 and we should scroll

    //todo diagram

  2. If we should scroll down, then we set our top variable to a positive value

    const top = shouldScrollDown
      ? clamp(20 - containerRect.height + currentPointer.y, 0, 20)
      : // other cases

    If you are 19 pixels from the edge of the container has a height of 100 then this value is 20 - 100 + 81 = 1

    As you approach the edge this value increases.

    If you are 1 pixel from the edge of the container then this value is 20 -100 + 99 = 19

    This allows the user to control the speed of the scroll as they approach the edge, while capping the value at 20 to prevent them from overscrolling.

  3. Now that we have a top value it's scroll time.

    containerRef.current.scrollBy({
      left,
      top,
    })
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

My big takeaways from writing this post was that Vectors are a great way to hold drag state.

That's all for this one folks 👋

Subscribe to the newsletter

A monthly no filler update.

Contact me at