├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── example.gif ├── .gitignore ├── .prettierignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example ├── .npmignore ├── components │ ├── Card.tsx │ ├── HorizontalExample.tsx │ ├── TwoDimensionalExample.tsx │ ├── VerticalExample.tsx │ └── WidthMultiRefComponent.tsx ├── index.html ├── index.tsx ├── package-lock.json ├── package.json ├── styles.css ├── tsconfig.json └── yarn.lock ├── package.json ├── postcss.config.js ├── src ├── index.tsx └── useIsomorphicLayoutEffect.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | node_modules 3 | dist -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "plugin:react/recommended", 9 | "airbnb", 10 | "plugin:prettier/recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["react", "@typescript-eslint", "prettier"], 22 | "rules": { 23 | "no-use-before-define": "off", 24 | "@typescript-eslint/no-use-before-define": ["error"], 25 | "react/jsx-props-no-spreading": ["off"], 26 | "react/jsx-filename-extension": "off", 27 | "react/react-in-jsx-scope": "off", 28 | "prettier/prettier": "error", 29 | "quotes": ["error", "double", 30 | { 31 | "avoidEscape": false, 32 | "allowTemplateLiterals": true 33 | } 34 | ], 35 | "import/no-extraneous-dependencies": ["error", {"devDependencies": true}], 36 | "import/prefer-default-export": "off", 37 | "import/extensions": [ 38 | "error", 39 | "ignorePackages", 40 | { 41 | "ts": "never", 42 | "tsx": "never" 43 | } 44 | ] 45 | }, 46 | "settings": { 47 | "import/extensions": [".js", ".jsx", ".ts", ".tsx"], 48 | "import/parsers": { 49 | "@typescript-eslint/parser": [".ts", ".tsx"] 50 | }, 51 | "import/resolver": { 52 | "typescript": { 53 | "directory": "./tsconfig.json" 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rfmiotto/react-use-draggable-scroll/f4bc9551d3fe2be02f53f9b8158b879b54a6fbf9/.github/example.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | .parcel-cache 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | --- 3 | 4 | # Running on development machine 5 | 6 | Install library dependencies: 7 | 8 | ```console 9 | yarn install 10 | ``` 11 | ```console 12 | npm install 13 | ``` 14 | 15 | Build library in watch mode: 16 | 17 | ```console 18 | yarn start 19 | ``` 20 | ```console 21 | npm start 22 | ``` 23 | 24 | In a separate console: 25 | 26 | Install example page dependencies: 27 | 28 | ```console 29 | cd example 30 | yarn install 31 | ``` 32 | ```console 33 | cd example 34 | npm install 35 | ``` 36 | 37 | Build and serve example page in watch mode: 38 | 39 | ```console 40 | yarn start 41 | ``` 42 | ```console 43 | npm start 44 | ``` 45 | 46 | Open a browser an point it to the URL where server is running: 47 | the example page should appear. 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Renato Fuzaro Miotto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # useDraggable Hook 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/react-use-draggable-scroll)](https://www.npmjs.com/package/react-use-draggable-scroll) 4 | 5 | useDraggable is a React hook that allows a wrapping div to have a draggable scroll with an inertial effect. 6 | It is completely unstyled and just adds the functionality you are looking for so your application gives 7 | the best user experience possible. It works in both x- and y-coordinate directions. 8 | 9 | See [DEMO](https://stackblitz.com/edit/nextjs-tg52v4). 10 | 11 | example gif 12 | 13 | ### Why useDraggable? 14 | 15 | Differently from other hooks designed for the same purpose, this hook does not rely on any state changes. The 16 | functionality is built entirely on event listeners. This means that the wrapping div and its children elements 17 | are not re-rendered, resulting in a better performance. 18 | 19 | ### Installation 20 | 21 | ```console 22 | yarn add react-use-draggable-scroll 23 | ``` 24 | 25 | ```console 26 | npm install react-use-draggable-scroll 27 | ``` 28 | 29 | ### How to use 30 | 31 | All you have to do is to create a reference to the wrapping div and pass it as parameter to to the useDraggable hook. 32 | The hook is totally unstyled. You can use any library of your choice to style the div and the child components as you would normally do. 33 | In the example below, we use TailwindCSS to illustrate. 34 | 35 | Just recapping some basics of CSS that you will probably use along with this hook: It is important to set `overflow-x: scroll;` 36 | property in the CSS of the wrapping div to create the scroll (same goes for y-direction, if that is your case). To prevent a 37 | flex item from growing or shrinking, use the CSS property `flex: none;`. 38 | 39 | **In Javascript:** 40 | 41 | ```javascript 42 | import { useRef } from "react"; 43 | import { useDraggable } from "react-use-draggable-scroll"; 44 | 45 | export default function MyComponent() { 46 | const ref = useRef(); // We will use React useRef hook to reference the wrapping div: 47 | const { events } = useDraggable(ref); // Now we pass the reference to the useDraggable hook: 48 | 49 | return ( 50 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | ); 66 | } 67 | ``` 68 | 69 | **In Typescript:** 70 | 71 | ```typescript 72 | import { useRef } from "react"; 73 | import { useDraggable } from "react-use-draggable-scroll"; 74 | 75 | export default function MyComponent(): JSX.Element { 76 | // We will use React useRef hook to reference the wrapping div: 77 | const ref = 78 | useRef() as React.MutableRefObject; 79 | const { events } = useDraggable(ref); // Now we pass the reference to the useDraggable hook: 80 | 81 | return ( 82 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | ); 98 | } 99 | ``` 100 | 101 | ### Additional settings: 102 | 103 | **`activeMouseButton`: Change which mouse button starts a scroll** 104 | 105 | By default, holding left mouse button will start a scroll. However, you can also use the middle or right 106 | mouse button to start a scroll. 107 | 108 | Accepts `"Left" | "Right" | "Middle` 109 | 110 | ```typescript 111 | const { events } = useDraggable(ref, { 112 | activeMouseButton?: "Middle"; // Sets which mouse button starts a scroll 113 | }); 114 | ``` 115 | 116 | **`applyRubberBandEffect`: Rubber Band Effect** 117 | 118 | It is possible to toggle a rubber band effect on and off for when the 119 | user scrolls past the end of the container. This effect is turned off by default to avoid conflicting CSS style rules in code that uses earlier versions of this hook. 120 | 121 | ```typescript 122 | const { events } = useDraggable(ref, { 123 | applyRubberBandEffect: true, // activate rubber band effect 124 | }); 125 | ``` 126 | 127 | > :warning: **If you are using rubber band effect**: This effect is applied 128 | > using the `transform` CSS property. User-defined styles can be overridden when `applyRubberBandEffect` is `true` (default value is `false`). 129 | 130 | **`decayRate`: Control the decay rate of the inertial effect** 131 | 132 | You can also control the decay rate of the inertial effect by using an optional 133 | parameter. The default value is 0.95, which means that at the speed will decay 5% of 134 | its current value at every 1/60 seconds. 135 | 136 | ```typescript 137 | const { events } = useDraggable(ref, { 138 | decayRate: 0.96, // specify the decay rate 139 | }); 140 | ``` 141 | 142 | **`safeDisplacement`: Control the drag sensitivity** 143 | 144 | Finally, you can control drag sensitivity by using an optional parameter that states 145 | the minimum distance in order to distinguish an intentional drag movement from 146 | an unwanted one, which should be instead considered as a click. 147 | The default value is 10, which means that when a drag movement travels for 10 pixels 148 | or less it is considered unintentional. In this scenario, the drag operation would 149 | still be performed, but the closing mouse-up event would still be propagated to the 150 | rest of the DOM. 151 | 152 | ```typescript 153 | const { events } = useDraggable(ref, { 154 | safeDisplacement: 11, // specify the drag sensitivity 155 | }); 156 | ``` 157 | 158 | **`isMounted`: Determine if ref is available or not, default `true`** 159 | 160 | In some use cases, such as you need to use `useImperativeHandle`, 161 | you will need to wait for the component to be rendered and the Ref to be accessible before 162 | using the `useDraggable` hook. In this case, use `isMounted` as a controllable switch. 163 | 164 | ```typescript 165 | const { events } = useDraggable(ref, { 166 | isMounted: true, 167 | }); 168 | ``` 169 | 170 | ### Contributing 171 | 172 | See [CONTRIBUTING.md](CONTRIBUTING.md). 173 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type CardPros = { 4 | onClick: () => void; 5 | } & React.ButtonHTMLAttributes; 6 | 7 | const Card = ({ onClick, ...rest }: CardPros): JSX.Element => { 8 | return ( 9 | 35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 | ); 49 | } 50 | 51 | export default HorizontalExample; 52 | -------------------------------------------------------------------------------- /example/components/TwoDimensionalExample.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useRef, useState } from "react"; 3 | import { useDraggable } from "../../src"; 4 | 5 | import Card from "./Card"; 6 | 7 | function TwoDimensionalExample(): JSX.Element { 8 | const [numberOfEventsFired, setNumberOfEventsFired] = useState(0); 9 | 10 | const handleClickEvent = () => { 11 | setNumberOfEventsFired((oldState) => oldState + 1); 12 | }; 13 | 14 | const ref = 15 | useRef() as React.MutableRefObject; 16 | const { events } = useDraggable(ref, { 17 | decayRate: 0.96, 18 | safeDisplacement: 11, 19 | applyRubberBandEffect: true, 20 | }); 21 | 22 | return ( 23 |
24 |

Example #3

25 | 26 |
27 |

Counter: {numberOfEventsFired}

28 | 35 |
36 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 | ); 65 | } 66 | 67 | export default TwoDimensionalExample; 68 | -------------------------------------------------------------------------------- /example/components/VerticalExample.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useRef, useState } from "react"; 3 | import { useDraggable } from "../../src"; 4 | 5 | import Card from "./Card"; 6 | 7 | function VerticalExample(): JSX.Element { 8 | const [numberOfEventsFired, setNumberOfEventsFired] = useState(0); 9 | 10 | const handleClickEvent = () => { 11 | setNumberOfEventsFired((oldState) => oldState + 1); 12 | }; 13 | 14 | const ref = 15 | useRef() as React.MutableRefObject; 16 | const { events } = useDraggable(ref, { 17 | decayRate: 0.96, 18 | safeDisplacement: 11, 19 | applyRubberBandEffect: true, 20 | }); 21 | 22 | return ( 23 |
24 |

Example #2

25 | 26 |
27 |

Counter: {numberOfEventsFired}

28 | 35 |
36 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | ); 53 | } 54 | 55 | export default VerticalExample; 56 | -------------------------------------------------------------------------------- /example/components/WidthMultiRefComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | forwardRef, 4 | useImperativeHandle, 5 | useRef, 6 | useState, 7 | useEffect, 8 | } from "react"; 9 | import { useDraggable } from "../../src"; 10 | import Card from "./Card"; 11 | 12 | type ChildrenProps = { 13 | nestedElementProps: { [key: string]: any }; 14 | }; 15 | type MultiRefs = { 16 | nestedRef: React.MutableRefObject; 17 | }; 18 | 19 | // Children 20 | const MultiRefChildren = forwardRef( 21 | ({ nestedElementProps }: ChildrenProps, ref) => { 22 | const rootRef = 23 | useRef() as React.MutableRefObject; 24 | const nestedRef = 25 | useRef() as React.MutableRefObject; 26 | 27 | useImperativeHandle(ref, () => ({ 28 | rootRef, 29 | nestedRef, 30 | })); 31 | 32 | const [numberOfEventsFired, setNumberOfEventsFired] = useState(0); 33 | 34 | const handleClickEvent = () => { 35 | setNumberOfEventsFired((oldState) => oldState + 1); 36 | }; 37 | 38 | return ( 39 |
40 |

