diff --git a/README.md b/README.md
index e86e38c42e..64858f9f44 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,7 @@
- [`useCss`](./docs/useCss.md) — dynamically adjusts CSS.
- [`useDrop` and `useDropArea`](./docs/useDrop.md) — tracks file, link and copy-paste drops.
- [`useFullscreen`](./docs/useFullscreen.md) — display an element or video full-screen. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-usefullscreen--demo)
+ - [`useSlider`](./docs/useSlider.md) — provides slide behavior over any HTML element. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-useslider--demo)
- [`useSpeech`](./docs/useSpeech.md) — synthesizes speech from a text string. [![][img-demo]](https://codesandbox.io/s/n090mqz69m)
- [`useVibrate`](./docs/useVibrate.md) — provide physical feedback using the [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API). [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-usevibrate--demo)
- [`useVideo`](./docs/useVideo.md) — plays video, tracks its state, and exposes playback controls. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-usevideo--demo)
diff --git a/docs/useSlider.md b/docs/useSlider.md
new file mode 100644
index 0000000000..80d05207b4
--- /dev/null
+++ b/docs/useSlider.md
@@ -0,0 +1,25 @@
+# `useSlider`
+
+React UI hook that provides slide behavior over any HTML element. Supports both mouse and touch events.
+
+## Usage
+
+```jsx
+import {useSlider} from 'react-use';
+
+const Demo = () => {
+ const ref = React.useRef(null);
+ const {isSliding, value, pos, length} = useSlider(ref);
+
+ return (
+
+
+
+ {Math.round(state.value * 100)}%
+
+
🎚
+
+
+ );
+};
+```
diff --git a/src/index.ts b/src/index.ts
index b5f89826bd..4a0aee4fa7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,6 +73,7 @@ export { default as useSessionStorage } from './useSessionStorage';
export { default as useSetState } from './useSetState';
export { default as useShallowCompareEffect } from './useShallowCompareEffect';
export { default as useSize } from './useSize';
+export { default as useSlider } from './useSlider';
export { default as useSpeech } from './useSpeech';
// not exported because of peer dependency
// export { default as useSpring } from './useSpring';
diff --git a/src/useMeasureDirty.ts b/src/useMeasureDirty.ts
new file mode 100644
index 0000000000..2f0702c2df
--- /dev/null
+++ b/src/useMeasureDirty.ts
@@ -0,0 +1,50 @@
+import { useState, useEffect, useRef, RefObject } from 'react';
+import ResizeObserver from 'resize-observer-polyfill';
+
+export interface ContentRect {
+ width: number;
+ height: number;
+ top: number;
+ right: number;
+ left: number;
+ bottom: number;
+}
+
+const useMeasureDirty = (ref: RefObject): ContentRect => {
+ const frame = useRef(0);
+ const [rect, set] = useState({
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+ bottom: 0,
+ right: 0,
+ });
+
+ const [observer] = useState(
+ () =>
+ new ResizeObserver(entries => {
+ const entry = entries[0];
+
+ if (entry) {
+ cancelAnimationFrame(frame.current);
+
+ frame.current = requestAnimationFrame(() => {
+ set(entry.contentRect);
+ });
+ }
+ })
+ );
+
+ useEffect(() => {
+ observer.disconnect();
+
+ if (ref.current) {
+ observer.observe(ref.current);
+ }
+ }, [ref]);
+
+ return rect;
+};
+
+export default useMeasureDirty;
diff --git a/src/useSlider.ts b/src/useSlider.ts
new file mode 100644
index 0000000000..f123cdc61f
--- /dev/null
+++ b/src/useSlider.ts
@@ -0,0 +1,141 @@
+import { useEffect, useRef, RefObject, CSSProperties } from 'react';
+
+import { isClient, off, on } from './util';
+import useMountedState from './useMountedState';
+import useSetState from './useSetState';
+
+export interface State {
+ isSliding: boolean;
+ value: number;
+}
+
+export interface Options {
+ onScrub: (value: number) => void;
+ onScrubStart: () => void;
+ onScrubStop: () => void;
+ reverse: boolean;
+ styles: boolean | CSSProperties;
+ vertical?: boolean;
+}
+
+const noop = () => {};
+
+const useSlider = (ref: RefObject, options: Partial = {}): State => {
+ const isMounted = useMountedState();
+ const isSliding = useRef(false);
+ const frame = useRef(0);
+ const [state, setState] = useSetState({
+ isSliding: false,
+ value: 0,
+ });
+
+ useEffect(() => {
+ if (isClient) {
+ const styles = options.styles === undefined ? true : options.styles;
+ const reverse = options.reverse === undefined ? false : options.reverse;
+
+ if (ref.current && styles) {
+ ref.current.style.userSelect = 'none';
+ }
+
+ const startScrubbing = () => {
+ if (!isSliding.current && isMounted()) {
+ (options.onScrubStart || noop)();
+ isSliding.current = true;
+ setState({ isSliding: true });
+ bindEvents();
+ }
+ };
+
+ const stopScrubbing = () => {
+ if (isSliding.current && isMounted()) {
+ (options.onScrubStop || noop)();
+ isSliding.current = false;
+ setState({ isSliding: false });
+ unbindEvents();
+ }
+ };
+
+ const onMouseDown = (event: MouseEvent) => {
+ startScrubbing();
+ onMouseMove(event);
+ };
+ const onMouseMove = options.vertical
+ ? (event: MouseEvent) => onScrub(event.clientY)
+ : (event: MouseEvent) => onScrub(event.clientX);
+
+ const onTouchStart = (event: TouchEvent) => {
+ startScrubbing();
+ onTouchMove(event);
+ };
+ const onTouchMove = options.vertical
+ ? (event: TouchEvent) => onScrub(event.changedTouches[0].clientY)
+ : (event: TouchEvent) => onScrub(event.changedTouches[0].clientX);
+
+ const bindEvents = () => {
+ on(document, 'mousemove', onMouseMove);
+ on(document, 'mouseup', stopScrubbing);
+
+ on(document, 'touchmove', onTouchMove);
+ on(document, 'touchend', stopScrubbing);
+ };
+
+ const unbindEvents = () => {
+ off(document, 'mousemove', onMouseMove);
+ off(document, 'mouseup', stopScrubbing);
+
+ off(document, 'touchmove', onTouchMove);
+ off(document, 'touchend', stopScrubbing);
+ };
+
+ const onScrub = (clientXY: number) => {
+ cancelAnimationFrame(frame.current);
+
+ frame.current = requestAnimationFrame(() => {
+ if (isMounted() && ref.current) {
+ const rect = ref.current.getBoundingClientRect();
+ const pos = options.vertical ? rect.top : rect.left;
+ const length = options.vertical ? rect.height : rect.width;
+
+ // Prevent returning 0 when element is hidden by CSS
+ if (!length) {
+ return;
+ }
+
+ let value = (clientXY - pos) / length;
+
+ if (value > 1) {
+ value = 1;
+ } else if (value < 0) {
+ value = 0;
+ }
+
+ if (reverse) {
+ value = 1 - value;
+ }
+
+ setState({
+ value,
+ });
+
+ (options.onScrub || noop)(value);
+ }
+ });
+ };
+
+ on(ref.current, 'mousedown', onMouseDown);
+ on(ref.current, 'touchstart', onTouchStart);
+
+ return () => {
+ off(ref.current, 'mousedown', onMouseDown);
+ off(ref.current, 'touchstart', onTouchStart);
+ };
+ } else {
+ return undefined;
+ }
+ }, [ref, options.vertical]);
+
+ return state;
+};
+
+export default useSlider;
diff --git a/stories/useSlider.story.tsx b/stories/useSlider.story.tsx
new file mode 100644
index 0000000000..9626a8b43a
--- /dev/null
+++ b/stories/useSlider.story.tsx
@@ -0,0 +1,43 @@
+import { storiesOf } from '@storybook/react';
+import * as React from 'react';
+import { useSlider } from '../src';
+import ShowDocs from './util/ShowDocs';
+
+const Demo = () => {
+ const ref = React.useRef(null);
+ const state = useSlider(ref);
+
+ return (
+
+
+
Slide me
+
+ {state.isSliding ? '🏂' : '🎿'}
+
+
+
{JSON.stringify(state, null, 2)}
+
+ );
+};
+
+const DemoVertical = () => {
+ const ref = React.useRef(null);
+ const state = useSlider(ref, { vertical: true });
+
+ return (
+
+
+
Slide me
+
+ {state.isSliding ? '🏂' : '🎿'}
+
+
+
{JSON.stringify(state, null, 2)}
+
+ );
+};
+
+storiesOf('UI|useSlider', module)
+ .add('Docs', () => )
+ .add('Horizontal', () => )
+ .add('Vertical', () => );