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