Example #4

41 |

42 | Use multiple ref e.g., `useImperativeHandle`, with `isMounted`. 43 |

44 |
45 |

Counter: {numberOfEventsFired}

46 | 53 |
54 |
59 | {Array.from(Array(40).keys()).map((e) => ( 60 | 61 | ))} 62 |
63 |
64 | ); 65 | } 66 | ); 67 | 68 | // Parent 69 | function WidthMultiRefExample(): JSX.Element { 70 | const multiRefs = React.useRef({ 71 | nestedRef: { current: null }, 72 | }); 73 | 74 | const [mounted, setMounted] = React.useState(false); 75 | 76 | const { events } = useDraggable( 77 | multiRefs.current?.nestedRef as React.MutableRefObject, 78 | { 79 | decayRate: 0.96, 80 | safeDisplacement: 11, 81 | applyRubberBandEffect: true, 82 | isMounted: mounted, 83 | } 84 | ); 85 | 86 | useEffect(() => { 87 | setMounted(true); 88 | }, []); 89 | 90 | return ( 91 | 92 | ); 93 | } 94 | 95 | export default WidthMultiRefExample; 96 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import "react-app-polyfill/ie11"; 2 | import * as React from "react"; 3 | import * as ReactDOM from "react-dom"; 4 | 5 | import "./styles.css"; 6 | 7 | import VerticalExample from "./components/VerticalExample"; 8 | import HorizontalExample from "./components/HorizontalExample"; 9 | import TwoDimensionalExample from "./components/TwoDimensionalExample"; 10 | import WidthMultiRefExample from "./components/WidthMultiRefComponent"; 11 | 12 | const App = () => { 13 | return ( 14 |
15 |

