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 |
--------------------------------------------------------------------------------