Skip to main content

useDragValue

Drive a number with a pointer drag: the range, the step and the scale are the hook's, so the handler receives a value rather than pixels.

Source

This is what Knob, Slider and XYPad are built on. Reach for it when a control of your own needs the same drag behaviour.

Loading...
function App() {
  const areaRef = useRef<HTMLDivElement>(null)
  const [value, setValue] = useState<[number, number]>([50, 50])

  const { refCallback, dragging } = useDragValue<HTMLDivElement>({
    // y is reversed so that its maximum is at the top.
    axis: [
      { min: 0, max: 100, step: 1 },
      { min: 0, max: 100, step: 1, reverse: true },
    ],
    baseElementRef: areaRef,
    updateOnPointerDown: true,
    onChange: (value) => setValue(value),
  })

  // The element that starts the drag is also the one the pointer is measured
  // against, so it takes both refs.
  const ref = useComposedRefs<HTMLDivElement>(areaRef, refCallback)

  return (
    <div style={{ userSelect: 'none' }}>
      <div
        ref={ref}
        style={{
          position: 'relative',
          width: '100%',
          height: 160,
          marginBottom: '0.5rem',
          border: '1px solid currentColor',
          borderRadius: 8,
          touchAction: 'none',
        }}
      >
        <div
          style={{
            position: 'absolute',
            left: `${value[0]}%`,
            top: `${100 - value[1]}%`,
            width: 16,
            height: 16,
            borderRadius: '50%',
            background: 'currentColor',
            translate: '-50% -50%',
            pointerEvents: 'none',
          }}
        />
      </div>
      <div>dragging: {String(dragging)}</div>
      <div>
        x: {value[0]}, y: {value[1]}
      </div>
    </div>
  )
}

Import​

import { useDragValue } from '@tremolo-ui/react'

The value is yours to hold​

The hook keeps no value. onChange is where it is stored, and the current value is read back through getValue — or, with baseElementRef, not read at all because the pointer position says what it is. That is what lets the same value be set from a text field, a MIDI message or a preset without the drag and the state drifting apart.

Two ways to map a drag​

Which one applies is decided by the option you pass. Give exactly one of them.

baseElementRef: the value is the position pointed at​

The pointer is normalized against the bounding rect of the element, so the value follows the pointer exactly. updateOnPointerDown makes a press jump the value there before the pointer moves at all. This is how Slider and XYPad behave.

getValue: the value moves by the distance dragged​

Where the pointer is carries no meaning — the value moves away from where it stood, with pixelRange pixels covering the whole range. This is how a knob follows a drag, and why the value does not jump to the pointer.

Loading...
function App() {
  const [value, setValue] = useState(50)

  const { refCallback, dragging } = useDragValue<HTMLDivElement>({
    axis: { min: 0, max: 100, reverse: true },
    // The value moves away from where it stood, so the hook has to be able to
    // read it. 200px of movement covers the whole range.
    getValue: () => [0, value],
    pixelRange: 200,
    // Hold shift to cover a tenth of the range with the same movement.
    sensitivity: (state) => (state.event.shiftKey ? 0.1 : 1),
    cursor: 'ns-resize',
    onChange: ([, y]) => setValue(y),
  })

  return (
    <div style={{ userSelect: 'none' }}>
      <div
        ref={refCallback}
        style={{
          padding: '2rem',
          marginBottom: '0.5rem',
          border: '1px solid currentColor',
          borderRadius: 8,
          textAlign: 'center',
          touchAction: 'none',
        }}
      >
        Drag up and down
      </div>
      <div>dragging: {String(dragging)}</div>
      <div>value: {value.toFixed(1)}</div>
    </div>
  )
}

Fine adjustment​

sensitivity is read on every move, so it can answer to a modifier key held part way through a drag:

useDragValue({
sensitivity: (state) => (state.event.shiftKey ? 0.1 : 1),
})

With baseElementRef the value is normally the position pointed at, so anything but 1 turns the mapping relative and leaves the pointer and the value apart for the rest of the drag.

Axes​

axis takes one AxisOptions for both axes, or [x, y] for one each. Besides min, max, step and scale it takes reverse, which is how the y axis is made to grow upwards — the screen's y grows downwards.

A control that moves on one axis only still gets a value for both; ignore the one you do not use, as the relative example above does.

Options​

PropTypeDefault
axis*AxisOptions | readonly [AxisOptions, AxisOptions]—
baseElementRefRefObject<Element | null>—
getValue() => XY<number>—
pixelRangenumber | readonly [number, number]100
sensitivity(state: DragState) => number—
updateOnPointerDownboolean—
thresholdnumber0
cursorstring—
pointerLockbooleanfalse
shouldStart(event: PointerEvent) => boolean—
onChange(value: XY<number>, state: DragState) => void—
onDragStart(value: XY<number>, state: DragState) => void—
onDragEnd(value: XY<number>, state: DragState) => void—

Full reference: UseDragValueOptions