When we make complex interactive UIs like sliders, editor or color pickers, we often need to track mouse or touch movement in a pressed state. Let me share an easy-to-use abstract component designed precisely for this purpose.
The component receives two properties:
render
- a function that consumer component should use to render the content. It receives an object with props
and position
properties. The props
field contains ref
and mouse and touch start handlers, it should be passed to the container element. The position
is a point with x
and y
coordinates in the range from 0 to 1. It represents the relative position of the cursor inside the container. In all my practical cases, I needed to know the relative position, so I decided to use it as a default.onChange
- a function that will be called when the position changes. It receives an object with the same position
argument. Note that when the press origin is outside the container, the position will be null
.import { Point } from "lib/entities/Point"
import { useBoundingBox } from "lib/shared/hooks/useBoundingBox"
import { enforceRange } from "lib/shared/utils/enforceRange"
import {
MouseEvent,
MouseEventHandler,
ReactNode,
TouchEvent,
TouchEventHandler,
useCallback,
useEffect,
useState,
} from "react"
import { useEvent } from "react-use"
interface ContainerProps {
onMouseDown: MouseEventHandler<HTMLElement>
onTouchStart: TouchEventHandler<HTMLElement>
ref: (node: HTMLElement | null) => void
}
interface ChangeParams {
position: Point | null
}
interface RenderParams extends ChangeParams {
props: ContainerProps
}
interface PressTrackerProps {
render: (props: RenderParams) => ReactNode
onChange?: (params: ChangeParams) => void
}
export const PressTracker = ({ render, onChange }: PressTrackerProps) => {
const [container, setContainer] = useState<HTMLElement | null>(null)
const box = useBoundingBox(container)
const [position, setPosition] = useState<Point | null>(null)
const handleMove = useCallback(
({ x, y }: Point) => {
if (!box) return
const { left, top, width, height } = box
setPosition({
x: enforceRange((x - left) / width, 0, 1),
y: enforceRange((y - top) / height, 0, 1),
})
},
[box]
)
const handleMouse = useCallback(
(event: MouseEvent) => {
handleMove({ x: event.clientX, y: event.clientY })
},
[handleMove]
)
const handleTouch = useCallback(
(event: TouchEvent) => {
const touch = event.touches[0]
if (touch) {
handleMove({ x: touch.clientX, y: touch.clientY })
}
},
[handleMove]
)
useEffect(() => {
if (onChange) {
onChange({ position })
}
}, [onChange, position])
const clearPosition = useCallback(() => {
setPosition(null)
}, [])
useEvent("mouseup", position ? clearPosition : undefined)
useEvent("touchend", position ? clearPosition : undefined)
useEvent("mousemove", position ? handleMouse : undefined)
useEvent("touchmove", position ? handleTouch : undefined)
return (
<>
{render({
props: {
ref: setContainer,
onMouseDown: handleMouse,
onTouchStart: handleTouch,
},
position: position,
})}
</>
)
}
The PressTracker
component stores the container element in the useState
and utilizes the useBoundingBox
hook to obtain its position and size. To initiate the tracking process, the consumer component should render the PressTracker
component and pass the necessary props to the container element.
<PressTracker
render={({ props, position }) => (
<Container {...props}>
{position && (
<Highlight
style={{
width: toPercents(position.x),
height: toPercents(position.y),
}}
/>
)}
</Container>
)}
/>
We set the position on either mouse down on touch start events. So once the user have interacted with the container, we start tracking the cursor position with the useEvent
hook from the react-use
library. It listens for mouseup
and touchend
events to stop tracking, as well as mousemove
and touchmove
events to update the cursor position. The handleMove
function converts absolute coordinates from touch and mouse events, ensuring that the coordinates remain within the range of 0
to 1
, even when the cursor is outside the container.