<div id="root"></div>
body{
	margin: 0;
}
const { useState, useRef, useCallback, useEffect } = React;

const clamp = (value, min, max) => {
	if (value < min) return min;
	else if (value > max) return max;
	return value;
};

const useResizeObserver = (elements, callback) => {
	useEffect(() => {
		const resizeObserver = new ResizeObserver((entries) => callback(entries));

		for (const elem of elements) {
			console.log(elem);
			elem.current && resizeObserver.observe(elem.current);
		}

		return () => resizeObserver.disconnect();
	}, []);
};
const useMutationObserver = (elements, callback, config) => {
	useEffect(() => {
		const mutationObserver = new MutationObserver((mutations) => {
			console.log("ita: ", mutations);

			mutationObserver.disconnect();
			callback(mutations);
			for (const elem of elements) {
				elem.current && mutationObserver.observe(elem.current, config);
			}
		});

		for (const elem of elements) {
			elem.current && mutationObserver.observe(elem.current, config);
		}

		return () => mutationObserver.disconnect();
	}, []);
};

const useDragAndDrop = () => {
	const [elementPosition, setElementPosition] = useState({ top: 0, left: 0 });
	const [elementOffset, setElementOffset] = useState({ x: 0, y: 0 });
	
	const pointerStartPosition = useRef({ x: null, y: null });
	const pointerMovePosition = useRef({ x: null, y: null });

	const currentDragElement = useRef(null);

	const prevElementOffset = useRef({ x: 0, y: 0 });

	const getCurrentPosition = (elem) => {
		const { top, left } = elem.getBoundingClientRect();
		return { top, left };
	};
	
	const moveDistance = (distance) =>
		setElementOffset({
			x: prevElementOffset.current.x + distance.x,
			y: prevElementOffset.current.y + distance.y
		});

	const resetElementOffset = () => {
		setElementOffset({
			x: 0,
			y: 0
		});

		prevElementOffset.current = {
			x: 0,
			y: 0
		};
	};

	const resetPointerStartPosition = () => {
		if (
			pointerStartPosition.current.x === null ||
			pointerStartPosition.current.y === null
		)
			return;

		pointerStartPosition.current.x = null;
		pointerStartPosition.current.y = null;
	};

	const handleMouseDown = (e) => {
		e.preventDefault();

		pointerStartPosition.current.x = e.clientX;
		pointerStartPosition.current.y = e.clientY;

		currentDragElement.current = e.target;

		const elementCurrentPosition = getCurrentPosition(currentDragElement.current);

		setElementPosition({
			top: elementCurrentPosition.top,
			left: elementCurrentPosition.left
		});
	};

	const handleMouseMove = (e) => {
		e.preventDefault();
		
		if (!currentDragElement.current) return;
console.log('suga move: ', currentDragElement.current)
		if (
			pointerStartPosition.current.x === null ||
			pointerStartPosition.current.y === null
		)
			return;

		pointerMovePosition.current.x = e.clientX;
		pointerMovePosition.current.y = e.clientY;

		const pointerMoveDistance = {
			x: pointerMovePosition.current.x - pointerStartPosition.current.x,
			y: pointerMovePosition.current.y - pointerStartPosition.current.y
		};

		moveDistance(pointerMoveDistance);
	};

	const handleMouseUp = (e) => {
		e.preventDefault();

		if (!currentDragElement.current) return;
		console.log('suga up: ', currentDragElement.current)
		resetPointerStartPosition();

		const elementCurrentPosition = getCurrentPosition(currentDragElement.current);

		setElementPosition({
			top: elementCurrentPosition.top,
			left: elementCurrentPosition.left
		});

		currentDragElement.current = null;
	};
	
	useEffect(() => {
		document.body.addEventListener("mousemove", handleMouseMove);
		document.body.addEventListener("mouseup", handleMouseUp);
		document.body.addEventListener("mouseleave", handleMouseUp);

		return () => {
			document.body.removeEventListener("mousemove", handleMouseMove);
			document.body.removeEventListener("mouseup", handleMouseUp);
			document.body.removeEventListener("mouseleave", handleMouseUp);
		};
	}, []);

	useEffect(() => {
		prevElementOffset.current = {
			x: elementOffset.x,
			y: elementOffset.y
		};
	}, [elementPosition.left, elementPosition.top]);

	return [
		{
			currentDragElement,
			elementPosition,
			elementOffset,
		},
	  	{
			pointerStartPosition,
			pointerMovePosition
		},
		handleMouseDown,
		setElementOffset,
		resetElementOffset,
		setElementPosition
	];
};

