Repository: vercel/next.js
Pull request: [dev-overlay] Make badge draggable

This PR introduces a convenience feature to the Dev Tools badge.

Re-positioning it is such a common action that it would be nice if you could drag it to another corner instead of having to open the settings. I've built a custom `useDrag()` hook that also calculates velocity and uses it to snap to the closest corner.


https://github.com/user-attachments/assets/8bc36ba6-bdfb-4cf7-bcb9-1b77e9240a3e

This implementation also works on touch devices.



Changed files:
filename: packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.tsx
patch: @@ -1,5 +1,5 @@
 import type { CSSProperties, Dispatch, SetStateAction } from 'react'
-import type { OverlayState } from '../../../../shared'
+import { STORAGE_KEY_POSITION, type OverlayState } from '../../../../shared'
 
 import { useState, useEffect, useRef, createContext, useContext } from 'react'
 import { Toast } from '../../toast'
@@ -21,6 +21,7 @@ import {
   getInitialPosition,
   type DevToolsScale,
 } from './dev-tools-info/preferences'
+import { Draggable } from './draggable'
 
 // TODO: add E2E tests to cover different scenarios
 
@@ -83,6 +84,8 @@ const OVERLAYS = {
 
 export type Overlays = (typeof OVERLAYS)[keyof typeof OVERLAYS]
 
+const INDICATOR_PADDING = 20
+
 function DevToolsPopover({
   routerType,
   disabled,
@@ -258,31 +261,38 @@ function DevToolsPopover({
           '--animate-out-timing-function': MENU_CURVE,
           boxShadow: 'none',
           zIndex: 2147483647,
-          // Reset the toast component's default positions.
-          bottom: 'initial',
-          left: 'initial',
-          [vertical]: '20px',
-          [horizontal]: '20px',
+          [vertical]: `${INDICATOR_PADDING}px`,
+          [horizontal]: `${INDICATOR_PADDING}px`,
         } as CSSProperties
       }
     >
-      {/* Trigger */}
-      <NextLogo
-        ref={triggerRef}
-        aria-haspopup="menu"
-        aria-expanded={isMenuOpen}
-        aria-controls="nextjs-dev-tools-menu"
-        aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
-        data-nextjs-dev-tools-button
-        disabled={disabled}
-        issueCount={issueCount}
-        onTriggerClick={onTriggerClick}
-        toggleErrorOverlay={toggleErrorOverlay}
-        isDevBuilding={useIsDevBuilding()}
-        isDevRendering={useIsDevRendering()}
-        isBuildError={isBuildError}
-        scale={scale}
-      />
+      <Draggable
+        padding={INDICATOR_PADDING}
+        onDragStart={() => setOpen(null)}
+        position={position}
+        setPosition={(p) => {
+          localStorage.setItem(STORAGE_KEY_POSITION, p)
+          setPosition(p)
+        }}
+      >
+        {/* Trigger */}
+        <NextLogo
+          ref={triggerRef}
+          aria-haspopup="menu"
+          aria-expanded={isMenuOpen}
+          aria-controls="nextjs-dev-tools-menu"
+          aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
+          data-nextjs-dev-tools-button
+          disabled={disabled}
+          issueCount={issueCount}
+          onTriggerClick={onTriggerClick}
+          toggleErrorOverlay={toggleErrorOverlay}
+          isDevBuilding={useIsDevBuilding()}
+          isDevRendering={useIsDevRendering()}
+          isBuildError={isBuildError}
+          scale={scale}
+        />
+      </Draggable>
 
       {/* Route Info */}
       <RouteInfo
@@ -612,4 +622,12 @@ export const DEV_TOOLS_INDICATOR_STYLES = `
       line-height: var(--size-16);
     }
   }
+
+  .dev-tools-grabbing {
+    cursor: grabbing;
+
+    > * {
+      pointer-events: none;
+    }
+  }
 `

filename: packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/draggable.tsx
patch: @@ -0,0 +1,278 @@
+import { useRef } from 'react'
+
+interface Point {
+  x: number
+  y: number
+}
+
+type Corners = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
+
+interface Corner {
+  corner: Corners
+  translation: Point
+}
+
+export function Draggable({
+  children,
+  padding,
+  position: currentCorner,
+  setPosition: setCurrentCorner,
+  onDragStart,
+}: {
+  children: React.ReactElement
+  position: Corners
+  padding: number
+  setPosition: (position: Corners) => void
+  onDragStart?: () => void
+}) {
+  const { ref, animate, ...drag } = useDrag({
+    threshold: 5,
+    onDragStart,
+    onDragEnd,
+    onAnimationEnd,
+  })
+
+  function onDragEnd(translation: Point, velocity: Point) {
+    const projectedPosition = {
+      x: translation.x + project(velocity.x),
+      y: translation.y + project(velocity.y),
+    }
+    const nearestCorner = getNearestCorner(projectedPosition)
+    animate(nearestCorner)
+  }
+
+  function onAnimationEnd({ corner }: Corner) {
+    // Unset drag translation
+    setTimeout(() => {
+      ref.current?.style.removeProperty('translate')
+      setCurrentCorner(corner)
+    })
+  }
+
+  function getNearestCorner({ x, y }: Point): Corner {
+    const allCorners = getCorners()
+    const distances = Object.entries(allCorners).map(([key, translation]) => {
+      const distance = Math.sqrt(
+        (x - translation.x) ** 2 + (y - translation.y) ** 2
+      )
+      return { key, distance }
+    })
+    const min = Math.min(...distances.map((d) => d.distance))
+    const nearest = distances.find((d) => d.distance === min)
+    if (!nearest) {
+      // Safety fallback
+      return { corner: currentCorner, translation: allCorners[currentCorner] }
+    }
+    return {
+      translation: allCorners[nearest.key as Corners],
+      corner: nearest.key as Corners,
+    }
+  }
+
+  function getCorners(): Record<Corners, Point> {
+    const offset = padding * 2
+    const triggerWidth = ref.current?.offsetWidth || 0
+    const triggerHeight = ref.current?.offsetHeight || 0
+
+    function getAbsolutePosition(corner: Corners) {
+      const isRight = corner.includes('right')
+      const isBottom = corner.includes('bottom')
+
+      return {
+        x: isRight ? window.innerWidth - offset - triggerWidth : 0,
+        y: isBottom ? window.innerHeight - offset - triggerHeight : 0,
+      }
+    }
+
+    const basePosition = getAbsolutePosition(currentCorner)
+
+    // Calculate all corner positions relative to the current corner
+    return {
+      'top-left': {
+        x: 0 - basePosition.x,
+        y: 0 - basePosition.y,
+      },
+      'top-right': {
+        x: window.innerWidth - offset - triggerWidth - basePosition.x,
+        y: 0 - basePosition.y,
+      },
+      'bottom-left': {
+        x: 0 - basePosition.x,
+        y: window.innerHeight - offset - triggerHeight - basePosition.y,
+      },
+      'bottom-right': {
+        x: window.innerWidth - offset - triggerWidth - basePosition.x,
+        y: window.innerHeight - offset - triggerHeight - basePosition.y,
+      },
+    }
+  }
+
+  return (
+    <div ref={ref} {...drag} style={{ touchAction: 'none' }}>
+      {children}
+    </div>
+  )
+}
+
+interface UseDragOptions {
+  onDragStart?: () => void
+  onDrag?: (translation: Point) => void
+  onDragEnd?: (translation: Point, velocity: Point) => void
+  onAnimationEnd?: (corner: Corner) => void
+  threshold: number // Minimum movement before drag starts
+}
+
+interface Velocity {
+  position: Point
+  timestamp: number
+}
+
+export function useDrag(options: UseDragOptions) {
+  const ref = useRef<HTMLDivElement>(null)
+  const state = useRef<'idle' | 'press' | 'drag' | 'drag-end'>('idle')
+
+  const origin = useRef<Point>({ x: 0, y: 0 })
+  const translation = useRef<Point>({ x: 0, y: 0 })
+  const lastTimestamp = useRef(0)
+  const velocities = useRef<Velocity[]>([])
+
+  function set(position: Point) {
+    if (ref.current) {
+      translation.current = position
+      ref.current.style.translate = `${position.x}px ${position.y}px`
+    }
+  }
+
+  function animate(corner: Corner) {
+    const el = ref.current
+    if (el === null) return
+
+    function listener(e: TransitionEvent) {
+      if (e.propertyName === 'translate') {
+        options.onAnimationEnd?.(corner)
+        translation.current = { x: 0, y: 0 }
+        el!.style.transition = ''
+        el!.removeEventListener('transitionend', listener)
+      }
+    }
+
+    // Generated from https://www.easing.dev/spring
+    el.style.transition = 'translate 491.22ms var(--timing-bounce)'
+    el.addEventListener('transitionend', listener)
+    set(corner.translation)
+  }
+
+  function onClick(e: MouseEvent) {
+    if (state.current === 'drag-end') {
+      e.preventDefault()
+      e.stopPropagation()
+      state.current = 'idle'
+      ref.current?.removeEventListener('click', onClick)
+    }
+  }
+
+  function onPointerDown(e: React.PointerEvent) {
+    origin.current = { x: e.clientX, y: e.clientY }
+    state.current = 'press'
+    window.addEventListener('pointermove', onPointerMove)
+    window.addEventListener('pointerup', onPointerUp)
+    ref.current?.addEventListener('click', onClick)
+  }
+
+  function onPointerMove(e: PointerEvent) {
+    if (state.current === 'press') {
+      const dx = e.clientX - origin.current.x
+      const dy = e.clientY - origin.current.y
+      const distance = Math.sqrt(dx * dx + dy * dy)
+
+      if (distance >= options.threshold) {
+        state.current = 'drag'
+        ref.current?.setPointerCapture(e.pointerId)
+        ref.current?.classList.add('dev-tools-grabbing')
+        options.onDragStart?.()
+      }
+    }
+
+    if (state.current !== 'drag') return
+
+    const currentPosition = { x: e.clientX, y: e.clientY }
+
+    const dx = currentPosition.x - origin.current.x
+    const dy = currentPosition.y - origin.current.y
+    origin.current = currentPosition
+
+    const newTranslation = {
+      x: translation.current.x + dx,
+      y: translation.current.y + dy,
+    }
+
+    set(newTranslation)
+
+    // Keep a history of recent positions for velocity calculation
+    // Only store points that are at least 10ms apart to avoid too many samples
+    const now = Date.now()
+    const shouldAddToHistory = now - lastTimestamp.current >= 10
+    if (shouldAddToHistory) {
+      velocities.current = [
+        ...velocities.current.slice(-5),
+        { position: currentPosition, timestamp: now },
+      ]
+    }
+
+    lastTimestamp.current = now
+    options.onDrag?.(translation.current)
+  }
+
+  function onPointerUp(e: PointerEvent) {
+    state.current = state.current === 'drag' ? 'drag-end' : 'idle'
+
+    window.removeEventListener('pointermove', onPointerMove)
+    window.removeEventListener('pointerup', onPointerUp)
+
+    const velocity = calculateVelocity(velocities.current)
+    velocities.current = []
+
+    ref.current?.classList.remove('dev-tools-grabbing')
+    ref.current?.releasePointerCapture(e.pointerId)
+    options.onDragEnd?.(translation.current, velocity)
+  }
+
+  return {
+    ref,
+    onPointerDown,
+    animate,
+  }
+}
+
+function calculateVelocity(
+  history: Array<{ position: Point; timestamp: number }>
+): Point {
+  if (history.length < 2) {
+    return { x: 0, y: 0 }
+  }
+
+  const oldestPoint = history[0]
+  const latestPoint = history[history.length - 1]
+
+  const timeDelta = latestPoint.timestamp - oldestPoint.timestamp
+
+  if (timeDelta === 0) {
+    return { x: 0, y: 0 }
+  }
+
+  // Calculate pixels per millisecond
+  const velocityX =
+    (latestPoint.position.x - oldestPoint.position.x) / timeDelta
+  const velocityY =
+    (latestPoint.position.y - oldestPoint.position.y) / timeDelta
+
+  // Convert to pixels per second for more intuitive values
+  return {
+    x: velocityX * 1000,
+    y: velocityY * 1000,
+  }
+}
+
+function project(initialVelocity: number, decelerationRate = 0.999) {
+  return ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate)
+}

filename: packages/next/src/client/components/react-dev-overlay/ui/components/fader/index.tsx
patch: @@ -56,5 +56,4 @@ export const FADER_STYLES = `
       mask-image: linear-gradient(to bottom, var(--color-bg) var(--stop), transparent);
     }
   }
-
 `

filename: packages/next/src/client/components/react-dev-overlay/ui/components/toast/styles.ts
patch: @@ -1,21 +1,12 @@
 const styles = `
   .nextjs-toast {
     position: fixed;
-    bottom: 16px;
-    left: 16px;
     max-width: 420px;
     z-index: 9000;
     box-shadow: 0px 16px 32px
       rgba(0, 0, 0, 0.25);
   }
 
-  @media (max-width: 440px) {
-    .nextjs-toast {
-      max-width: 90vw;
-      left: 5vw;
-    }
-  }
-
   .nextjs-toast-errors-parent {
     padding: 16px;
     border-radius: var(--rounded-4xl);

filename: packages/next/src/client/components/react-dev-overlay/ui/styles/base.tsx
patch: @@ -59,6 +59,8 @@ export function Base({ scale = 1 }: { scale?: DevToolsScale }) {
 
           --timing-swift: cubic-bezier(0.23, 0.88, 0.26, 0.92);
           --timing-overlay: cubic-bezier(0.175, 0.885, 0.32, 1.1);
+          /* prettier-ignore */
+          --timing-bounce: linear(0 0%, 0.005871 1%, 0.022058 2%, 0.046612 3%, 0.077823 4%, 0.114199 5%, 0.154441 6%, 0.197431 7.000000000000001%, 0.242208 8%, 0.287959 9%, 0.333995 10%, 0.379743 11%, 0.424732 12%, 0.46858 13%, 0.510982 14.000000000000002%, 0.551702 15%, 0.590564 16%, 0.627445 17%, 0.662261 18%, 0.694971 19%, 0.725561 20%, 0.754047 21%, 0.780462 22%, 0.804861 23%, 0.82731 24%, 0.847888 25%, 0.866679 26%, 0.883775 27%, 0.899272 28.000000000000004%, 0.913267 28.999999999999996%, 0.925856 30%, 0.937137 31%, 0.947205 32%, 0.956153 33%, 0.96407 34%, 0.971043 35%, 0.977153 36%, 0.982479 37%, 0.987094 38%, 0.991066 39%, 0.994462 40%, 0.997339 41%, 0.999755 42%, 1.001761 43%, 1.003404 44%, 1.004727 45%, 1.00577 46%, 1.006569 47%, 1.007157 48%, 1.007563 49%, 1.007813 50%, 1.007931 51%, 1.007939 52%, 1.007855 53%, 1.007697 54%, 1.007477 55.00000000000001%, 1.00721 56.00000000000001%, 1.006907 56.99999999999999%, 1.006576 57.99999999999999%, 1.006228 59%, 1.005868 60%, 1.005503 61%, 1.005137 62%, 1.004776 63%, 1.004422 64%, 1.004078 65%, 1.003746 66%, 1.003429 67%, 1.003127 68%, 1.00284 69%, 1.002571 70%, 1.002318 71%, 1.002082 72%, 1.001863 73%, 1.00166 74%, 1.001473 75%, 1.001301 76%, 1.001143 77%, 1.001 78%, 1.000869 79%, 1.000752 80%, 1.000645 81%, 1.00055 82%, 1.000464 83%, 1.000388 84%, 1.000321 85%, 1.000261 86%, 1.000209 87%, 1.000163 88%, 1.000123 89%, 1.000088 90%);
 
           --rounded-none: 0px;
           --rounded-sm: 2px;