├── example ├── src │ ├── react-app-env.d.ts │ ├── utils.ts │ ├── theme.ts │ ├── setupTests.ts │ ├── index.css │ ├── reportWebVitals.ts │ ├── UseScrollValueExample.tsx │ ├── index.tsx │ ├── FixedPositionExample.tsx │ ├── HorizontalTwoExample.tsx │ ├── UseScrollStateExample.tsx │ ├── HorizontalExample.tsx │ ├── StyledSectionsExample.tsx │ └── StickyPositionExample.tsx ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── src ├── components │ ├── Section.module.css │ ├── Section.tsx │ ├── Container.tsx │ └── Item.tsx ├── hooks │ ├── useId.ts │ ├── useLatestRef.ts │ ├── useResizeObserver.ts │ ├── useConst.ts │ ├── useObservableRef.ts │ ├── useScrollLayoutManager.ts │ ├── useScroll.tsx │ ├── useScrollValue.ts │ └── useScrollState.ts ├── index.ts └── utils.ts ├── README.md ├── .gitignore ├── tsconfig.json ├── rollup.config.ts ├── LICENSE └── package.json /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/scrollex/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/scrollex/HEAD/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/scrollex/HEAD/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (val: number, min: number, max: number) => { 2 | return Math.min(Math.max(val, min), max); 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/Section.module.css: -------------------------------------------------------------------------------- 1 | .heightFull { 2 | height: 100%; 3 | } 4 | 5 | .widthFull { 6 | width: 100%; 7 | } 8 | 9 | .relative { 10 | position: relative; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useId.ts: -------------------------------------------------------------------------------- 1 | import useConst from './useConst'; 2 | 3 | let counter = 0; 4 | const nextId = () => { 5 | counter = counter + 1; 6 | return String(counter); 7 | }; 8 | 9 | export const useId = () => useConst(nextId); 10 | -------------------------------------------------------------------------------- /example/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react'; 2 | 3 | const theme = extendTheme({ 4 | fonts: { 5 | heading: 'Arvo, sans-serif', 6 | body: 'Arvo, sans-serif', 7 | }, 8 | }); 9 | 10 | export default theme; 11 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scrollex 2 | 3 | A library to help you make beautiful scroll experiences using minimal code. 4 | 5 | ## Docs 6 | 7 | [https://scrollex-docs.vercel.app/](https://scrollex-docs.vercel.app/) 8 | 9 | ## Demos 10 | 11 | [https://scrollex-docs.vercel.app/examples](https://scrollex-docs.vercel.app/examples) 12 | 13 | 14 | https://user-images.githubusercontent.com/5760059/167218020-d1ff94a8-a138-418e-9ef0-779c849a7c23.mp4 15 | 16 | -------------------------------------------------------------------------------- /src/hooks/useLatestRef.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** 4 | * React hook to persist any value between renders, 5 | * but keeps it up-to-date if it changes. 6 | * 7 | * @param value the value or function to persist 8 | */ 9 | const useLatestRef = (value: T) => { 10 | const ref = React.useRef(null); 11 | ref.current = value; 12 | return ref as React.MutableRefObject; 13 | }; 14 | 15 | export default useLatestRef; 16 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | body { 16 | /* background: #111; */ 17 | } 18 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # builds 5 | build 6 | dist 7 | 8 | # misc 9 | .cache 10 | .parcel-cache 11 | .DS_Store 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # temp directories created during testing 24 | my-test-library 25 | nala 26 | 27 | # editor 28 | .vscode 29 | .idea 30 | 31 | # example 32 | /example/node_modules 33 | /example/.next 34 | 35 | # Supabase 36 | **/supabase/.branches 37 | **/supabase/.temp 38 | **/supabase/.env -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "target": "es6", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "declarationDir": "dist", 10 | "moduleResolution": "node", 11 | "noUnusedLocals": false, 12 | "noUnusedParameters": false, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "allowSyntheticDefaultImports": true, 20 | "skipLibCheck": true, 21 | "resolveJsonModule": true 22 | }, 23 | "include": ["src"], 24 | "exclude": ["node_modules", "dist"] 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { ObservableRef } from './useObservableRef'; 3 | import useLatestRef from './useLatestRef'; 4 | 5 | const useResizeObserver = ( 6 | ref: ObservableRef, 7 | callback: (entry: ResizeObserverEntry) => void 8 | ) => { 9 | const callbackRef = useLatestRef(callback); 10 | 11 | useEffect(() => { 12 | return ref.subscribe((el) => { 13 | if (el) { 14 | const observer = new ResizeObserver((entries) => { 15 | callbackRef.current(entries[0]); 16 | }); 17 | observer.observe(el); 18 | return () => { 19 | observer.unobserve(el); 20 | observer.disconnect(); 21 | }; 22 | } 23 | return; 24 | }); 25 | }, [callbackRef, ref]); 26 | }; 27 | 28 | export default useResizeObserver; 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Container from './components/Container'; 2 | import Section from './components/Section'; 3 | import Item from './components/Item'; 4 | import { useScrollState } from './hooks/useScrollState'; 5 | import { useScrollValue } from './hooks/useScrollValue'; 6 | export type { ScrollStateFn, ScrollStateContext } from './hooks/useScrollState'; 7 | export type { ScrollValueFn, ScrollValueContext } from './hooks/useScrollValue'; 8 | export type { ScrollContainerProps } from './components/Container'; 9 | export type { ScrollSectionProps } from './components/Section'; 10 | export type { 11 | ScrollItemProps, 12 | Keyframes, 13 | KeyframesFn, 14 | KeyframesObj, 15 | KeyframesContext, 16 | SpringConfigs, 17 | } from './components/Item'; 18 | 19 | export const Scroll = { 20 | Container, 21 | Section, 22 | Item, 23 | }; 24 | export { useScrollState, useScrollValue }; 25 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import json from '@rollup/plugin-json'; 4 | import postcss from 'rollup-plugin-postcss'; 5 | import resolve from '@rollup/plugin-node-resolve'; 6 | import commonjs from '@rollup/plugin-commonjs'; 7 | 8 | const packageJson = require('./package.json'); 9 | 10 | export default { 11 | input: 'src/index.ts', 12 | output: [ 13 | { 14 | file: packageJson.main, 15 | format: 'cjs', 16 | sourcemap: true, 17 | }, 18 | { 19 | file: packageJson.module, 20 | format: 'esm', 21 | sourcemap: true, 22 | }, 23 | ], 24 | watch: { 25 | include: 'src/**', 26 | }, 27 | plugins: [ 28 | peerDepsExternal(), 29 | resolve(), 30 | commonjs(), 31 | json(), 32 | typescript({ useTsconfigDeclarationDir: true }), 33 | postcss(), 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/useConst.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | type InitFn = () => T; 4 | 5 | /** 6 | * Creates a constant value over the lifecycle of a component. 7 | * 8 | * Even if `useMemo` is provided an empty array as its final argument, it doesn't offer 9 | * a guarantee that it won't re-run for performance reasons later on. By using `useConst` 10 | * you can ensure that initializers don't execute twice or more. 11 | */ 12 | function useConst(init: T | InitFn): T { 13 | // Use useRef to store the value because it's the least expensive built-in 14 | // hook that works here. We could also use `useState` but that's more 15 | // expensive internally due to reducer handling which we don't need. 16 | const ref = useRef(null); 17 | 18 | if (ref.current === null) { 19 | ref.current = typeof init === 'function' ? (init as InitFn)() : init; 20 | } 21 | 22 | return ref.current as T; 23 | } 24 | 25 | export default useConst; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 malerba118 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. 22 | -------------------------------------------------------------------------------- /example/src/UseScrollValueExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, useScrollState, useScrollValue } from 'scrollex'; 2 | import { Box, Center, chakra } from '@chakra-ui/react'; 3 | import { motion } from 'framer-motion'; 4 | 5 | const MotionBox = motion(Box); 6 | const ScrollSection = chakra(Scroll.Section); 7 | const ScrollContainer = chakra(Scroll.Container); 8 | 9 | const items = [1, 2, 3, 4]; 10 | 11 | const ScrollyBox = () => { 12 | const x = useScrollValue(({ velocity }) => { 13 | return velocity / 35; 14 | }); 15 | 16 | const rotate = useScrollValue(({ velocity }) => { 17 | const v = velocity / 35; 18 | return v; 19 | }); 20 | 21 | const direction = useScrollState(({ velocity }) => { 22 | if (velocity > 0) { 23 | return 'down'; 24 | } else if (velocity < 0) { 25 | return 'up'; 26 | } 27 | return null; 28 | }); 29 | 30 | return ( 31 | 38 | ); 39 | }; 40 | 41 | export default function UseScrollStateExample() { 42 | return ( 43 | 44 | 45 |
46 | 47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://malerba118.github.io/scrollex", 6 | "dependencies": { 7 | "@chakra-ui/media-query": "^1.2.3", 8 | "@chakra-ui/react": "^1.8.8", 9 | "@emotion/react": "^11.9.0", 10 | "@emotion/styled": "^11.8.1", 11 | "@testing-library/jest-dom": "^5.16.1", 12 | "@testing-library/react": "^12.1.2", 13 | "@testing-library/user-event": "^13.5.0", 14 | "@types/jest": "^27.4.0", 15 | "@types/node": "^16.11.20", 16 | "@types/react": "^18.0.26", 17 | "@types/react-dom": "^18.0.10", 18 | "framer-motion": "file:../node_modules/framer-motion", 19 | "react": "file:../node_modules/react", 20 | "react-dom": "file:../node_modules/react-dom", 21 | "react-fps-stats": "^0.2.3", 22 | "react-router-dom": "^6.3.0", 23 | "react-scripts": "5.0.0", 24 | "scrollex": "file:../", 25 | "typescript": "^4.5.4", 26 | "web-vitals": "^2.1.3" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "predeploy": "npm run build", 34 | "deploy": "gh-pages -d build" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "gh-pages": "^3.2.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/hooks/useObservableRef.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import useConst from './useConst'; 3 | 4 | type Cleanup = (() => void) | void; 5 | type Subscriber = (next: T) => Cleanup; 6 | type Unsubscribe = () => void; 7 | type Subscription = { 8 | subscriber: Subscriber; 9 | cleanup?: Cleanup; 10 | }; 11 | 12 | export class ObservableRef { 13 | // @ts-ignore 14 | private value: T; 15 | private subscriptions: Subscription[] = []; 16 | public current: T; 17 | 18 | constructor(defaultValue: T) { 19 | Object.defineProperty(this, 'current', { 20 | enumerable: true, 21 | get: () => { 22 | return this.value; 23 | }, 24 | set: (nextVal) => { 25 | this.value = nextVal; 26 | this.notify(nextVal); 27 | }, 28 | }); 29 | this.current = defaultValue; 30 | } 31 | 32 | subscribe(subscriber: Subscriber): Unsubscribe { 33 | this.subscriptions.push({ 34 | subscriber, 35 | cleanup: subscriber(this.value), 36 | }); 37 | return () => { 38 | this.subscriptions.forEach((subscription) => { 39 | if (subscription.subscriber === subscriber) { 40 | subscription.cleanup?.(); 41 | } 42 | }); 43 | this.subscriptions = this.subscriptions.filter( 44 | (subscription) => subscription.subscriber !== subscriber 45 | ); 46 | }; 47 | } 48 | 49 | private notify(next: T) { 50 | this.subscriptions.forEach((subscription) => { 51 | subscription.cleanup?.(); 52 | subscription.cleanup = subscription.subscriber(next); 53 | }); 54 | } 55 | } 56 | 57 | const useObservableRef = (defaultValue: T) => { 58 | const ref = useConst(() => new ObservableRef(defaultValue)); 59 | return ref; 60 | }; 61 | 62 | export default useObservableRef; 63 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import FixedPositionExample from './FixedPositionExample'; 5 | import StickyPositionExample from './StickyPositionExample'; 6 | import StyledSectionsExample from './StyledSectionsExample'; 7 | import HorizontalExample from './HorizontalExample'; 8 | import HorizontalTwoExample from './HorizontalTwoExample'; 9 | import reportWebVitals from './reportWebVitals'; 10 | import { ChakraProvider } from '@chakra-ui/react'; 11 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 12 | // @ts-ignore 13 | import FPSStats from 'react-fps-stats'; 14 | import theme from './theme'; 15 | import { useScrollState } from '../../dist'; 16 | import UseScrollStateExample from './UseScrollStateExample'; 17 | import UseScrollValueExample from './UseScrollValueExample'; 18 | 19 | ReactDOM.render( 20 | 21 | 22 | 23 | 24 | 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | 33 | 34 | 35 | , 36 | document.getElementById('root') 37 | ); 38 | 39 | // If you want to start measuring performance in your app, pass a function 40 | // to log results (for example: reportWebVitals(console.log)) 41 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 42 | reportWebVitals(); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scrollex", 3 | "author": "malerba118@gmail.com", 4 | "version": "2.0.1", 5 | "description": "Simple, declarative scroll experiences for react apps.", 6 | "main": "dist/index.js", 7 | "module": "dist/index.esm.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "start": "NODE_ENV=production rollup -c -w", 11 | "build": "NODE_ENV=production rollup -c", 12 | "prepare": "NODE_ENV=production rollup -c" 13 | }, 14 | "license": "MIT", 15 | "peerDependencies": { 16 | "framer-motion": ">=7.0.0", 17 | "react": ">=17.0.0", 18 | "react-dom": ">=17.0.0" 19 | }, 20 | "devDependencies": { 21 | "@rollup/plugin-commonjs": "^22.0.0", 22 | "@rollup/plugin-json": "^4.1.0", 23 | "@rollup/plugin-node-resolve": "^13.2.1", 24 | "@testing-library/jest-dom": "^5.14.1", 25 | "@testing-library/react": "^12.0.0", 26 | "@types/file-saver": "^2.0.2", 27 | "@types/jest": "^27.0.1", 28 | "@types/lodash.throttle": "^4.1.6", 29 | "@types/react": "^17.0.20", 30 | "@types/react-dom": "^17.0.9", 31 | "autoprefixer": "^10.3.4", 32 | "cssnano": "^5.0.8", 33 | "identity-obj-proxy": "^3.0.0", 34 | "jest": "^26.6.3", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "rollup": "^2.56.3", 38 | "rollup-plugin-copy": "^3.4.0", 39 | "rollup-plugin-node-polyfills": "^0.2.1", 40 | "rollup-plugin-peer-deps-external": "^2.2.4", 41 | "rollup-plugin-postcss": "^4.0.2", 42 | "rollup-plugin-typescript2": "^0.30.0", 43 | "ts-jest": "^26.5.6", 44 | "typescript": "^4.4.2" 45 | }, 46 | "dependencies": { 47 | "lodash.throttle": "4.1.1", 48 | "popmotion": "11.0.3" 49 | }, 50 | "prettier": { 51 | "printWidth": 80, 52 | "semi": true, 53 | "singleQuote": true, 54 | "trailingComma": "es5" 55 | }, 56 | "files": [ 57 | "dist" 58 | ], 59 | "release": { 60 | "branches": [ 61 | "main" 62 | ] 63 | }, 64 | "publishConfig": { 65 | "access": "public" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /example/src/FixedPositionExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, Keyframes } from 'scrollex'; 2 | import { Center, chakra, Img } from '@chakra-ui/react'; 3 | 4 | const ScrollItem = chakra(Scroll.Item); 5 | const ScrollSection = chakra(Scroll.Section); 6 | const ScrollContainer = chakra(Scroll.Container); 7 | 8 | const keyframes: Record = { 9 | image: ({ section }) => ({ 10 | [section.topAt('container-bottom')]: { 11 | translateY: 125, 12 | translateX: -250, 13 | opacity: 0, 14 | rotateX: -25, 15 | rotateY: -50, 16 | scale: 0.4, 17 | }, 18 | [section.topAt('container-top')]: { 19 | translateY: 0, 20 | translateX: 0, 21 | opacity: 1, 22 | rotateX: 0, 23 | rotateY: 0, 24 | scale: 1.3, 25 | }, 26 | [section.bottomAt('container-top')]: { 27 | translateY: -125, 28 | translateX: 250, 29 | opacity: 0, 30 | rotateX: 25, 31 | rotateY: 50, 32 | scale: 0.4, 33 | }, 34 | }), 35 | }; 36 | 37 | const images = [ 38 | 'https://picsum.photos/id/237/200/300', 39 | 'https://picsum.photos/id/238/200/300', 40 | 'https://picsum.photos/id/239/200/300', 41 | 'https://picsum.photos/id/212/200/300', 42 | 'https://picsum.photos/id/213/200/300', 43 | 'https://picsum.photos/id/214/200/300', 44 | 'https://picsum.photos/id/215/200/300', 45 | 'https://picsum.photos/id/216/200/300', 46 | 'https://picsum.photos/id/217/200/300', 47 | ]; 48 | 49 | export default function App() { 50 | return ( 51 | 52 | {images.map((image) => { 53 | return ( 54 | 55 |
61 | 62 | 63 | 64 |
65 |
66 | ); 67 | })} 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /example/src/HorizontalTwoExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, Keyframes, SpringConfigs } from 'scrollex'; 2 | import { chakra, Img, Box } from '@chakra-ui/react'; 3 | 4 | const ScrollItem = chakra(Scroll.Item); 5 | const ScrollSection = chakra(Scroll.Section); 6 | const ScrollContainer = chakra(Scroll.Container); 7 | 8 | const keyframes: Record = { 9 | image: ({ section, container }) => ({ 10 | [section.rightAt('container-right') - container.width]: { 11 | translateX: -100, 12 | }, 13 | [section.leftAt('container-left') + container.width]: { 14 | translateX: 100, 15 | }, 16 | }), 17 | }; 18 | 19 | const springs: SpringConfigs = { 20 | translateX: { 21 | mass: 0.1, 22 | stiffness: 200, 23 | damping: 20, 24 | }, 25 | }; 26 | 27 | const images = [ 28 | 'https://picsum.photos/id/334/400/600', 29 | 'https://picsum.photos/id/239/400/600', 30 | 'https://picsum.photos/id/238/400/600', 31 | 'https://picsum.photos/id/212/400/600', 32 | 'https://picsum.photos/id/213/400/600', 33 | 'https://picsum.photos/id/214/400/600', 34 | 'https://picsum.photos/id/215/400/600', 35 | 'https://picsum.photos/id/116/400/600', 36 | 'https://picsum.photos/id/117/400/600', 37 | ]; 38 | 39 | export default function App() { 40 | return ( 41 | 48 | {images.map((image) => { 49 | return ( 50 | 51 | 52 | 53 | 54 | 62 | 63 | 64 | 65 | 66 | ); 67 | })} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 24 | 33 | supabase-comments-extension 34 | 35 | 36 | 37 |
38 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/hooks/useScrollLayoutManager.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | import { Rect } from '../utils'; 3 | 4 | interface UseScrollLayoutManagerParams { 5 | scrollAxis: 'x' | 'y'; 6 | } 7 | 8 | export interface Layout { 9 | sections: Record; 10 | container: Rect; 11 | content: Rect; 12 | maxScrollPosition: number; 13 | } 14 | 15 | export interface LayoutManager { 16 | layout: Layout; 17 | setContainerRect: (rect: Rect) => void; 18 | setContentRect: (rect: Rect) => void; 19 | setSectionRect: (sectionId: string, rect: Rect) => void; 20 | } 21 | 22 | const useScrollLayoutManager = ({ 23 | scrollAxis, 24 | }: UseScrollLayoutManagerParams): LayoutManager => { 25 | let [container, setContainer] = useState({ 26 | x: 0, 27 | y: 0, 28 | width: 0, 29 | height: 0, 30 | }); 31 | let [content, setContent] = useState({ 32 | x: 0, 33 | y: 0, 34 | width: 0, 35 | height: 0, 36 | }); 37 | let [sections, setSections] = useState>({}); 38 | 39 | const setContainerRect = useCallback( 40 | (rect: Rect) => { 41 | setContainer(rect); 42 | }, 43 | [setContainer] 44 | ); 45 | 46 | const setContentRect = useCallback( 47 | (rect: Rect) => { 48 | setContent(rect); 49 | }, 50 | [setContent] 51 | ); 52 | 53 | const setSectionRect = useCallback( 54 | (sectionId: string, rect: Rect) => { 55 | setSections((prev) => ({ 56 | ...prev, 57 | [sectionId]: rect, 58 | })); 59 | }, 60 | [setSections] 61 | ); 62 | 63 | const maxScrollPosition = useMemo(() => { 64 | if (scrollAxis === 'y') { 65 | return content.height - container.height; 66 | } else { 67 | return content.width - container.width; 68 | } 69 | }, [container, content, scrollAxis]); 70 | 71 | return useMemo( 72 | () => ({ 73 | layout: { 74 | sections, 75 | container, 76 | content, 77 | maxScrollPosition, 78 | }, 79 | setContainerRect, 80 | setContentRect, 81 | setSectionRect, 82 | }), 83 | [ 84 | setContainerRect, 85 | setSectionRect, 86 | sections, 87 | container, 88 | content, 89 | maxScrollPosition, 90 | ] 91 | ); 92 | }; 93 | 94 | export default useScrollLayoutManager; 95 | -------------------------------------------------------------------------------- /example/src/UseScrollStateExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, useScrollState } from 'scrollex'; 2 | import { Box, Center, chakra, HStack, Text } from '@chakra-ui/react'; 3 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'; 4 | import { motion } from 'framer-motion'; 5 | import { clamp } from './utils'; 6 | 7 | const MotionBox = motion(Box); 8 | const MotionCenter = motion(Center); 9 | const ScrollItem = chakra(Scroll.Item); 10 | const ScrollSection = chakra(Scroll.Section); 11 | const ScrollContainer = chakra(Scroll.Container); 12 | 13 | const items = [1, 2, 3, 4]; 14 | 15 | const MenuPage = () => { 16 | const selectedIndex = useScrollState(({ section, position, container }) => { 17 | const index = Math.floor( 18 | (position - section.topAt('container-top')) / 19 | ((section.height - container.height) / items.length) 20 | ); 21 | return clamp(index, 0, items.length - 1); 22 | }); 23 | return ( 24 | 32 | 33 | {items.map((item, index) => ( 34 | 45 | {selectedIndex === index && ( 46 | 54 | )} 55 | 56 | {item} 57 | 58 | 59 | ))} 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default function UseScrollStateExample() { 66 | return ( 67 | 68 | {/* */} 73 | 74 | 75 | 76 | 77 | 78 | {/* */} 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /example/src/HorizontalExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, Keyframes } from 'scrollex'; 2 | import { Center, chakra, Heading, Img } from '@chakra-ui/react'; 3 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'; 4 | 5 | const ScrollItem = chakra(Scroll.Item); 6 | const ScrollSection = chakra(Scroll.Section); 7 | const ScrollContainer = chakra(Scroll.Container); 8 | 9 | const keyframes: Record = { 10 | heading: ({ section, maxScrollPosition }) => ({ 11 | [section.leftAt('container-left')]: { 12 | rotateZ: 0, 13 | translateY: 0, 14 | translateX: 0, 15 | scale: 1, 16 | skewX: 10, 17 | }, 18 | [section.rightAt('container-left')]: { 19 | rotateZ: 180, 20 | translateY: section.height, 21 | translateX: section.width, 22 | scale: 4, 23 | skewX: -10, 24 | }, 25 | }), 26 | }; 27 | 28 | export default function App() { 29 | const [mass, setMass] = useState(2); 30 | const ref = useRef(null); 31 | useEffect(() => { 32 | setTimeout(() => { 33 | setMass(0.2); 34 | }, 10000); 35 | }, []); 36 | useLayoutEffect(() => { 37 | // alert(ref.current?.offsetHeight); 38 | }, []); 39 | return ( 40 | <> 41 | 42 | 43 | 44 | Yo 45 | 46 | 47 | 48 | 53 | What's 54 | What's 55 | What's 56 | 57 | test test test test test test test test test test test test 58 | whitespace normal 59 | 60 | 61 | 62 | 63 | 64 | 65 | Up 66 | 67 | 68 | 69 | 70 | 71 | 72 | ? 73 | 74 | 75 | 76 | 77 | 78 | 79 | ? 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type RefCallback = (newValue: T | null) => void; 4 | export type RefObject = React.MutableRefObject; 5 | 6 | export type DefinedReactRef = RefCallback | RefObject; 7 | export type ReactRef = DefinedReactRef | null; 8 | 9 | export function assignRef(ref: ReactRef, value: T | null): ReactRef { 10 | if (typeof ref === 'function') { 11 | ref(value); 12 | } else if (ref) { 13 | ref.current = value; 14 | } 15 | 16 | return ref; 17 | } 18 | 19 | export interface Rect { 20 | x: number; 21 | y: number; 22 | width: number; 23 | height: number; 24 | } 25 | 26 | export const getRect = (el: HTMLElement): Rect => ({ 27 | x: el.offsetLeft, 28 | y: el.offsetTop, 29 | width: el.offsetWidth, 30 | height: el.offsetHeight, 31 | }); 32 | 33 | export class LayoutSection { 34 | private container: Rect; 35 | private x: number; 36 | private y: number; 37 | height: number; 38 | width: number; 39 | 40 | constructor(section: Rect, container: Rect) { 41 | this.x = section.x; 42 | this.y = section.y; 43 | this.width = section.width; 44 | this.height = section.height; 45 | this.container = container; 46 | } 47 | 48 | topAt(position: 'container-top' | 'container-center' | 'container-bottom') { 49 | if (position === 'container-top') { 50 | return this.y; 51 | } else if (position === 'container-center') { 52 | return this.y - this.container.height / 2; 53 | } else { 54 | return this.y - this.container.height; 55 | } 56 | } 57 | 58 | bottomAt( 59 | position: 'container-top' | 'container-center' | 'container-bottom' 60 | ) { 61 | if (position === 'container-top') { 62 | return this.y + this.height; 63 | } else if (position === 'container-center') { 64 | return this.y + this.height - this.container.height / 2; 65 | } else { 66 | return this.y + this.height - this.container.height; 67 | } 68 | } 69 | 70 | leftAt(position: 'container-left' | 'container-center' | 'container-right') { 71 | if (position === 'container-left') { 72 | return this.x; 73 | } else if (position === 'container-center') { 74 | return this.x - this.container.width / 2; 75 | } else { 76 | return this.x - this.container.width; 77 | } 78 | } 79 | 80 | rightAt(position: 'container-left' | 'container-center' | 'container-right') { 81 | if (position === 'container-left') { 82 | return this.x + this.width; 83 | } else if (position === 'container-center') { 84 | return this.x + this.width - this.container.width / 2; 85 | } else { 86 | return this.x + this.width - this.container.width; 87 | } 88 | } 89 | } 90 | 91 | export class LayoutContainer { 92 | private x: number; 93 | private y: number; 94 | width: number; 95 | height: number; 96 | 97 | constructor(container: Rect) { 98 | this.x = container.x; 99 | this.y = container.y; 100 | this.width = container.width; 101 | this.height = container.height; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/hooks/useScroll.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useMemo, 3 | createContext, 4 | useContext, 5 | forwardRef, 6 | useEffect, 7 | useLayoutEffect, 8 | } from 'react'; 9 | import { 10 | HTMLMotionProps, 11 | motion, 12 | motionValue, 13 | MotionValue, 14 | useScroll as useScrollFM, 15 | useSpring, 16 | // useVelocity, 17 | } from 'framer-motion'; 18 | import useConst from './useConst'; 19 | 20 | const ScrollContext = createContext(null); 21 | 22 | interface ScrollProviderProps extends HTMLMotionProps<'div'> {} 23 | 24 | interface ScrollValues { 25 | position: { 26 | x: MotionValue; 27 | y: MotionValue; 28 | }; 29 | velocity: { 30 | x: MotionValue; 31 | y: MotionValue; 32 | }; 33 | } 34 | 35 | /** 36 | * Creates a `MotionValue` that updates when the velocity of the provided `MotionValue` changes. 37 | * 38 | * ```javascript 39 | * const x = useMotionValue(0) 40 | * const xVelocity = useVelocity(x) 41 | * const xAcceleration = useVelocity(xVelocity) 42 | * ``` 43 | * 44 | * @public 45 | */ 46 | export function useVelocity(value: MotionValue): MotionValue { 47 | const velocity = useConst(() => motionValue(value.getVelocity())); 48 | 49 | useEffect(() => { 50 | return value.on('velocityChange', (newVelocity) => { 51 | velocity.set(newVelocity); 52 | }); 53 | }, [value]); 54 | 55 | return velocity; 56 | } 57 | 58 | export const ScrollProvider = forwardRef( 59 | ({ children, style, ...otherProps }: ScrollProviderProps, ref) => { 60 | const { scrollX, scrollY } = useScrollFM({ container: ref as any }); 61 | const rawVelocityX = useVelocity(scrollX); 62 | const rawVelocityY = useVelocity(scrollY); 63 | const velocityX = useSpring(rawVelocityX, { 64 | stiffness: 100, 65 | damping: 10, 66 | mass: 0.05, 67 | }); 68 | const velocityY = useSpring(rawVelocityY, { 69 | stiffness: 100, 70 | damping: 10, 71 | mass: 0.05, 72 | }); 73 | 74 | const scroll = useMemo( 75 | () => ({ 76 | position: { 77 | x: scrollX, 78 | y: scrollY, 79 | }, 80 | velocity: { 81 | x: velocityX, 82 | y: velocityY, 83 | }, 84 | }), 85 | [scrollX, scrollY, velocityX, velocityY] 86 | ); 87 | 88 | return ( 89 | 90 | 91 | 100 | {children} 101 | 102 | 103 | ); 104 | } 105 | ); 106 | 107 | export const useScroll = () => { 108 | const context = useContext(ScrollContext); 109 | if (context === null) { 110 | throw new Error('useScroll must be used within a ScrollProvider'); 111 | } else { 112 | return context; 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /example/src/StyledSectionsExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, Keyframes } from 'scrollex'; 2 | import { Box, chakra, Heading, Img } from '@chakra-ui/react'; 3 | 4 | const ScrollItem = chakra(Scroll.Item); 5 | const ScrollSection = chakra(Scroll.Section); 6 | const ScrollContainer = chakra(Scroll.Container); 7 | 8 | const keyframes: Record = { 9 | heading: ({ section }) => ({ 10 | [section.topAt('container-top')]: { 11 | rotateZ: 0, 12 | translateY: 0, 13 | }, 14 | [section.bottomAt('container-top') - 110]: { 15 | color: 'rgba(255,0,255,1)', 16 | }, 17 | [section.bottomAt('container-top') - 100]: { 18 | rotateZ: 180, 19 | translateY: section.height - 150, 20 | color: 'rgba(255,0,0,1)', 21 | }, 22 | }), 23 | single: ({ section }) => ({ 24 | [section.topAt('container-top')]: { 25 | translateX: 300, 26 | }, 27 | }), 28 | questionMark: ({ section, container, maxScrollPosition }) => ({ 29 | [maxScrollPosition - 100]: { 30 | rotateZ: 0, 31 | translateY: 0, 32 | }, 33 | [maxScrollPosition]: { 34 | rotateZ: 180, 35 | translateY: 1000, 36 | }, 37 | }), 38 | }; 39 | 40 | export default function App() { 41 | return ( 42 | 43 | 49 | 50 | 51 | Yo 52 | 53 | 54 | 55 | 56 | What's 57 | 58 | 59 | 66 | 71 | 72 | Up 73 | 74 | 75 | 76 | 77 | 78 | How's 79 | 80 | 81 | 82 | 83 | 84 | It 85 | 86 | 87 | 88 | 89 | 90 | Going 91 | 92 | 93 | 94 | 95 | 96 | ? 97 | 98 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/hooks/useScrollValue.ts: -------------------------------------------------------------------------------- 1 | import { useMotionValue } from 'framer-motion'; 2 | import { useEffect } from 'react'; 3 | import { useScrollContainer } from '../components/Container'; 4 | import { useSection } from '../components/Section'; 5 | import { LayoutContainer, LayoutSection } from '../utils'; 6 | import useLatestRef from './useLatestRef'; 7 | import { useScroll } from './useScroll'; 8 | import { Layout } from './useScrollLayoutManager'; 9 | 10 | export type ScrollValueContext = { 11 | section: LayoutSection; 12 | container: LayoutContainer; 13 | maxScrollPosition: number; 14 | position: number; 15 | velocity: number; 16 | }; 17 | export type ScrollValueFn = ( 18 | context: ScrollValueContext 19 | ) => T; 20 | 21 | const getScrollValueContext = ( 22 | layout: Layout, 23 | sectionId: string, 24 | position: number, 25 | velocity: number 26 | ): ScrollValueContext => { 27 | const section = new LayoutSection( 28 | layout.sections[sectionId], 29 | layout.container 30 | ); 31 | const container = new LayoutContainer(layout.container); 32 | const maxScrollPosition = layout.maxScrollPosition; 33 | 34 | return { 35 | section, 36 | container, 37 | maxScrollPosition, 38 | position, 39 | velocity, 40 | }; 41 | }; 42 | 43 | export const useScrollValue = ( 44 | callback: ScrollValueFn 45 | ) => { 46 | const value = useMotionValue(undefined); 47 | const container = useScrollContainer(); 48 | const section = useSection(); 49 | const scroll = useScroll(); 50 | 51 | if (section === null) { 52 | throw new Error('useScrollValue can only be used within a Scroll.Section'); 53 | } 54 | 55 | if (container === null) { 56 | throw new Error( 57 | 'useScrollValue can only be used within a Scroll.Container' 58 | ); 59 | } 60 | 61 | const { layoutManager, scrollAxis } = container; 62 | 63 | const maybeUpdateValue = useLatestRef(() => { 64 | if (!section.isReady) { 65 | return; 66 | } 67 | const position = 68 | scrollAxis === 'x' ? scroll.position.x.get() : scroll.position.y.get(); 69 | const velocity = 70 | scrollAxis === 'x' ? scroll.velocity.x.get() : scroll.velocity.y.get(); 71 | const nextValue = callback( 72 | getScrollValueContext( 73 | layoutManager.layout, 74 | section.sectionId, 75 | position, 76 | velocity 77 | ) 78 | ); 79 | value.set(nextValue); 80 | }); 81 | 82 | useEffect(() => { 83 | maybeUpdateValue.current(); 84 | }, [layoutManager.layout, section.sectionId, section.isReady, scrollAxis]); 85 | 86 | useEffect(() => { 87 | if (scrollAxis === 'x') { 88 | return scroll.position.x.onChange(() => { 89 | maybeUpdateValue.current(); 90 | }); 91 | } else { 92 | return scroll.position.y.onChange(() => { 93 | maybeUpdateValue.current(); 94 | }); 95 | } 96 | }, [scrollAxis]); 97 | 98 | useEffect(() => { 99 | if (scrollAxis === 'x') { 100 | return scroll.velocity.x.onChange(() => { 101 | maybeUpdateValue.current(); 102 | }); 103 | } else { 104 | return scroll.velocity.y.onChange(() => { 105 | maybeUpdateValue.current(); 106 | }); 107 | } 108 | }, [scrollAxis]); 109 | 110 | return value; 111 | }; 112 | -------------------------------------------------------------------------------- /src/hooks/useScrollState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useScrollContainer } from '../components/Container'; 3 | import { useSection } from '../components/Section'; 4 | import { LayoutContainer, LayoutSection } from '../utils'; 5 | import useLatestRef from './useLatestRef'; 6 | import { useScroll } from './useScroll'; 7 | import { Layout } from './useScrollLayoutManager'; 8 | 9 | export type ScrollStateContext = { 10 | section: LayoutSection; 11 | container: LayoutContainer; 12 | maxScrollPosition: number; 13 | position: number; 14 | velocity: number; 15 | }; 16 | 17 | export type ScrollStateFn< 18 | T extends number | string | boolean | null | undefined 19 | > = (context: ScrollStateContext) => T; 20 | 21 | const getScrollStateContext = ( 22 | layout: Layout, 23 | sectionId: string, 24 | position: number, 25 | velocity: number 26 | ): ScrollStateContext => { 27 | const section = new LayoutSection( 28 | layout.sections[sectionId], 29 | layout.container 30 | ); 31 | const container = new LayoutContainer(layout.container); 32 | const maxScrollPosition = layout.maxScrollPosition; 33 | 34 | return { 35 | section, 36 | container, 37 | maxScrollPosition, 38 | position, 39 | velocity, 40 | }; 41 | }; 42 | 43 | export const useScrollState = < 44 | T extends number | string | boolean | null | undefined 45 | >( 46 | callback: ScrollStateFn 47 | ) => { 48 | const [state, setState] = useState(); 49 | const container = useScrollContainer(); 50 | const section = useSection(); 51 | const scroll = useScroll(); 52 | 53 | if (section === null) { 54 | throw new Error('useScrollState can only be used within a Scroll.Section'); 55 | } 56 | 57 | if (container === null) { 58 | throw new Error( 59 | 'useScrollState can only be used within a Scroll.Container' 60 | ); 61 | } 62 | 63 | const { layoutManager, scrollAxis } = container; 64 | 65 | const maybeUpdateState = useLatestRef(() => { 66 | if (!section.isReady) { 67 | return; 68 | } 69 | const position = 70 | scrollAxis === 'x' ? scroll.position.x.get() : scroll.position.y.get(); 71 | const velocity = 72 | scrollAxis === 'x' ? scroll.velocity.x.get() : scroll.velocity.y.get(); 73 | 74 | const nextState = callback( 75 | getScrollStateContext( 76 | layoutManager.layout, 77 | section.sectionId, 78 | position, 79 | velocity 80 | ) 81 | ); 82 | setState(nextState); 83 | }); 84 | 85 | useEffect(() => { 86 | maybeUpdateState.current(); 87 | }, [layoutManager.layout, section.sectionId, section.isReady, scrollAxis]); 88 | 89 | useEffect(() => { 90 | if (scrollAxis === 'x') { 91 | return scroll.position.x.onChange(() => { 92 | maybeUpdateState.current(); 93 | }); 94 | } else { 95 | return scroll.position.y.onChange(() => { 96 | maybeUpdateState.current(); 97 | }); 98 | } 99 | }, [scrollAxis]); 100 | 101 | useEffect(() => { 102 | if (scrollAxis === 'x') { 103 | return scroll.velocity.x.onChange(() => { 104 | maybeUpdateState.current(); 105 | }); 106 | } else { 107 | return scroll.velocity.y.onChange(() => { 108 | maybeUpdateState.current(); 109 | }); 110 | } 111 | }, [scrollAxis]); 112 | 113 | return state; 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, forwardRef, useContext, useMemo } from 'react'; 2 | import useResizeObserver from '../hooks/useResizeObserver'; 3 | import useObservableRef from '../hooks/useObservableRef'; 4 | import { assignRef, getRect } from '../utils'; 5 | import { useScrollContainer } from './Container'; 6 | import { HTMLMotionProps, motion } from 'framer-motion'; 7 | //@ts-ignore 8 | import styles from './Section.module.css'; 9 | import { useId } from '../hooks/useId'; 10 | 11 | interface SectionContextApi { 12 | sectionId: string; 13 | isReady: boolean; 14 | } 15 | 16 | const SectionContext = createContext(null); 17 | 18 | export const useSection = () => { 19 | return useContext(SectionContext); 20 | }; 21 | 22 | export interface ScrollSectionProps extends HTMLMotionProps<'div'> {} 23 | 24 | const Section = forwardRef( 25 | ({ children, className, ...otherProps }, forwardedRef) => { 26 | const sectionRef = useObservableRef(null); 27 | const sectionId = useId(); 28 | const container = useScrollContainer(); 29 | const section = useSection(); 30 | 31 | if (section !== null) { 32 | throw new Error( 33 | 'Scroll.Section cannot be nested within another Scroll.Section' 34 | ); 35 | } 36 | 37 | if (container === null) { 38 | throw new Error( 39 | 'Scroll.Section can only be used within a Scroll.Container' 40 | ); 41 | } 42 | 43 | const { layoutManager, scrollAxis } = container; 44 | 45 | useResizeObserver(sectionRef, (entry) => { 46 | layoutManager.setSectionRect( 47 | sectionId, 48 | getRect(entry.target as HTMLElement) 49 | ); 50 | }); 51 | 52 | // Definitely not the best check, but should suffice for determining 53 | // whether the first layout measurement has happened. 54 | const isReady = 55 | !!layoutManager.layout.sections[sectionId] && 56 | layoutManager.layout.container.width !== 0 && 57 | layoutManager.layout.container.height !== 0 && 58 | layoutManager.layout.content.width !== 0 && 59 | layoutManager.layout.content.height !== 0; 60 | 61 | const context = useMemo( 62 | () => ({ 63 | sectionId, 64 | isReady, 65 | }), 66 | [sectionId, isReady] 67 | ); 68 | 69 | // Using classes here to keep specificity low so user can override 70 | const _className = useMemo(() => { 71 | const classes = [styles.relative, className]; 72 | if (scrollAxis === 'x') { 73 | classes.push(styles.heightFull); 74 | } else { 75 | classes.push(styles.widthFull); 76 | } 77 | return classes.join(' '); 78 | }, [scrollAxis, className]); 79 | 80 | return ( 81 | 82 | { 85 | assignRef(forwardedRef, el); 86 | assignRef(sectionRef, el); 87 | }} 88 | className={_className} 89 | style={{ 90 | visibility: isReady ? 'visible' : 'hidden', 91 | ...otherProps.style, 92 | }} 93 | > 94 | {children} 95 | 96 | 97 | ); 98 | } 99 | ); 100 | 101 | export default Section; 102 | -------------------------------------------------------------------------------- /src/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | forwardRef, 4 | useContext, 5 | useLayoutEffect, 6 | useMemo, 7 | } from 'react'; 8 | import useScrollLayoutManager, { 9 | LayoutManager, 10 | } from '../hooks/useScrollLayoutManager'; 11 | import useResizeObserver from '../hooks/useResizeObserver'; 12 | import useObservableRef from '../hooks/useObservableRef'; 13 | import { ScrollProvider } from '../hooks/useScroll'; 14 | import { assignRef, getRect } from '../utils'; 15 | import { HTMLMotionProps } from 'framer-motion'; 16 | 17 | export interface ScrollContainerApi { 18 | layoutManager: LayoutManager; 19 | scrollAxis: 'x' | 'y'; 20 | throttleAmount: number; 21 | } 22 | 23 | const ScrollContainerContext = createContext(null); 24 | 25 | export const useScrollContainer = () => { 26 | return useContext(ScrollContainerContext); 27 | }; 28 | 29 | export interface ScrollContainerProps extends HTMLMotionProps<'div'> { 30 | scrollAxis?: 'x' | 'y'; 31 | throttleAmount?: number; 32 | } 33 | 34 | const Container = forwardRef( 35 | ( 36 | { scrollAxis = 'y', throttleAmount = 90, children, ...otherProps }, 37 | forwardedRef 38 | ) => { 39 | const containerRef = useObservableRef(null); 40 | const contentRef = useObservableRef(null); 41 | const layoutManager = useScrollLayoutManager({ scrollAxis }); 42 | 43 | useResizeObserver(containerRef, (entry) => { 44 | layoutManager.setContainerRect(getRect(entry.target as HTMLElement)); 45 | }); 46 | 47 | useResizeObserver(contentRef, (entry) => { 48 | layoutManager.setContentRect(getRect(entry.target as HTMLElement)); 49 | }); 50 | 51 | useLayoutEffect(() => { 52 | return containerRef.subscribe((el) => { 53 | assignRef(forwardedRef, el); 54 | }); 55 | }, []); 56 | 57 | const scrollContainerApi = useMemo( 58 | () => ({ 59 | scrollAxis, 60 | layoutManager, 61 | throttleAmount, 62 | }), 63 | [scrollAxis, layoutManager, throttleAmount] 64 | ); 65 | 66 | return ( 67 | 68 | 78 |
91 | {children} 92 |
93 |
94 |
95 | ); 96 | } 97 | ); 98 | 99 | export default Container; 100 | -------------------------------------------------------------------------------- /example/src/StickyPositionExample.tsx: -------------------------------------------------------------------------------- 1 | import { Scroll, Keyframes } from 'scrollex'; 2 | import { Box, Center, chakra, Heading, Img } from '@chakra-ui/react'; 3 | 4 | const ScrollItem = chakra(Scroll.Item); 5 | const ScrollSection = chakra(Scroll.Section); 6 | const ScrollContainer = chakra(Scroll.Container); 7 | 8 | const keyframes: Record = { 9 | headerText: ({ section }) => ({ 10 | [section.topAt('container-top')]: { 11 | translateY: 0, 12 | backgroundColor: '#ff00ff', 13 | }, 14 | [section.bottomAt('container-top')]: { 15 | translateY: 200, 16 | backgroundColor: 'rgba(255,0,0,1)', 17 | }, 18 | }), 19 | headerImage: ({ section }) => ({ 20 | [section.topAt('container-top')]: { 21 | translateY: 0, 22 | }, 23 | [section.bottomAt('container-top')]: { 24 | translateY: 125, 25 | }, 26 | }), 27 | galleryImage: ({ section, data }) => ({ 28 | [section.topAt('container-top')]: { 29 | translateZ: data.initialZ, 30 | }, 31 | [section.bottomAt('container-bottom')]: { 32 | translateZ: data.initialZ + 510, 33 | }, 34 | }), 35 | footerText: ({ section }) => ({ 36 | [section.topAt('container-bottom')]: { 37 | translateY: 200, 38 | }, 39 | [section.bottomAt('container-bottom')]: { 40 | translateY: 0, 41 | }, 42 | }), 43 | }; 44 | 45 | const images = [ 46 | { 47 | x: -600, 48 | y: -500, 49 | z: -200, 50 | src: 'https://picsum.photos/id/121/600/600', 51 | }, 52 | { 53 | x: 600, 54 | y: -500, 55 | z: -100, 56 | src: 'https://picsum.photos/id/146/600/600', 57 | }, 58 | { 59 | x: 0, 60 | y: -100, 61 | z: 0, 62 | src: 'https://picsum.photos/id/152/1000/600', 63 | }, 64 | { 65 | x: -450, 66 | y: 300, 67 | z: 100, 68 | src: 'https://picsum.photos/id/235/600/600', 69 | }, 70 | { 71 | x: 400, 72 | y: 250, 73 | z: 200, 74 | src: 'https://picsum.photos/id/311/1000/800', 75 | }, 76 | ]; 77 | 78 | export default function App() { 79 | return ( 80 | 86 | 87 | 88 | 95 | 96 |
97 | 98 | Skrolify 99 | 100 |
101 |
102 | 103 | 110 | {images.map((image) => { 111 | return ( 112 | 123 | 124 | 125 | ); 126 | })} 127 | 128 | 129 | 130 |
131 | 132 | Skrolify 133 | 134 |
135 |
136 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/components/Item.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLMotionProps, motion, MotionValue, useSpring } from 'framer-motion'; 2 | import React, { forwardRef, useEffect, useMemo, useState } from 'react'; 3 | import { useScrollContainer } from './Container'; 4 | import { useSection } from './Section'; 5 | import { useScroll } from '../hooks/useScroll'; 6 | import { Layout } from '../hooks/useScrollLayoutManager'; 7 | import { 8 | keyframes as animation, 9 | SpringOptions, 10 | Animation as PopmotionAnimation, 11 | } from 'popmotion'; 12 | import throttle from 'lodash.throttle'; 13 | import { LayoutSection, LayoutContainer, assignRef } from '../utils'; 14 | 15 | export type StyleObj = { 16 | scale?: number | string; 17 | scaleX?: number | string; 18 | scaleY?: number | string; 19 | scaleZ?: number | string; 20 | translateX?: number | string; 21 | translateY?: number | string; 22 | translateZ?: number | string; 23 | rotateX?: number | string; 24 | rotateY?: number | string; 25 | rotateZ?: number | string; 26 | skewX?: number | string; 27 | skewY?: number | string; 28 | opacity?: number | string; 29 | backgroundColor?: string; 30 | color?: string; 31 | }; 32 | 33 | export type KeyframesContext = { 34 | section: LayoutSection; 35 | container: LayoutContainer; 36 | maxScrollPosition: number; 37 | data?: any; 38 | }; 39 | 40 | export type KeyframesObj = Record; 41 | export type KeyframesFn = (context: KeyframesContext) => KeyframesObj; 42 | export type Keyframes = KeyframesFn | KeyframesObj; 43 | export type KeyframesMap = Map; 44 | 45 | interface Animation { 46 | get(progress: number): any; 47 | } 48 | 49 | const getAnimationForProperty = ( 50 | property: string, 51 | keyframesMap: KeyframesMap 52 | ): Animation | null => { 53 | const values: any[] = []; 54 | const offsets: number[] = []; 55 | for (const [offset, keyframe] of keyframesMap.entries()) { 56 | if (property in keyframe) { 57 | values.push(keyframe[property]); 58 | offsets.push(offset); 59 | } 60 | } 61 | if (!values.length) { 62 | return null; 63 | } 64 | let anim: PopmotionAnimation; 65 | if (values.length === 1) { 66 | // needs at least two values to work as expected 67 | anim = animation({ 68 | to: [values[0], values[0]], 69 | offset: [offsets[0], offsets[0] + 1], 70 | duration: 1, 71 | }); 72 | } else { 73 | anim = animation({ 74 | to: values, 75 | offset: offsets, 76 | duration: 1, 77 | }); 78 | } 79 | return { 80 | get(progress: number) { 81 | return anim.next(progress).value; 82 | }, 83 | }; 84 | }; 85 | 86 | const getKeyframesContext = ( 87 | layout: Layout, 88 | sectionId: string, 89 | data: any 90 | ): KeyframesContext => { 91 | const section = new LayoutSection( 92 | layout.sections[sectionId], 93 | layout.container 94 | ); 95 | const container = new LayoutContainer(layout.container); 96 | const maxScrollPosition = layout.maxScrollPosition; 97 | 98 | return { 99 | section, 100 | container, 101 | maxScrollPosition, 102 | data, 103 | }; 104 | }; 105 | 106 | const processKeyframes = ( 107 | keyframes: Keyframes, 108 | layout: Layout, 109 | sectionId: string, 110 | data: any 111 | ) => { 112 | let keyframesObj: KeyframesObj; 113 | if (typeof keyframes === 'function') { 114 | keyframesObj = keyframes(getKeyframesContext(layout, sectionId, data)); 115 | } else { 116 | keyframesObj = keyframes; 117 | } 118 | const offsets = Object.keys(keyframesObj).sort( 119 | (a, b) => Number(a) - Number(b) 120 | ); 121 | const map = new Map(); 122 | offsets.forEach((offset) => { 123 | map.set(Number(offset) / layout.maxScrollPosition, keyframesObj[offset]); 124 | }); 125 | return map; 126 | }; 127 | 128 | export type SpringConfigs = { 129 | scale?: SpringOptions; 130 | scaleX?: SpringOptions; 131 | scaleY?: SpringOptions; 132 | scaleZ?: SpringOptions; 133 | translateX?: SpringOptions; 134 | translateY?: SpringOptions; 135 | translateZ?: SpringOptions; 136 | rotateX?: SpringOptions; 137 | rotateY?: SpringOptions; 138 | rotateZ?: SpringOptions; 139 | skewX?: SpringOptions; 140 | skewY?: SpringOptions; 141 | opacity?: SpringOptions; 142 | backgroundColor?: SpringOptions; 143 | color?: SpringOptions; 144 | }; 145 | 146 | const DEFAULT_SPRING_CONFIGS: SpringConfigs = { 147 | translateX: { 148 | mass: 0.05, 149 | damping: 7.5, 150 | stiffness: 100, 151 | }, 152 | translateY: { 153 | mass: 0.05, 154 | damping: 7.5, 155 | stiffness: 100, 156 | }, 157 | translateZ: { 158 | mass: 0.05, 159 | damping: 7.5, 160 | stiffness: 100, 161 | }, 162 | scale: { 163 | restDelta: 0.000000001, 164 | restSpeed: 0.000000001, 165 | mass: 0.05, 166 | damping: 20, 167 | }, 168 | scaleX: { 169 | restDelta: 0.000000001, 170 | restSpeed: 0.000000001, 171 | mass: 0.05, 172 | damping: 20, 173 | }, 174 | scaleY: { 175 | restDelta: 0.000000001, 176 | restSpeed: 0.000000001, 177 | mass: 0.05, 178 | damping: 20, 179 | }, 180 | scaleZ: { 181 | restDelta: 0.000000001, 182 | restSpeed: 0.000000001, 183 | mass: 0.05, 184 | damping: 20, 185 | }, 186 | skewX: { 187 | mass: 0.1, 188 | damping: 20, 189 | }, 190 | skewY: { 191 | mass: 0.1, 192 | damping: 20, 193 | }, 194 | rotateX: { 195 | mass: 0.05, 196 | damping: 7.5, 197 | stiffness: 100, 198 | }, 199 | rotateY: { 200 | mass: 0.05, 201 | damping: 7.5, 202 | stiffness: 100, 203 | }, 204 | rotateZ: { 205 | mass: 0.05, 206 | damping: 7.5, 207 | stiffness: 100, 208 | }, 209 | opacity: { 210 | mass: 0.1, 211 | damping: 20, 212 | }, 213 | backgroundColor: { 214 | mass: 0.05, 215 | damping: 7.5, 216 | stiffness: 100, 217 | }, 218 | color: { 219 | mass: 0.05, 220 | damping: 7.5, 221 | stiffness: 100, 222 | }, 223 | }; 224 | 225 | const Springs = ({ keyframes, springConfigs, data, onSprings }: any) => { 226 | const container = useScrollContainer(); 227 | const section = useSection(); 228 | const scroll = useScroll(); 229 | 230 | if (!section) { 231 | throw new Error('Springs can only be used within a Scroll.Section'); 232 | } 233 | 234 | if (container === null) { 235 | throw new Error('Springs can only be used within a Scroll.Container'); 236 | } 237 | 238 | const { layoutManager, scrollAxis, throttleAmount } = container; 239 | 240 | // section dependencies include section and container rects 241 | const animations = useMemo(() => { 242 | const keyframesMap = processKeyframes( 243 | keyframes, 244 | layoutManager.layout, 245 | section.sectionId, 246 | data 247 | ); 248 | return { 249 | translateX: getAnimationForProperty('translateX', keyframesMap), 250 | translateY: getAnimationForProperty('translateY', keyframesMap), 251 | translateZ: getAnimationForProperty('translateZ', keyframesMap), 252 | scale: getAnimationForProperty('scale', keyframesMap), 253 | scaleX: getAnimationForProperty('scaleX', keyframesMap), 254 | scaleY: getAnimationForProperty('scaleY', keyframesMap), 255 | scaleZ: getAnimationForProperty('scaleZ', keyframesMap), 256 | skewX: getAnimationForProperty('skewX', keyframesMap), 257 | skewY: getAnimationForProperty('skewY', keyframesMap), 258 | rotateX: getAnimationForProperty('rotateX', keyframesMap), 259 | rotateY: getAnimationForProperty('rotateY', keyframesMap), 260 | rotateZ: getAnimationForProperty('rotateZ', keyframesMap), 261 | opacity: getAnimationForProperty('opacity', keyframesMap), 262 | backgroundColor: getAnimationForProperty('backgroundColor', keyframesMap), 263 | color: getAnimationForProperty('color', keyframesMap), 264 | }; 265 | }, [layoutManager.layout, keyframes, JSON.stringify(data)]); 266 | 267 | const mergedSpringConfigs = { 268 | ...DEFAULT_SPRING_CONFIGS, 269 | ...springConfigs, 270 | }; 271 | 272 | const springs = { 273 | translateX: useSpring( 274 | animations.translateX?.get(0) ?? '0', 275 | mergedSpringConfigs.translateX 276 | ), 277 | translateY: useSpring( 278 | animations.translateY?.get(0) ?? '0', 279 | mergedSpringConfigs.translateY 280 | ), 281 | translateZ: useSpring( 282 | animations.translateZ?.get(0) ?? '0', 283 | mergedSpringConfigs.translateZ 284 | ), 285 | scale: useSpring( 286 | animations.scale?.get(0) ?? '1', 287 | mergedSpringConfigs.scale 288 | ), 289 | scaleX: useSpring( 290 | animations.scaleX?.get(0) ?? '1', 291 | mergedSpringConfigs.scaleX 292 | ), 293 | scaleY: useSpring( 294 | animations.scaleY?.get(0) ?? '1', 295 | mergedSpringConfigs.scaleY 296 | ), 297 | scaleZ: useSpring( 298 | animations.scaleZ?.get(0) ?? '1', 299 | mergedSpringConfigs.scaleZ 300 | ), 301 | skewX: useSpring( 302 | animations.skewX?.get(0) ?? '0', 303 | mergedSpringConfigs.skewX 304 | ), 305 | skewY: useSpring( 306 | animations.skewY?.get(0) ?? '0', 307 | mergedSpringConfigs.skewY 308 | ), 309 | rotateX: useSpring( 310 | animations.rotateX?.get(0) ?? '0', 311 | mergedSpringConfigs.rotateX 312 | ), 313 | rotateY: useSpring( 314 | animations.rotateY?.get(0) ?? '0', 315 | mergedSpringConfigs.rotateY 316 | ), 317 | rotateZ: useSpring( 318 | animations.rotateZ?.get(0) ?? '0', 319 | mergedSpringConfigs.rotateZ 320 | ), 321 | opacity: useSpring( 322 | animations.opacity?.get(0) ?? '1', 323 | mergedSpringConfigs.opacity 324 | ), 325 | backgroundColor: useSpring( 326 | animations.backgroundColor?.get(0) ?? undefined, 327 | mergedSpringConfigs.backgroundColor 328 | ), 329 | color: useSpring( 330 | animations.color?.get(0) ?? undefined, 331 | mergedSpringConfigs.color 332 | ), 333 | }; 334 | 335 | useEffect(() => { 336 | const updateSprings = throttle( 337 | (scrollOffset: number) => { 338 | const progress = scrollOffset / layoutManager.layout.maxScrollPosition; 339 | springs.translateX.set(animations.translateX?.get(progress) ?? '0'); 340 | springs.translateY.set(animations.translateY?.get(progress) ?? '0'); 341 | springs.translateZ.set(animations.translateZ?.get(progress) ?? '0'); 342 | springs.scale.set(animations.scale?.get(progress) ?? '1'); 343 | springs.scaleX.set(animations.scaleX?.get(progress) ?? '1'); 344 | springs.scaleY.set(animations.scaleY?.get(progress) ?? '1'); 345 | springs.scaleZ.set(animations.scaleZ?.get(progress) ?? '1'); 346 | springs.skewX.set(animations.skewX?.get(progress) ?? '0'); 347 | springs.skewY.set(animations.skewY?.get(progress) ?? '0'); 348 | springs.rotateX.set(animations.rotateX?.get(progress) ?? '0'); 349 | springs.rotateY.set(animations.rotateY?.get(progress) ?? '0'); 350 | springs.rotateZ.set(animations.rotateZ?.get(progress) ?? '0'); 351 | springs.opacity.set(animations.opacity?.get(progress) ?? '1'); 352 | springs.backgroundColor.set( 353 | animations.backgroundColor?.get(progress) ?? undefined 354 | ); 355 | springs.color.set(animations.color?.get(progress) ?? undefined); 356 | }, 357 | throttleAmount, 358 | { leading: true, trailing: true } 359 | ); 360 | if (scrollAxis === 'y') { 361 | updateSprings(scroll.position.y.get()); 362 | return scroll.position.y.onChange(updateSprings); 363 | } else { 364 | updateSprings(scroll.position.x.get()); 365 | return scroll.position.x.onChange(updateSprings); 366 | } 367 | }, [ 368 | scrollAxis, 369 | throttleAmount, 370 | scroll.position.y, 371 | scroll.position.x, 372 | animations, 373 | layoutManager.layout.maxScrollPosition, 374 | ]); 375 | 376 | useEffect(() => { 377 | onSprings(springs); 378 | }, []); 379 | 380 | return null; 381 | }; 382 | 383 | export interface ScrollItemProps extends HTMLMotionProps<'div'> { 384 | keyframes?: Keyframes; 385 | springs?: SpringConfigs; 386 | data?: any; 387 | } 388 | 389 | const Item = forwardRef( 390 | ( 391 | { keyframes = {}, springs: springConfigs = {}, data, ...otherProps }, 392 | forwardedRef 393 | ) => { 394 | const [springs, setSprings] = useState>({}); 395 | const section = useSection(); 396 | 397 | if (!section) { 398 | throw new Error('Scroll.Item can only be used within a Scroll.Section'); 399 | } 400 | 401 | return ( 402 | <> 403 | {section.isReady && ( 404 | 410 | )} 411 | { 414 | assignRef(forwardedRef, el); 415 | }} 416 | style={{ 417 | ...otherProps.style, 418 | translateX: springs.translateX, 419 | translateY: springs.translateY, 420 | translateZ: springs.translateZ, 421 | scale: springs.scale, 422 | scaleX: springs.scaleX, 423 | scaleY: springs.scaleY, 424 | scaleZ: springs.scaleZ, 425 | skewX: springs.skewX, 426 | skewY: springs.skewY, 427 | rotateX: springs.rotateX, 428 | rotateY: springs.rotateY, 429 | rotateZ: springs.rotateZ, 430 | opacity: springs.opacity, 431 | backgroundColor: springs.backgroundColor, 432 | color: springs.color, 433 | }} 434 | /> 435 | 436 | ); 437 | } 438 | ); 439 | 440 | export default Item; 441 | --------------------------------------------------------------------------------