const useRangeSlider = () => {
	const [
		{ 
			currentDragElement,
			elementPosition,
			elementOffset
		},
		{ 
			pointerStartPosition,
			pointerMovePosition
		},
		handleMouseDown,
		setElementOffset,
		resetElementOffset,
		setElementPosition
	] = useDragAndDrop();
	
	const [rangeSliderHandleOffsetX, setRangeSliderHandleOffsetX] = useState(
		0
	);

	const [rangeSliderHandleWidth, setRangeSliderHandleWidth] = useState(0);
	const [rangeSliderBarWidth, setRangeSliderBarWidth] = useState(0);

	const [rangeSliderBarPosition, setRangeSliderBarPosition] = useState({
		left: 0,
		top: 0
	});
	const [rangeSliderHandlePosition, setRangeSliderHandlePosition] = useState(0);

	const rangeSliderBarScale = useRef(1)

	const rangeSliderHandleElement = useRef(null);
	const rangeSliderBarElement = useRef(null);

	const previousRangeSliderBarWidth = useRef(0)

	useResizeObserver(
		[rangeSliderBarElement],
		(entries) => {
			const entry = entries[0]
			const width = entry.contentRect.width;
			const height = entry.contentRect.height;

			//if(width === 0 || height === 0) return

			// useFrontBackRatioフックだとresizeObserverの検知の速さに対応できないため、ここでスケールを取得
			/*rangeSliderBarScale.current =
						previousRangeSliderBarWidth.current === 0
							? 1
							: width / previousRangeSliderBarWidth.current;*/

			setRangeSliderBarWidth(width);

			//previousRangeSliderBarWidth.current = width

			//setRangeSliderHandleOffsetX((p) => p * rangeSliderBarScale.current)

			const rect = entry.target.getBoundingClientRect();
			setRangeSliderBarPosition({
				left: rect.left,
				top: rect.top
			});
		}
	);
	
	useResizeObserver(
		[rangeSliderHandleElement],
		(entries) => {
			const entry = entries[0]
			
			const width = entry.contentRect.width;
			const height = entry.contentRect.height;
			
			if(width === 0 || height === 0) return
			
			setRangeSliderHandleWidth(width);

			rangeSliderBarScale.current =
				previousRangeSliderBarWidth.current === 0
				? 1
			: width / previousRangeSliderBarWidth.current;

			previousRangeSliderBarWidth.current = width

			setRangeSliderHandleOffsetX((p) => p * rangeSliderBarScale.current)

			const left = entry.target.getBoundingClientRect().left;
			setRangeSliderHandlePosition(left);
		}
	)
	
	useMutationObserver(
		[rangeSliderHandleElement],
		(mutations) => {
			const left = mutations[0].target.getBoundingClientRect().left;
			setRangeSliderHandlePosition(left);
		},
		{
            attributes: true,
            subtree: false,
            childList: false,
            attributeFilter: ["class"]
        }
	);

	useEffect(() => {
		if (!currentDragElement.current) return;

		// rangeSliderBarPosition.leftだと、バーの位置が途中から変化しなくなったときに、そこから先の変更値が取得できなくなるため、押し込まれたときのバーの位置を取得する
		const rect = rangeSliderBarElement.current.getBoundingClientRect()
			/*setRangeSliderBarPosition({
				left: rect.left,
				top: rect.top
			});*/
		const startX = pointerStartPosition.current.x - rect.left//rangeSliderBarPosition.left;

		setRangeSliderHandleOffsetX(clamp(startX, 0, rangeSliderBarWidth));
	}, [elementPosition]);

	useEffect(() => {
		const rect = rangeSliderBarElement.current.getBoundingClientRect()
		/*setRangeSliderBarPosition({
				left: rect.left,
				top: rect.top
		});*/
		const moveX = pointerMovePosition.current.x - rect.left//rangeSliderBarPosition.left;

		setRangeSliderHandleOffsetX((p) => clamp(moveX, 0, rangeSliderBarWidth));
	}, [elementOffset]);

	return [
		{
			handle: {
				element: rangeSliderHandleElement,
				offsetX: rangeSliderHandleOffsetX,
				position: elementPosition,
				width: rangeSliderHandleWidth
			},
			bar: {
				element: rangeSliderBarElement,
				position: rangeSliderBarPosition,
				width: rangeSliderBarWidth,
				scale: rangeSliderBarScale
			},
			handleMouseDown
		},
		setRangeSliderHandleOffsetX
	];
};

const StyledRangeSlider = styled.div`
	display: flex;
	align-items: center;
	height: 20vmin;
	position: relative;
	width: 100%;
`;

const Bar = styled.div`
	background-color: #f0f0f0;
	border-radius: 10vmin;
	cursor: pointer;
	height: 10vmin;
	position: absolute;
	width: 100%;
`;

const Handle = styled.div`
	background-color: #ffa44a;
	border-radius: 100%;
	cursor: pointer;
	height: 16vmin;
	position: absolute;
	left: -8vmin;
	transform: translate3d(
		${({ offsetX }) => offsetX}px,
		0,
		0
	);
	width: 16vmin;
`;

const RangeSlider = ({ rangeSlider }) => (
	<StyledRangeSlider className="range-slider">
		<Bar
			className="range-slider-bar"
			ref={rangeSlider.bar.element}
			onMouseDown={rangeSlider.handleMouseDown}
		/>
		<Handle
			className="range-slider-handle"
			ref={rangeSlider.handle.element}
			onMouseDown={rangeSlider.handleMouseDown}
			offsetX={rangeSlider.handle.offsetX}
		/>
	</StyledRangeSlider>
);

const StyledApp = styled.div`
	background-color: #8e82bf;
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;
`;

const Container = styled.div`
	width: 80vmin;
`;

const App = () => {
	const [
		rangeSlider,
		setRangeSliderHandleOffsetX
	] = useRangeSlider();

	return (
		<StyledApp>
			<Container>
				<RangeSlider rangeSlider={rangeSlider} />
			</Container>
		</StyledApp>
	);
};

ReactDOM.render(<App />, document.getElementById("root"));
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/styled-components/4.3.1/styled-components.min.js