├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── .npmignore ├── index.html ├── package.json ├── src │ ├── dat.tsx │ ├── data.tsx │ ├── index.tsx │ └── style.css ├── tsconfig.json └── yarn.lock ├── package.json ├── src └── index.tsx ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "react-app", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "version": "detect" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | dist 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage/ 3 | /assets/ 4 | Thumbs.db 5 | ehthumbs.db 6 | Desktop.ini 7 | $RECYCLE.BIN/ 8 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | cache: 5 | yarn: true 6 | directories: 7 | - /home/travis/.dts/typescript-installs/ 8 | script: 9 | - 'yarn test' 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 2.2.2 Release 4 | 5 | **Summary:** Minor style adjustments. 6 | 7 | ### Changes 8 | 9 | 1. Only add `style.width: '100%'` when no `className` prop is provided. 10 | 11 | # 2.2.0 Release 12 | 13 | **Summary:** Removes outer div, adds className as styling options. 14 | 15 | ### Changes 16 | 17 | 1. Removed outer div. 18 | 2. Remove `rootStyle` option. 19 | 20 | ### Added 21 | 22 | 1. Add `styleClassName` prop to style slides. 23 | 24 | ### Fixes 25 | 26 | 1. `slideAlign` behaviour in vertical mode should work properly. 27 | 28 | # 2.1.2 Release 29 | 30 | **Summary:** Added `indexRange` and `rootStyle` options. 31 | 32 | ### Added 33 | 34 | 1. `indexRange: [number, number]` config option to artificially limit the index ranges. 35 | 2. `rootStyle` option styles the inner root `
`. 36 | 37 | # 2.1.1 Release 38 | 39 | **Summary:** This lib is now using `tsdx`. 40 | 41 | ### Added 42 | 43 | 1. `releaseSpring` config option. 44 | 2. `slideAlign` option to align slides. 45 | 46 | ### Changes 47 | 48 | 1. Removed intersection observer polyfill. 49 | 50 | # 2.0.0 Release 51 | 52 | **Summary:** Move to `react-use-gesture` v7 and `react-spring` v9. Shouldn't break anything but the trailing behavior is slightly different so you might want to keep v1.x. 53 | 54 | ### Added 55 | 56 | 1. `trailingDelay` option. 57 | 58 | # 1.0.5-7 Releases 59 | 60 | **Summary:** Package updates. 61 | 62 | # 1.0.4 Release 63 | 64 | **Summary:** Removes lodash dependency for `clamp`. 65 | 66 | # 1.0.3 Release 67 | 68 | **Summary:** Fixed unmount bug affecting IntersectionObserver. 69 | 70 | # 1.0.2 Release 71 | 72 | **Summary:** Deps update. 73 | 74 | ## 1.0.1 Release 75 | 76 | **Summary:** Added vertical mode. 77 | 78 | ### Added 79 | 80 | 1. Added vertical prop to set a vertical sliding mode. 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David Bismut 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 | # react-soft-slider 2 | 3 | ![npm (tag)](https://img.shields.io/npm/v/react-soft-slider) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-soft-slider) ![GitHub](https://img.shields.io/github/license/dbismut/react-soft-slider) 4 | 5 |

6 | 7 |

8 |

9 | Demo 10 | [Source] 11 |

12 | 13 | React-soft-slider is a minimally-featured carousel. It focuses on providing the best user experience for manipulating slides. It doesn't try to implement additional features such as pagination dots, next and previous buttons, autoplay. If you're looking for a slider that has all this, there's [plenty](https://github.com/akiran/react-slick) of [alternatives](https://github.com/FormidableLabs/nuka-carousel) [out](https://github.com/express-labs/pure-react-carousel) [there](https://github.com/voronianski/react-swipe). 14 | 15 | This allows react-soft-slider to be highly impartial when it comes to styling, so you shouldn't be fighting too hard to making the slider slider look the way you want. 16 | 17 | - **Touch-gesture compatible:** handles swipe and drag on mobile and desktop devices 18 | - **Spring animations:** driven by high-performance springs 19 | - **Impartial styling:** you are responsible for the styling of your slides 20 | - **Fully responsive:** as long as your slides styling is responsive as well! 21 | - **Dynamic number of slides:** you can add or remove slides on the fly 22 | 23 | React-soft-slider is powered by [react-spring](https://github.com/react-spring/react-spring) for springs animation and [react-use-gesture](https://github.com/react-spring/react-use-gesture) for handling the drag gesture. 24 | 25 | ## Installation 26 | 27 | ``` 28 | npm install react-soft-slider 29 | ``` 30 | 31 | > ⚠️ You also want to add the [intersection-observer](https://www.npmjs.com/package/intersection-observer) and [resize-observer](resize-observer-polyfill) polyfills for full browser support. Check out adding the [polyfills](#polyfills) for details about how you can include it. 32 | 33 | ## Usage 34 | 35 | `` has a very limited logic, and essentially does two things: 36 | 37 | 1. it positions the slider to the slide matching the `index` you passed as a prop 38 | 2. when the user changes the slide, it will then fire `onIndexChange` that will pass you the new `index`. You will usually respond by updating the slider `index` prop: 39 | 40 | ```jsx 41 | import { Slider } from 'react-soft-slider' 42 | 43 | const slides = ['red', 'blue', 'yellow', 'orange'] 44 | const style = { width: 300, height: '100%', margin: '0 10px' } 45 | 46 | function App() { 47 | const [index, setIndex] = React.useState(0) 48 | 49 | return ( 50 | 55 | {slides.map((color, i) => ( 56 |
57 | ))} 58 | 59 | ) 60 | } 61 | ``` 62 | 63 | As you can see from the example, any child of the `` component is considered as a slide. You are fully responsible for the appearance of the slides, and each slide can be styled independently. 64 | 65 | > **Note:** although the above example uses hooks, react-soft-slider is compatible with Class-based components. However, since it internally uses hooks, it requires React `16.8+`. 66 | 67 | ### Props 68 | 69 | The `` component accepts the following props: 70 | 71 | | Name | Type | Description | Default Value | 72 | | ----------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | 73 | | `children` | `node` | elements you should pass to the slider and that will be considered as slides | Required | 74 | | `index` | `number` | the index of the slide that should be shown by the slider | Required | 75 | | `onIndexChange()` | `(newIndex: number) => void` | function called by the slider when the slide index should change | Required | 76 | | `indexRange` | `[number,number]` | sets the minimum and maximum index range the slider should slide through. If the maximum index is negative, then it's set relatively to the children length. [See example here](https://codesandbox.io/s/react-soft-slider-example-o7k0g). | | 77 | | `enabled` | `boolean` | enables or disables the slider gestures | `true` | 78 | | `vertical` | `boolean` | enables vertical sliding mode | `false` | 79 | | `draggedScale` | `Number` | scale factor of the slides when dragged | `1.0` | 80 | | `draggedSpring` | `object` | spring between the pointer and the dragged slide | `{ tension: 1200, friction: 40 }` | 81 | | `trailingSpring` | `object` | spring of the other slides | `{ tension: 120, friction: 30 }` | 82 | | `releaseSpring` | `object` | spring used when the slides rest (user releases the pointer) | `{ tension: 120, friction: 30 }` | 83 | | `trailingDelay` | `Number` | delay of trailing slides (in ms) | `50` | 84 | | `onDragStart()` | `(pressedIndex: number) => void` | function called when the drag starts, passing the index of the slide being dragged as an argument | | 85 | | `onDragEnd()` | `(pressedIndex: number) => void` | function called when the drag ends, passing the index of the slide being dragged as an argument | | 86 | | `className` | `string` | CSS class passed to the slider wrapper | | 87 | | `style` | `object` | style passed to the slider wrapper | | 88 | | `slideStyle` | `object` or `(i: number) => object` | style passed to the slides | | 89 | | `slideClassName` | `string` | CSS class passed to the slides | | 90 | | `slideAlign` | `string (align-items prop)` | slide alignment (`'center'`, `'flex-start'`, `'flex-end'`) | `'center'` | 91 | 92 | ### Springs configuration 93 | 94 | React-soft-slider uses two springs, one for the dragged slide, and one for the other slides, that you can configure to your liking. It accepts any options supported by react-spring, including durations if you're not happy with how springs feel [see here for more info](https://www.react-spring.io/docs/hooks/api). 95 | 96 | ## Gotchas 97 | 98 | **Sizing the slider** 99 | 100 | The slider wrapper has a default width set to `100%`, so that it fills its container by default. You can override this behaviour by passing your own `style` or `className` props. 101 | 102 | **Sizing your slides relatively to the slider** 103 | 104 | If you want to size your slides relatively to the slider width (let's say `width: 80%`), you'll need to rely on `slideStyle` set to `{{ minWidth: '80%' }}` and styling your slide with `width` set to `100%`. The same logic applies for height when in vertical sliding mode. 105 | 106 | **Don't use transform styling in slideStyle** 107 | 108 | React-soft-slider uses the `transform` attribute to make slides move so transform attributes in `slideStyle` will get overriden. 109 | 110 | **React-soft-slider is open to suggestions!** 111 | 112 | React-soft-slider will probably never include slider peripheral features, but is open to suggestions to make handling your slides easier! 113 | 114 | ### Polyfills 115 | 116 | You can import the 117 | [IntersectionObserver polyfill](https://www.npmjs.com/package/intersection-observer) and [ResizeObserver polyfill](https://www.npmjs.com/package/resize-observer-polyfill) directly or use 118 | a service like [polyfill.io](https://polyfill.io/v2/docs/) to add it when 119 | needed. 120 | 121 | ```sh 122 | yarn add intersection-observer resize-observer-polyfill 123 | ``` 124 | 125 | Then import it in your app: 126 | 127 | ```js 128 | import 'intersection-observer' 129 | import 'resize-observer-polyfill' 130 | ``` 131 | 132 | If you are using Webpack (or similar) you could use 133 | [dynamic imports](https://webpack.js.org/api/module-methods/#import-), to load 134 | the Polyfill only if needed. A basic implementation could look something like 135 | this: 136 | 137 | ```js 138 | /** 139 | * Do feature detection, to figure out which polyfills needs to be imported. 140 | **/ 141 | async function loadPolyfills() { 142 | if (typeof window.IntersectionObserver === 'undefined') { 143 | await import('intersection-observer') 144 | } 145 | if (typeof window.ResizeObserver === 'undefined') { 146 | await import('resize-observer-polyfill') 147 | } 148 | } 149 | ``` 150 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Soft Slider Example 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-soft-slider-example", 3 | "version": "1.0.0", 4 | "main": "./src/index.tsx", 5 | "scripts": { 6 | "start": "parcel index.html", 7 | "build": "parcel build index.html" 8 | }, 9 | "author": "David Bismut", 10 | "license": "MIT", 11 | "dependencies": { 12 | "react-app-polyfill": "^1.0.0", 13 | "react-soft-slider": "^2.1.0", 14 | "@tim-soft/react-dat-gui": "^4.0.11", 15 | "resize-observer-polyfill": "1.5.1" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^16.9.11", 19 | "@types/react-dom": "^16.8.4", 20 | "parcel": "^1.12.3", 21 | "typescript": "^3.4.5" 22 | }, 23 | "alias": { 24 | "react-soft-slider": "../src/index", 25 | "react": "../node_modules/react", 26 | "react-dom": "../node_modules/react-dom/profiling", 27 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/src/dat.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DatGui, { 3 | DatBoolean, 4 | DatButton, 5 | DatNumber, 6 | DatFolder, 7 | DatSelect 8 | } from '@tim-soft/react-dat-gui' 9 | 10 | import { slides, springOptions } from './data' 11 | 12 | const Dat = ({ data, onUpdate }) => { 13 | const addSlide = () => 14 | data.nbSlides < slides.length - 1 && 15 | onUpdate(({ nbSlides, ...rest }) => ({ ...rest, nbSlides: nbSlides + 1 })) 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 39 | 46 | 47 | 48 | 49 | 55 | 61 | 66 | 71 | 76 | 77 | 78 | ) 79 | } 80 | 81 | export default Dat 82 | -------------------------------------------------------------------------------- /example/src/data.tsx: -------------------------------------------------------------------------------- 1 | import { config } from 'react-spring' 2 | 3 | export const slides = [ 4 | 'https://images.pexels.com/photos/1055272/pexels-photo-1055272.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 5 | 'https://images.pexels.com/photos/704569/pexels-photo-704569.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 6 | 'https://images.pexels.com/photos/953206/pexels-photo-953206.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 7 | 'https://images.pexels.com/photos/704571/pexels-photo-704571.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 8 | 'https://images.pexels.com/photos/735280/pexels-photo-735280.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 9 | 'https://images.pexels.com/photos/376464/pexels-photo-376464.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 10 | 'https://images.pexels.com/photos/85910/coffee-hot-mug-stripes-85910.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800', 11 | 'https://images.pexels.com/photos/5510/bread-food-plate-rucola.jpg?auto=compress&cs=tinysrgb&dpr=2&w=800', 12 | 'https://images.pexels.com/photos/543730/pexels-photo-543730.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=800' 13 | ] 14 | 15 | export const defaultState = { 16 | enabled: true, 17 | autoplay: false, 18 | vertical: false, 19 | index: 0, 20 | nbSlides: 4, 21 | draggedScale: 0.8, 22 | trailingDelay: 50, 23 | sliderWidth: 80, 24 | variableWidth: false, 25 | variableHeight: false, 26 | slideAlign: 'center', 27 | draggedSpring: 'default', 28 | trailingSpring: 'slow', 29 | releaseSpring: 'slow' 30 | } 31 | 32 | export const springOptions = { 33 | normal: config.default, 34 | ...config, 35 | default: undefined 36 | } 37 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useCallback, useEffect } from 'react' 2 | import { render } from 'react-dom' 3 | import { Slider } from 'react-soft-slider' 4 | import ResizeObserver from 'resize-observer-polyfill' 5 | import Dat from './dat' 6 | import { defaultState, slides, springOptions } from './data' 7 | 8 | import './style.css' 9 | 10 | // @ts-ignore 11 | window.ResizeObserver = window.ResizeObserver || ResizeObserver 12 | 13 | function App() { 14 | const [state, setState] = useState(defaultState) 15 | const timeout = useRef(0) 16 | const { 17 | autoplay, 18 | enabled, 19 | vertical, 20 | index, 21 | nbSlides, 22 | trailingDelay, 23 | draggedScale, 24 | sliderWidth, 25 | slideAlign, 26 | variableHeight, 27 | variableWidth, 28 | draggedSpring, 29 | trailingSpring, 30 | releaseSpring 31 | } = state 32 | 33 | const setIndex = useCallback(index => setState({ ...state, index }), [state]) 34 | 35 | const startAutoplay = useCallback(() => { 36 | if (autoplay) 37 | timeout.current = window.setInterval( 38 | () => setIndex((index + 1) % nbSlides), 39 | 5000 40 | ) 41 | }, [autoplay, index, nbSlides, setIndex]) 42 | 43 | const stopAutoplay = useCallback(() => void clearTimeout(timeout.current), []) 44 | 45 | const handleClick = i => { 46 | if (i !== state.index) { 47 | setIndex(i) 48 | } 49 | } 50 | 51 | useEffect(() => { 52 | startAutoplay() 53 | return stopAutoplay 54 | }, [startAutoplay, stopAutoplay]) 55 | 56 | return ( 57 | <> 58 | 59 | 84 | {slides.slice(0, nbSlides).map((url, i) => ( 85 |
handleClick(i)} 89 | style={{ 90 | width: variableWidth ? `${400 + (i % 3) * 100}px` : '100%', 91 | margin: `${ 92 | variableHeight && vertical 93 | ? 10 + (i % 5) * 5 94 | : vertical 95 | ? 10 96 | : 0 97 | }px ${ 98 | variableWidth && !vertical 99 | ? 10 + (i % 5) * 5 100 | : vertical 101 | ? 0 102 | : 10 103 | }px`, 104 | height: variableHeight ? `${300 + (i % 2) * 200}px` : '80%', 105 | backgroundImage: `url(${url})` 106 | }} 107 | /> 108 | ))} 109 | 110 | 111 | ) 112 | } 113 | 114 | render(, document.getElementById('root')) 115 | -------------------------------------------------------------------------------- /example/src/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | overscroll-behavior-y: contain; 8 | margin: 0; 9 | padding: 0; 10 | height: 100%; 11 | width: 100%; 12 | user-select: none; 13 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, 14 | sans-serif; 15 | } 16 | 17 | #root { 18 | overflow: hidden; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | width: 100%; 23 | height: 100%; 24 | background: linear-gradient(to bottom, #904e95, #e96443); 25 | cursor: url('https://uploads.codesandbox.io/uploads/user/b3e56831-8b98-4fee-b941-0e27f39883ab/Ad1_-cursor.png') 39 39, auto; 26 | } 27 | 28 | .wrapper { 29 | width: 80vw; 30 | height: 300px; 31 | border: 10px solid black; 32 | transition: all 450ms ease; 33 | } 34 | 35 | .slide { 36 | background-size: cover; 37 | background-repeat: no-repeat; 38 | background-position: center center; 39 | height: 80%; 40 | will-change: transform; 41 | box-shadow: 0 62.5px 125px -25px rgba(50, 50, 73, 0.5), 0 37.5px 75px -37.5px rgba(0, 0, 0, 0.6); 42 | } 43 | 44 | .react-dat-gui { 45 | z-index: 10000; 46 | } 47 | 48 | @media screen and (max-width: 812px) { 49 | .react-dat-gui { 50 | display: none; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 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 | "baseUrl": ".", 17 | "types": ["node"], 18 | "paths": { 19 | "react-soft-slider": ["../src"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-soft-slider", 3 | "version": "2.2.2", 4 | "description": "Simple, fast and impartial slider", 5 | "main": "dist/index.js", 6 | "module": "dist/react-soft-slider.esm.js", 7 | "typings": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "start": "tsdx watch", 13 | "build": "tsdx build", 14 | "test": "tsdx test --passWithNoTests", 15 | "lint": "tsdx lint", 16 | "prepare": "tsdx build" 17 | }, 18 | "jest": { 19 | "setupFiles": [ 20 | "@testing-library/react/dont-cleanup-after-each" 21 | ] 22 | }, 23 | "peerDependencies": { 24 | "react": ">= 16.8.0" 25 | }, 26 | "prettier": { 27 | "printWidth": 80, 28 | "semi": false, 29 | "singleQuote": true, 30 | "trailingComma": "none" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/dbismut/react-soft-slider.git" 35 | }, 36 | "keywords": [ 37 | "react", 38 | "slider", 39 | "carousel", 40 | "gesture", 41 | "touch", 42 | "drag", 43 | "spring" 44 | ], 45 | "author": "David Bismut (https://github.com/dbismut)", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/dbismut/react-soft-slider/issues" 49 | }, 50 | "homepage": "https://github.com/dbismut/react-soft-slider", 51 | "devDependencies": { 52 | "@babel/core": "^7.8.4", 53 | "@types/jest": "^25.1.1", 54 | "@types/react": "^16.9.19", 55 | "@types/react-dom": "^16.9.5", 56 | "@types/use-resize-observer": "^6.0.0", 57 | "babel-loader": "^8.0.6", 58 | "eslint": "^6.8.0", 59 | "husky": "^4.2.1", 60 | "react": "^16.12.0", 61 | "react-dom": "^16.12.0", 62 | "ts-loader": "^6.2.1", 63 | "tsdx": "^0.12.3", 64 | "tslib": "^1.10.0", 65 | "typescript": "^3.7.5" 66 | }, 67 | "dependencies": { 68 | "react-spring": "9.0.0-beta.34", 69 | "react-use-gesture": "^7.0.3", 70 | "use-resize-observer": "^6.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react' 2 | import { useSprings, animated, SpringConfig } from 'react-spring' 3 | import { useDrag } from 'react-use-gesture' 4 | import useResizeObserver from 'use-resize-observer' 5 | 6 | type SliderProps = { 7 | children: React.ReactNode[] 8 | index: number 9 | onIndexChange: (newIndex: number) => void 10 | className?: string 11 | style?: React.CSSProperties 12 | slideClassName?: string 13 | slideStyle?: React.CSSProperties | ((index: number) => React.CSSProperties) 14 | indexRange?: [number, number] 15 | onDragStart?: (pressedIndex: number) => void 16 | onDragEnd?: (pressedIndex: number) => void 17 | onTap?: (pressedIndex: number) => void 18 | } & typeof defaultProps 19 | 20 | const defaultProps = { 21 | enabled: true, 22 | vertical: false, 23 | slideAlign: 'center', 24 | draggedScale: 1, 25 | draggedSpring: { tension: 1200, friction: 40 } as SpringConfig, 26 | trailingSpring: { tension: 120, friction: 30 } as SpringConfig, 27 | releaseSpring: { tension: 120, friction: 30 } as SpringConfig, 28 | trailingDelay: 50 29 | } 30 | 31 | // style for the slides wrapper 32 | const slidesWrapperStyle = (vertical: boolean): React.CSSProperties => ({ 33 | display: 'flex', 34 | flexWrap: 'nowrap', 35 | alignItems: 'stretch', 36 | position: 'relative', 37 | WebkitUserSelect: 'none', 38 | userSelect: 'none', 39 | WebkitTouchCallout: 'none', 40 | flexDirection: vertical ? 'column' : 'row', 41 | touchAction: vertical ? 'pan-x' : 'pan-y' 42 | }) 43 | 44 | const clamp = (num: number, clamp: number, higher: number) => 45 | Math.min(Math.max(num, clamp), higher) 46 | 47 | export const Slider = ({ 48 | children, 49 | index, 50 | onIndexChange, 51 | className, 52 | style, 53 | slideStyle, 54 | slideClassName, 55 | enabled, 56 | vertical, 57 | indexRange, 58 | slideAlign, 59 | draggedScale, 60 | draggedSpring, 61 | releaseSpring, 62 | trailingSpring, 63 | trailingDelay, 64 | onDragStart, 65 | onDragEnd, 66 | onTap 67 | }: SliderProps) => { 68 | const slideStyleFunc = 69 | typeof slideStyle === 'function' ? slideStyle : () => slideStyle 70 | // root holds are slides wrapper node and we use a ResizeObserver 71 | // to observe its size in order to recompute the slides position 72 | // when it changes 73 | const root = useRef(null) 74 | const { width, height } = useResizeObserver({ ref: root }) 75 | 76 | const axis = vertical ? 'y' : 'x' 77 | const size = vertical ? height : width 78 | 79 | let [minIndex, maxIndex] = indexRange || [0, children.length - 1] 80 | maxIndex = maxIndex > 0 ? maxIndex : children.length - 1 + maxIndex 81 | 82 | // indexRef is an internal reference to the current slide index 83 | const indexRef = useRef(index) 84 | 85 | // restPos holds a reference to the adjusted position of the slider 86 | // when rested 87 | const restPos = useRef(0) 88 | const velocity = useRef(0) 89 | 90 | // visibleIndexes is a Set holding the index of slides that are 91 | // currently partially or fully visible (intersecting) in the 92 | // viewport 93 | const visibleIndexes = useRef(new Set()) 94 | const firstVisibleIndex = useRef(0) 95 | const lastVisibleIndex = useRef(0) 96 | 97 | // instances holds a ref to an array of controllers 98 | // to simulate a spring trail. Mechanics is directly 99 | // copied from here https://github.com/react-spring/react-spring/blob/31200a79843ce85200b2a7692e8f14788e60f9e9/src/useTrail.js#L14 100 | // const instances = useRef() 101 | 102 | // callback called by the intersection observer updating 103 | // visibleIndexes 104 | const cb: IntersectionObserverCallback = slides => { 105 | slides.forEach(({ isIntersecting, target }) => 106 | visibleIndexes.current[isIntersecting ? 'add' : 'delete']( 107 | Number(target.getAttribute('data-index')) 108 | ) 109 | ) 110 | const visibles = Array.from(visibleIndexes.current).sort() 111 | firstVisibleIndex.current = visibles[0] 112 | lastVisibleIndex.current = visibles[visibles.length - 1] 113 | } 114 | 115 | const observer = useRef(null) 116 | 117 | // we add the slides to the IntersectionObserver: 118 | // this is recomputed everytime the user adds or removes a slide 119 | useEffect(() => { 120 | if (!observer.current) observer.current = new IntersectionObserver(cb) 121 | Array.from(root.current!.children).forEach(t => 122 | observer.current!.observe(t) 123 | ) 124 | return () => observer.current!.disconnect() 125 | }, [children.length, root]) 126 | 127 | // setting the springs with initial position set to restPos: 128 | // this is important when adding slides since changing children 129 | // length recomputes useSprings 130 | const [springs, set] = useSprings(children.length, _i => { 131 | // zIndex will make sure the dragged slide stays on top of the others 132 | return { 133 | x: vertical ? 0 : restPos.current, 134 | y: vertical ? restPos.current : 0, 135 | s: 1, 136 | zIndex: 0, 137 | immediate: key => key === 'zIndex' 138 | } 139 | }) 140 | 141 | // everytime the index changes, we should calculate the right position 142 | // of the slide so that its centered: this is recomputed everytime 143 | // the index changes 144 | useEffect(() => { 145 | // if width and height haven't been set don't do anything 146 | // (this happens on first render before useResizeObserver had the time to kick in) 147 | if (!width || !height) return 148 | // here we take the selected slide 149 | // and calculate its position so its centered in the slides wrapper 150 | if (vertical) { 151 | const { offsetTop, offsetHeight } = root.current!.children[ 152 | index 153 | ] as HTMLElement 154 | restPos.current = Math.round(-offsetTop + (height - offsetHeight) / 2) 155 | } else { 156 | const { offsetLeft, offsetWidth } = root.current!.children[ 157 | index 158 | ] as HTMLElement 159 | restPos.current = Math.round(-offsetLeft + (width - offsetWidth) / 2) 160 | } 161 | // two options then: 162 | // 1. the index was changed through gestures: in that case indexRef 163 | // is equal to index, we just want to set the position where it should 164 | 165 | if (indexRef.current === index) { 166 | set(_i => ({ 167 | [axis]: restPos.current, 168 | s: 1, 169 | config: { ...releaseSpring, velocity: velocity.current } 170 | // config: key => 171 | // key === axis 172 | // ? { ...releaseSpring, velocity: velocity.current } 173 | // : undefined, 174 | })) 175 | } else { 176 | // 2. the user has changed the index props: in that case indexRef 177 | // is outdated and different from index. We want to animate depending 178 | // on the direction of the slide, with the furthest slide moving first 179 | // trailing the others 180 | 181 | const dir = index < indexRef.current ? -1 : 1 182 | // if direction is 1 then the first slide to animate should be the lowest 183 | // indexed visible slide, if -1 the highest 184 | const firstToMove = 185 | dir > 0 ? firstVisibleIndex.current : lastVisibleIndex.current 186 | set(i => { 187 | return { 188 | [axis]: restPos.current, 189 | s: 1, 190 | // config: key => key === axis && releaseSpring, 191 | config: releaseSpring, 192 | delay: 193 | i * dir < firstToMove * dir 194 | ? 0 195 | : Math.abs(firstToMove - i) * trailingDelay 196 | } 197 | }) 198 | } 199 | // finally we update indexRef to match index 200 | indexRef.current = index 201 | }, [ 202 | index, 203 | set, 204 | root, 205 | vertical, 206 | axis, 207 | height, 208 | width, 209 | releaseSpring, 210 | draggedSpring, 211 | trailingDelay 212 | ]) 213 | 214 | // adding the bind listener 215 | const bind = useDrag( 216 | ({ 217 | first, 218 | last, 219 | tap, 220 | vxvy: [vx, vy], 221 | delta: [dx, dy], 222 | swipe: [sx, sy], 223 | movement: [movX, movY], 224 | args: [pressedIndex], 225 | memo = springs[pressedIndex][axis].getValue() 226 | }) => { 227 | if (tap) { 228 | onTap && onTap(pressedIndex) 229 | return 230 | } 231 | const v = vertical ? vy : vx 232 | const dir = -Math.sign(vertical ? dy : dx) 233 | const mov = vertical ? movY : movX 234 | const swipe = vertical ? sy : sx 235 | 236 | if (first) { 237 | // if this is the first drag event, we're trailing the controllers 238 | // to the index being dragged and setting zIndex, scale and config 239 | set(i => { 240 | return { 241 | [axis]: memo + mov, 242 | s: draggedScale, 243 | config: key => 244 | key === axis && i === pressedIndex 245 | ? draggedSpring 246 | : trailingSpring, 247 | zIndex: i === pressedIndex ? 10 : 0 248 | } 249 | }) 250 | 251 | // triggering onDragStart prop if it exists 252 | onDragStart && onDragStart(pressedIndex) 253 | } else if (last) { 254 | // when the user releases the drag and the distance or speed are superior to a threshold 255 | // we update the indexRef 256 | if (Math.abs(mov) > size! / 2 || swipe !== 0) { 257 | indexRef.current = clamp( 258 | indexRef.current + (mov > 0 ? -1 : 1), 259 | minIndex, 260 | maxIndex 261 | ) 262 | } 263 | // if the index is not equal to indexRef we know we've moved a slide 264 | // so we tell the user to update its index in the next tick and our useEffect 265 | // will do the rest. RAF is used to make sure we're not updating the index 266 | // too fast: that might happen if the user wants to update a slide onClick 267 | // TODO - need an example 268 | if (index !== indexRef.current) { 269 | velocity.current = v 270 | requestAnimationFrame(() => onIndexChange(indexRef.current)) 271 | } 272 | // if the index hasn't changed then we set the position back to where it should be 273 | else 274 | set(() => ({ 275 | [axis]: restPos.current, 276 | s: 1, 277 | // config: key => key === axis && releaseSpring, 278 | config: releaseSpring 279 | })) 280 | 281 | // triggering onDragEnd prop if it exists 282 | onDragEnd && onDragEnd(pressedIndex) 283 | } 284 | 285 | // if not we're just dragging and we're just updating the position 286 | else { 287 | const firstToMove = 288 | dir > 0 ? firstVisibleIndex.current : lastVisibleIndex.current 289 | set(i => { 290 | return { 291 | [axis]: mov + memo, 292 | delay: 293 | i * dir < firstToMove * dir || i === pressedIndex 294 | ? 0 295 | : Math.abs(firstToMove - i) * trailingDelay, 296 | config: key => 297 | key === axis && i === pressedIndex 298 | ? draggedSpring 299 | : trailingSpring 300 | } 301 | }) 302 | } 303 | 304 | // and returning memo to keep the initial position in cache along drag 305 | return memo 306 | }, 307 | { enabled, axis, filterTaps: true } 308 | ) 309 | 310 | const rootStyle = slidesWrapperStyle(vertical) 311 | if (!className) rootStyle.width = '100%' 312 | 313 | return ( 314 |
315 | {springs.map(({ [axis]: pos, s, zIndex }, i) => ( 316 | 333 | {children[i]} 334 | 335 | ))} 336 |
337 | ) 338 | } 339 | 340 | Slider.defaultProps = defaultProps 341 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------