Example of usage

16 | 17 |

18 | 19 | react-use-draggable-scroll 20 | {" "} 21 | does not interfere with other events passing through the same scroll 22 | container. In this example, clicking on one of the cards below causes a 23 | counter to increase its value. 24 |

25 | 26 |
27 | 28 | 29 | 30 | 31 |
32 |
33 | ); 34 | }; 35 | 36 | ReactDOM.render(, document.getElementById("root")); 37 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "parcel-bundler": "^1.12.5", 12 | "react-app-polyfill": "^1.0.0" 13 | }, 14 | "alias": { 15 | "react": "../node_modules/react", 16 | "react-dom": "../node_modules/react-dom/profiling", 17 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^16.9.11", 21 | "@types/react-dom": "^16.8.4", 22 | "autoprefixer": "^9", 23 | "parcel": "1.12.3", 24 | "postcss": "^7", 25 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.17", 26 | "typescript": "^3.4.5" 27 | }, 28 | "resolutions": { 29 | "@babel/preset-env": "7.13.8" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.4.5", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why" 21 | }, 22 | "peerDependencies": { 23 | "react": ">=16" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": true, 33 | "singleQuote": false, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "react-use-draggable-scroll", 37 | "author": "Renato Fuzaro Miotto", 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/rfmiotto/react-use-draggable-scroll.git" 41 | }, 42 | "homepage": "https://github.com/rfmiotto/react-use-draggable-scroll", 43 | "keywords": [ 44 | "react", 45 | "hook", 46 | "drag", 47 | "scroll", 48 | "mouse" 49 | ], 50 | "module": "dist/react-use-draggable-scroll.esm.js", 51 | "size-limit": [ 52 | { 53 | "path": "dist/react-use-draggable-scroll.cjs.production.min.js", 54 | "limit": "10 KB" 55 | }, 56 | { 57 | "path": "dist/react-use-draggable-scroll.esm.js", 58 | "limit": "10 KB" 59 | } 60 | ], 61 | "devDependencies": { 62 | "@size-limit/preset-small-lib": "^5.0.2", 63 | "@types/react": "^17.0.17", 64 | "@types/react-dom": "^17.0.9", 65 | "@typescript-eslint/eslint-plugin": "^4.29.0", 66 | "@typescript-eslint/parser": "^4.29.0", 67 | "eslint": "^7.32.0", 68 | "eslint-config-airbnb": "^18.2.1", 69 | "eslint-config-airbnb-base": "^14.2.1", 70 | "eslint-config-next": "11.0.1", 71 | "eslint-config-prettier": "^8.3.0", 72 | "eslint-import-resolver-typescript": "^2.4.0", 73 | "eslint-plugin-import": "^2.23.4", 74 | "eslint-plugin-import-helpers": "^1.1.0", 75 | "eslint-plugin-jsx-a11y": "^6.4.1", 76 | "eslint-plugin-prettier": "^3.4.0", 77 | "eslint-plugin-react": "^7.24.0", 78 | "eslint-plugin-react-hooks": "^4.2.0", 79 | "husky": "^7.0.1", 80 | "prettier": "^2.3.2", 81 | "react": "^17.0.2", 82 | "react-dom": "^17.0.2", 83 | "size-limit": "^5.0.2", 84 | "tsdx": "^0.14.1", 85 | "tslib": "^2.3.0", 86 | "typescript": "^4.3.5" 87 | }, 88 | "dependencies": {} 89 | } 90 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect, useRef } from "react"; 2 | 3 | import useLayoutEffect from "./useIsomorphicLayoutEffect"; 4 | 5 | type OptionsType = { 6 | decayRate?: number; 7 | safeDisplacement?: number; 8 | applyRubberBandEffect?: boolean; 9 | activeMouseButton?: "Left" | "Middle" | "Right"; 10 | isMounted?: boolean; 11 | }; 12 | 13 | type ReturnType = { 14 | events: { 15 | onMouseDown: (e: React.MouseEvent) => void; 16 | }; 17 | }; 18 | 19 | export function useDraggable( 20 | ref: MutableRefObject, 21 | { 22 | decayRate = 0.95, 23 | safeDisplacement = 10, 24 | applyRubberBandEffect = false, 25 | activeMouseButton = "Left", 26 | isMounted = true, 27 | }: OptionsType = {} 28 | ): ReturnType { 29 | const internalState = useRef({ 30 | isMouseDown: false, 31 | isDraggingX: false, 32 | isDraggingY: false, 33 | initialMouseX: 0, 34 | initialMouseY: 0, 35 | lastMouseX: 0, 36 | lastMouseY: 0, 37 | scrollSpeedX: 0, 38 | scrollSpeedY: 0, 39 | lastScrollX: 0, 40 | lastScrollY: 0, 41 | }); 42 | 43 | let isScrollableAlongX = false; 44 | let isScrollableAlongY = false; 45 | let maxHorizontalScroll = 0; 46 | let maxVerticalScroll = 0; 47 | let cursorStyleOfWrapperElement: string; 48 | let cursorStyleOfChildElements: string[]; 49 | let transformStyleOfChildElements: string[]; 50 | let transitionStyleOfChildElements: string[]; 51 | 52 | const timing = (1 / 60) * 1000; // period of most monitors (60fps) 53 | 54 | useLayoutEffect(() => { 55 | if (isMounted) { 56 | isScrollableAlongX = 57 | window.getComputedStyle(ref.current).overflowX === "scroll"; 58 | isScrollableAlongY = 59 | window.getComputedStyle(ref.current).overflowY === "scroll"; 60 | 61 | maxHorizontalScroll = ref.current.scrollWidth - ref.current.clientWidth; 62 | maxVerticalScroll = ref.current.scrollHeight - ref.current.clientHeight; 63 | 64 | cursorStyleOfWrapperElement = window.getComputedStyle(ref.current).cursor; 65 | 66 | cursorStyleOfChildElements = []; 67 | transformStyleOfChildElements = []; 68 | transitionStyleOfChildElements = []; 69 | 70 | (ref.current.childNodes as NodeListOf).forEach( 71 | (child: HTMLElement) => { 72 | cursorStyleOfChildElements.push( 73 | window.getComputedStyle(child).cursor 74 | ); 75 | 76 | transformStyleOfChildElements.push( 77 | window.getComputedStyle(child).transform === "none" 78 | ? "" 79 | : window.getComputedStyle(child).transform 80 | ); 81 | 82 | transitionStyleOfChildElements.push( 83 | window.getComputedStyle(child).transition === "none" 84 | ? "" 85 | : window.getComputedStyle(child).transition 86 | ); 87 | } 88 | ); 89 | } 90 | }, [isMounted]); 91 | 92 | const runScroll = () => { 93 | const dx = internalState.current.scrollSpeedX * timing; 94 | const dy = internalState.current.scrollSpeedY * timing; 95 | const offsetX = ref.current.scrollLeft + dx; 96 | const offsetY = ref.current.scrollTop + dy; 97 | 98 | ref.current.scrollLeft = offsetX; // eslint-disable-line no-param-reassign 99 | ref.current.scrollTop = offsetY; // eslint-disable-line no-param-reassign 100 | internalState.current.lastScrollX = offsetX; 101 | internalState.current.lastScrollY = offsetY; 102 | }; 103 | 104 | const rubberBandCallback = (e: MouseEvent) => { 105 | const dx = e.clientX - internalState.current.initialMouseX; 106 | const dy = e.clientY - internalState.current.initialMouseY; 107 | 108 | const { clientWidth, clientHeight } = ref.current; 109 | 110 | let displacementX = 0; 111 | let displacementY = 0; 112 | 113 | if (isScrollableAlongX && isScrollableAlongY) { 114 | displacementX = 115 | 0.3 * 116 | clientWidth * 117 | Math.sign(dx) * 118 | Math.log10(1.0 + (0.5 * Math.abs(dx)) / clientWidth); 119 | displacementY = 120 | 0.3 * 121 | clientHeight * 122 | Math.sign(dy) * 123 | Math.log10(1.0 + (0.5 * Math.abs(dy)) / clientHeight); 124 | } else if (isScrollableAlongX) { 125 | displacementX = 126 | 0.3 * 127 | clientWidth * 128 | Math.sign(dx) * 129 | Math.log10(1.0 + (0.5 * Math.abs(dx)) / clientWidth); 130 | } else if (isScrollableAlongY) { 131 | displacementY = 132 | 0.3 * 133 | clientHeight * 134 | Math.sign(dy) * 135 | Math.log10(1.0 + (0.5 * Math.abs(dy)) / clientHeight); 136 | } 137 | 138 | (ref.current.childNodes as NodeListOf).forEach( 139 | (child: HTMLElement) => { 140 | child.style.transform = `translate3d(${displacementX}px, ${displacementY}px, 0px)`; // eslint-disable-line no-param-reassign 141 | child.style.transition = "transform 0ms"; // eslint-disable-line no-param-reassign 142 | } 143 | ); 144 | }; 145 | 146 | const recoverChildStyle = () => { 147 | (ref.current.childNodes as NodeListOf).forEach( 148 | (child: HTMLElement, i) => { 149 | child.style.transform = transformStyleOfChildElements[i]; // eslint-disable-line no-param-reassign 150 | child.style.transition = transitionStyleOfChildElements[i]; // eslint-disable-line no-param-reassign 151 | } 152 | ); 153 | }; 154 | 155 | let rubberBandAnimationTimer: NodeJS.Timeout; 156 | let keepMovingX: NodeJS.Timer; 157 | let keepMovingY: NodeJS.Timer; 158 | 159 | const callbackMomentum = () => { 160 | const minimumSpeedToTriggerMomentum = 0.05; 161 | 162 | keepMovingX = setInterval(() => { 163 | const lastScrollSpeedX = internalState.current.scrollSpeedX; 164 | const newScrollSpeedX = lastScrollSpeedX * decayRate; 165 | internalState.current.scrollSpeedX = newScrollSpeedX; 166 | 167 | const isAtLeft = ref.current.scrollLeft <= 0; 168 | const isAtRight = ref.current.scrollLeft >= maxHorizontalScroll; 169 | const hasReachedHorizontalEdges = isAtLeft || isAtRight; 170 | 171 | runScroll(); 172 | 173 | if ( 174 | Math.abs(newScrollSpeedX) < minimumSpeedToTriggerMomentum || 175 | internalState.current.isMouseDown || 176 | hasReachedHorizontalEdges 177 | ) { 178 | internalState.current.scrollSpeedX = 0; 179 | clearInterval(keepMovingX); 180 | } 181 | }, timing); 182 | 183 | keepMovingY = setInterval(() => { 184 | const lastScrollSpeedY = internalState.current.scrollSpeedY; 185 | const newScrollSpeedY = lastScrollSpeedY * decayRate; 186 | internalState.current.scrollSpeedY = newScrollSpeedY; 187 | 188 | const isAtTop = ref.current.scrollTop <= 0; 189 | const isAtBottom = ref.current.scrollTop >= maxVerticalScroll; 190 | const hasReachedVerticalEdges = isAtTop || isAtBottom; 191 | 192 | runScroll(); 193 | 194 | if ( 195 | Math.abs(newScrollSpeedY) < minimumSpeedToTriggerMomentum || 196 | internalState.current.isMouseDown || 197 | hasReachedVerticalEdges 198 | ) { 199 | internalState.current.scrollSpeedY = 0; 200 | clearInterval(keepMovingY); 201 | } 202 | }, timing); 203 | 204 | internalState.current.isDraggingX = false; 205 | internalState.current.isDraggingY = false; 206 | 207 | if (applyRubberBandEffect) { 208 | const transitionDurationInMilliseconds = 250; 209 | 210 | (ref.current.childNodes as NodeListOf).forEach( 211 | (child: HTMLElement) => { 212 | child.style.transform = `translate3d(0px, 0px, 0px)`; // eslint-disable-line no-param-reassign 213 | child.style.transition = `transform ${transitionDurationInMilliseconds}ms`; // eslint-disable-line no-param-reassign 214 | } 215 | ); 216 | 217 | rubberBandAnimationTimer = setTimeout( 218 | recoverChildStyle, 219 | transitionDurationInMilliseconds 220 | ); 221 | } 222 | }; 223 | 224 | const preventClick = (e: Event) => { 225 | e.preventDefault(); 226 | e.stopImmediatePropagation(); 227 | // e.stopPropagation(); 228 | }; 229 | 230 | const getIsMousePressActive = (buttonsCode: number) => { 231 | return ( 232 | (activeMouseButton === "Left" && buttonsCode === 1) || 233 | (activeMouseButton === "Middle" && buttonsCode === 4) || 234 | (activeMouseButton === "Right" && buttonsCode === 2) 235 | ); 236 | }; 237 | 238 | const onMouseDown = (e: React.MouseEvent) => { 239 | const isMouseActive = getIsMousePressActive(e.buttons); 240 | if (!isMouseActive) { 241 | return; 242 | } 243 | 244 | internalState.current.isMouseDown = true; 245 | internalState.current.lastMouseX = e.clientX; 246 | internalState.current.lastMouseY = e.clientY; 247 | internalState.current.initialMouseX = e.clientX; 248 | internalState.current.initialMouseY = e.clientY; 249 | }; 250 | 251 | const onMouseUp = (e: MouseEvent) => { 252 | const isDragging = 253 | internalState.current.isDraggingX || internalState.current.isDraggingY; 254 | 255 | const dx = internalState.current.initialMouseX - e.clientX; 256 | const dy = internalState.current.initialMouseY - e.clientY; 257 | 258 | const isMotionIntentional = 259 | Math.abs(dx) > safeDisplacement || Math.abs(dy) > safeDisplacement; 260 | 261 | const isDraggingConfirmed = isDragging && isMotionIntentional; 262 | 263 | if (isDraggingConfirmed) { 264 | ref.current.childNodes.forEach((child) => { 265 | child.addEventListener("click", preventClick); 266 | }); 267 | } else { 268 | ref.current.childNodes.forEach((child) => { 269 | child.removeEventListener("click", preventClick); 270 | }); 271 | } 272 | 273 | internalState.current.isMouseDown = false; 274 | internalState.current.lastMouseX = 0; 275 | internalState.current.lastMouseY = 0; 276 | 277 | ref.current.style.cursor = cursorStyleOfWrapperElement; // eslint-disable-line no-param-reassign 278 | (ref.current.childNodes as NodeListOf).forEach( 279 | (child: HTMLElement, i) => { 280 | child.style.cursor = cursorStyleOfChildElements[i]; // eslint-disable-line no-param-reassign 281 | } 282 | ); 283 | 284 | if (isDraggingConfirmed) { 285 | callbackMomentum(); 286 | } 287 | }; 288 | 289 | const onMouseMove = (e: MouseEvent) => { 290 | if (!internalState.current.isMouseDown) { 291 | return; 292 | } 293 | 294 | e.preventDefault(); 295 | 296 | const dx = internalState.current.lastMouseX - e.clientX; 297 | internalState.current.lastMouseX = e.clientX; 298 | 299 | internalState.current.scrollSpeedX = dx / timing; 300 | internalState.current.isDraggingX = true; 301 | 302 | const dy = internalState.current.lastMouseY - e.clientY; 303 | internalState.current.lastMouseY = e.clientY; 304 | 305 | internalState.current.scrollSpeedY = dy / timing; 306 | internalState.current.isDraggingY = true; 307 | 308 | ref.current.style.cursor = "grabbing"; // eslint-disable-line no-param-reassign 309 | (ref.current.childNodes as NodeListOf).forEach( 310 | (child: HTMLElement) => { 311 | child.style.cursor = "grabbing"; // eslint-disable-line no-param-reassign 312 | } 313 | ); 314 | 315 | const isAtLeft = ref.current.scrollLeft <= 0 && isScrollableAlongX; 316 | const isAtRight = 317 | ref.current.scrollLeft >= maxHorizontalScroll && isScrollableAlongX; 318 | const isAtTop = ref.current.scrollTop <= 0 && isScrollableAlongY; 319 | const isAtBottom = 320 | ref.current.scrollTop >= maxVerticalScroll && isScrollableAlongY; 321 | const isAtAnEdge = isAtLeft || isAtRight || isAtTop || isAtBottom; 322 | 323 | if (isAtAnEdge && applyRubberBandEffect) { 324 | rubberBandCallback(e); 325 | } 326 | 327 | runScroll(); 328 | }; 329 | 330 | const handleResize = () => { 331 | maxHorizontalScroll = ref.current.scrollWidth - ref.current.clientWidth; 332 | maxVerticalScroll = ref.current.scrollHeight - ref.current.clientHeight; 333 | }; 334 | 335 | useEffect(() => { 336 | if (isMounted) { 337 | window.addEventListener("mouseup", onMouseUp); 338 | window.addEventListener("mousemove", onMouseMove); 339 | window.addEventListener("resize", handleResize); 340 | } 341 | return () => { 342 | window.removeEventListener("mouseup", onMouseUp); 343 | window.removeEventListener("mousemove", onMouseMove); 344 | window.removeEventListener("resize", handleResize); 345 | 346 | clearInterval(keepMovingX); 347 | clearInterval(keepMovingY); 348 | clearTimeout(rubberBandAnimationTimer); 349 | }; 350 | }, [isMounted]); 351 | 352 | return { 353 | events: { 354 | onMouseDown, 355 | }, 356 | }; 357 | } 358 | -------------------------------------------------------------------------------- /src/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useEffect } from 'react'; 2 | 3 | const useIsomorphicLayoutEffect = 4 | typeof window !== 'undefined' ? useLayoutEffect : useEffect; 5 | 6 | export default useIsomorphicLayoutEffect; 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: "jit", 3 | purge: [], 4 | darkMode: false, // or 'media' or 'class' 5 | theme: { 6 | extend: {}, 7 | }, 8 | variants: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | --------------------------------------------------------------------------------