diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index eeeb6ef..bd8596d 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -31,10 +31,10 @@ jobs: - name: Build Storybook run: | - npm ci - npm run build - npm run storybook:build - cp -r ./packages/*/.docs/ ./.docs + npm ci; + npm run build; + npm run storybook:build; + cp -r ./packages/*/.docs/ ./.docs; - name: Create artifact uses: actions/upload-pages-artifact@v1 diff --git a/.gitignore b/.gitignore index f798fe6..d8f5968 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ node_modules/ dist/ .docs/ + +# npmrc contains auth token for private gsap npm registry +.npmrc diff --git a/README.md b/README.md index a6bb99a..e974c4e 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,18 @@ getting started guides. ## 💪 Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would -like to change. +like to change. Please make sure to update tests accordingly. -Please make sure to update tests as appropriate. +Installation requires a greensock license, create a .npmrc file in the root of the repository with +the necessary configuration to install the business package. + +``` +.npmrc + +//npm.greensock.com/:_authToken= +@gsap:registry=https://npm.greensock.com + +``` ## 📝 License diff --git a/package-lock.json b/package-lock.json index d65c18d..9319ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,14 @@ ], "devDependencies": { "@mediamonks/prettier-config": "^1.0.1", + "@storybook/cli": "^7.0.0-beta.19", + "@storybook/react-vite": "^7.0.0-beta.19", + "@storybook/types": "^7.0.0-beta.19", "husky": "^7.0.4", "lerna": "^6.1.0", "lint-staged": "^11.2.6", "npm-run-all": "^4.1.5", - "prettier": "^2.8.1", - "storybook": "^7.0.0-beta.13" + "prettier": "^2.8.1" } }, "node_modules/@ampproject/remapping": { @@ -2378,8 +2380,9 @@ }, "node_modules/@jest/globals": { "version": "29.3.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.3.1.tgz", + "integrity": "sha512-cTicd134vOcwO59OPaB6AmdHQMCtWOe+/DitpTZVxWgMJ+YvXL1HNAmPyiGbSHmF/mXVBkvlm8YYtQhyHPnV6Q==", "dev": true, - "license": "MIT", "dependencies": { "@jest/environment": "^29.3.1", "@jest/expect": "^29.3.1", @@ -16399,8 +16402,11 @@ "peer": true }, "node_modules/gsap": { + "name": "@gsap/business", "version": "3.11.4", - "license": "Standard 'no charge' license: https://greensock.com/standard-license. Club GreenSock members get more: https://greensock.com/licensing/. Why GreenSock doesn't employ an MIT license: https://greensock.com/why-license/" + "resolved": "https://npm.greensock.com/@gsap%2fbusiness/-/business-3.11.4.tgz", + "integrity": "sha512-+rAtCKKYO+EXUSSjQdkk5EEyZEC+FKJGinwTzQCpMfyCZN/n2ogtSH2HAP80S4gRGhCjBJeNxwyHrnUvHNDlyA==", + "license": "This package should only be used by individuals/companies with an active Business Green Club GreenSock membership. See https://greensock.com/club/. Licensing: https://greensock.com/licensing/" }, "node_modules/handlebars": { "version": "4.7.7", @@ -25629,6 +25635,7 @@ "version": "1.0.2", "license": "MIT", "devDependencies": { + "@jest/globals": "^29.3.1", "@mediamonks/eslint-config": "^2.0.6", "@mediamonks/eslint-config-react": "^2.1.11", "@mediamonks/eslint-config-typescript": "^1.0.8", @@ -25653,7 +25660,7 @@ }, "peerDependencies": { "@mediamonks/react-hooks": "^1.0.3", - "gsap": "^3.11.4", + "gsap": "npm:@gsap/business@^3.11.4", "react": ">=17", "react-dom": ">=17" } @@ -27284,6 +27291,8 @@ }, "@jest/globals": { "version": "29.3.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.3.1.tgz", + "integrity": "sha512-cTicd134vOcwO59OPaB6AmdHQMCtWOe+/DitpTZVxWgMJ+YvXL1HNAmPyiGbSHmF/mXVBkvlm8YYtQhyHPnV6Q==", "dev": true, "requires": { "@jest/environment": "^29.3.1", @@ -29821,6 +29830,7 @@ "@mediamonks/react-animation": { "version": "file:packages/react-animation", "requires": { + "@jest/globals": "*", "@mediamonks/eslint-config": "^2.0.6", "@mediamonks/eslint-config-react": "^2.1.11", "@mediamonks/eslint-config-typescript": "^1.0.8", @@ -37769,7 +37779,9 @@ "peer": true }, "gsap": { - "version": "3.11.4" + "version": "npm:@gsap/business@3.11.4", + "resolved": "https://npm.greensock.com/@gsap%2fbusiness/-/business-3.11.4.tgz", + "integrity": "sha512-+rAtCKKYO+EXUSSjQdkk5EEyZEC+FKJGinwTzQCpMfyCZN/n2ogtSH2HAP80S4gRGhCjBJeNxwyHrnUvHNDlyA==" }, "handlebars": { "version": "4.7.7", diff --git a/package.json b/package.json index 2230c65..5233213 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ }, "devDependencies": { "@mediamonks/prettier-config": "^1.0.1", - "@storybook/react-vite": "^7.0.0-beta.19", "@storybook/cli": "^7.0.0-beta.19", + "@storybook/react-vite": "^7.0.0-beta.19", "@storybook/types": "^7.0.0-beta.19", "husky": "^7.0.4", "lerna": "^6.1.0", diff --git a/packages/react-animation/package.json b/packages/react-animation/package.json index 1763e98..76ca6cb 100644 --- a/packages/react-animation/package.json +++ b/packages/react-animation/package.json @@ -39,6 +39,7 @@ "author": "frontend.monks", "license": "MIT", "devDependencies": { + "@jest/globals": "^29.3.1", "@mediamonks/eslint-config": "^2.0.6", "@mediamonks/eslint-config-react": "^2.1.11", "@mediamonks/eslint-config-typescript": "^1.0.8", @@ -47,8 +48,8 @@ "@storybook/addon-interactions": "^7.0.0-beta.19", "@storybook/addon-links": "^7.0.0-beta.19", "@storybook/blocks": "^7.0.0-beta.19", - "@storybook/react-vite": "^7.0.0-beta.19", "@storybook/cli": "^7.0.0-beta.19", + "@storybook/react-vite": "^7.0.0-beta.19", "@storybook/types": "^7.0.0-beta.19", "@swc/cli": "^0.1.57", "@swc/core": "^1.3.24", @@ -63,7 +64,7 @@ }, "peerDependencies": { "@mediamonks/react-hooks": "^1.0.3", - "gsap": "^3.11.4", + "gsap": "npm:@gsap/business@^3.11.4", "react": ">=17", "react-dom": ">=17" }, @@ -76,6 +77,9 @@ ], "parserOptions": { "project": "tsconfig.eslint.json" + }, + "rules": { + "@typescript-eslint/no-redundant-type-constituents": "off" } } } diff --git a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx index 2bb1fac..8a4b04e 100644 --- a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx +++ b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx @@ -1,5 +1,24 @@ -import { Meta } from '@storybook/blocks'; +import { Meta, Canvas, Story, Source } from '@storybook/blocks'; +import { Example } from './SplitTextWrapper.stories'; # SplitTextWrapper + +The SplitTextWrapper creates a SplitText instance that can be retrieved using a ref. The SplitText +is available as soon as the just before the components is finished mounting. A new SplitText +instance is created when the children or variables change. + +## Example + + + + + +## Usage + +The SplitTextWrapper accepts 1 child, this child is rendered to HTML inside the component to make +sure that compnents from the vDOM are not changed on render making them untargetable in the created +SplitText instance. + + diff --git a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx new file mode 100644 index 0000000..dd8d675 --- /dev/null +++ b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx @@ -0,0 +1,50 @@ +/* eslint-disable react/jsx-no-literals */ +import gsap from 'gsap'; +import { useCallback, useRef, type ReactElement } from 'react'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { SplitTextWrapper } from './SplitTextWrapper.js'; + +export default { + title: 'components/SplitTextWrapper', +}; + +export function Example(): ReactElement { + const splitTextRef = useRef(null); + + const animation = useAnimation(() => { + if (!splitTextRef.current) { + return; + } + + return gsap.from(splitTextRef.current.words, { + y: 20, + x: 4, + opacity: 0, + duration: 0.2, + stagger: 0.05, + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> +

+ + <> + Lorem ipsum dolor sit amet consectetur +
adipisicing elit. Tenetur perspiciatis eius ea, ratione, +
illo molestias, quia sapiente modi quo +
molestiae temporibus. + +
+

+ + + + ); +} diff --git a/packages/react-animation/src/animations.ts b/packages/react-animation/src/animations.ts new file mode 100644 index 0000000..77fcd0f --- /dev/null +++ b/packages/react-animation/src/animations.ts @@ -0,0 +1,38 @@ +class AnimationsMap extends Map { + private readonly callbacks = new Set<() => void>(); + + private updateQueued = false; + + public set(key: unknown, value: gsap.core.Animation): this { + const result = super.set(key, value); + + if (this.updateQueued) { + return result; + } + + this.updateQueued = true; + + queueMicrotask(() => { + for (const callback of this.callbacks) { + callback(); + } + + this.updateQueued = false; + }); + + return result; + } + + public listen(callback: () => void): () => void { + this.callbacks.add(callback); + + return () => { + this.callbacks.delete(callback); + }; + } +} + +/** + * Global map of animations that can be accessed by reference + */ +export const animations = new AnimationsMap(); diff --git a/packages/react-animation/src/index.ts b/packages/react-animation/src/index.ts index 2700aa1..1eed5c7 100644 --- a/packages/react-animation/src/index.ts +++ b/packages/react-animation/src/index.ts @@ -1,4 +1,3 @@ export * from './SplitTextWrapper/SplitTextWrapper.js'; export * from './useAnimation/useAnimation.js'; export * from './useExposeAnimation/useExposeAnimation.js'; -export * from './useScrollAnimation/useScrollAnimation.js'; diff --git a/packages/react-animation/src/useAnimation/useAnimation.mdx b/packages/react-animation/src/useAnimation/useAnimation.mdx index e3a114b..01e5497 100644 --- a/packages/react-animation/src/useAnimation/useAnimation.mdx +++ b/packages/react-animation/src/useAnimation/useAnimation.mdx @@ -1,5 +1,213 @@ -import { Meta } from '@storybook/blocks'; +import { Meta, Canvas, Story, Source } from '@storybook/blocks'; +import { Tween, Timeline, OnAction } from './useAnimation.stories'; # useAnimation + +The useAnimation hook is used to created an animation with GSAP, the animation is automatically +killed when the component unmounts. The animation is updated when one of the dependencies changes. + +### Timeline + + + + + +### Tween + + + + + +### On Action + + + + + +## Usage + +An animation is created in the callback function of the useAnimation hook. The callback hook should +return an animation instance. The `useAnimation` hook returns a RefObject, the animation is availble +on mount. + +The callback accepts a `gsap.core.Animation` instance as a return type. You can use tweens +(`gsap.to`, `gsap.from`, `gsap.fromTo`) and `gsap.timelines` to create an animation. + +```tsx +import { useAnimation } from '@mediamonks/react-animation'; +import gsap from 'gsap'; + +function MyComponent(): null { + const animation1 = useAnimation(() => { + return gsap.to({ value: 0 }, { value: 100 }); + }); + + const animation2 = useAnimation(() => { + return gsap.to({ value: 0 }, { value: 100 }); + }); + + return null; +} +``` + +### Timeline + +```tsx +export function Timeline(): ReactElement { + const ref = useRef(null); + + useAnimation(() => { + if (ref.current === null) { + return; + } + + gsap.set(ref.current, { + scaleX: 0, + scaleY: 0, + }); + + return gsap + .timeline() + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 1, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 1, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scale: 1, + delay: 0.5, + ease: Bounce.easeOut, + }); + }, []); + + return ( +
+ ); +} +``` + +### Tween + +```tsx +export function Tween(): ReactElement { + const ref = useRef(null); + + useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + return ( +
+ ); +} +``` + +### On Action + +The `useAnimation` hooks returns the animation instance, this can be used to control the animation. +Make sure to pause the animation if you don't want it to start on mount. + +```tsx +export function OnAction(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + paused: true, + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + const onPlay = useCallback(() => { + if (animation.current?.progress() === 1) { + animation.current.play(0); + return; + } + + animation.current?.play(); + }, [animation]); + + const onPause = useCallback(() => { + animation.current?.pause(); + }, [animation]); + + const onReset = useCallback(() => { + animation.current?.pause(); + animation.current?.progress(0); + }, [animation]); + + return ( + <> +
+ +
+ + + + + + + + ); +} +``` diff --git a/packages/react-animation/src/useAnimation/useAnimation.stories.tsx b/packages/react-animation/src/useAnimation/useAnimation.stories.tsx new file mode 100644 index 0000000..4f5eb28 --- /dev/null +++ b/packages/react-animation/src/useAnimation/useAnimation.stories.tsx @@ -0,0 +1,179 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import gsap, { Bounce } from 'gsap'; +import { useCallback, useRef, type ReactElement } from 'react'; +import { useAnimation } from './useAnimation.js'; + +export default { + title: 'hooks/useAnimation', +}; + +export function Timeline(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + gsap.set(ref.current, { + scaleX: 0, + scaleY: 0, + }); + + return gsap + .timeline() + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 1, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 1, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scale: 1, + delay: 0.5, + ease: Bounce.easeOut, + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> +
+ +
+
+ + + + ); +} + +export function Tween(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> +
+ +
+
+ + + + ); +} + +export function OnAction(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + paused: true, + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + const onPlay = useCallback(() => { + if (animation.current?.progress() === 1) { + animation.current.play(0); + return; + } + + animation.current?.play(); + }, [animation]); + + const onPause = useCallback(() => { + animation.current?.pause(); + }, [animation]); + + const onReset = useCallback(() => { + animation.current?.pause(); + animation.current?.progress(0); + }, [animation]); + + return ( + <> +
+ +
+ + + + + + + + ); +} diff --git a/packages/react-animation/src/useAnimation/useAnimation.test.ts b/packages/react-animation/src/useAnimation/useAnimation.test.ts index e7922c5..9df078e 100644 --- a/packages/react-animation/src/useAnimation/useAnimation.test.ts +++ b/packages/react-animation/src/useAnimation/useAnimation.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react'; import { gsap } from 'gsap'; +import { type RefObject } from 'react'; import { useAnimation } from './useAnimation.js'; describe('useAnimation', () => { @@ -10,7 +11,7 @@ describe('useAnimation', () => { it('should return animation and update it when dependencies change', () => { const ref = { value: 0 }; - const hook = renderHook( + const hook = renderHook, { value: number }>( ({ value = 1 }) => useAnimation(() => gsap.to(ref, { value }), [value]), { initialProps: { @@ -19,11 +20,11 @@ describe('useAnimation', () => { }, ); - hook.result.current?.progress(1); + hook.result.current.current?.progress(1); expect(ref.value).toBe(1); hook.rerender({ value: 2 }); - hook.result.current?.progress(1); + hook.result.current.current?.progress(1); expect(ref.value).toBe(2); }); }); diff --git a/packages/react-animation/src/useAnimation/useAnimation.ts b/packages/react-animation/src/useAnimation/useAnimation.ts index 19a082d..02fa161 100644 --- a/packages/react-animation/src/useAnimation/useAnimation.ts +++ b/packages/react-animation/src/useAnimation/useAnimation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { type RefObject, useCallback, useEffect, useRef } from 'react'; /** * Create gsap animation via a callback, animation is killed when component is @@ -7,19 +7,18 @@ import { useCallback, useEffect, useState } from 'react'; export function useAnimation( callback: () => T | undefined, dependencies: ReadonlyArray, -): T | undefined { - const [animation, setAnimation] = useState(); +): RefObject { + const animation = useRef(); // eslint-disable-next-line react-hooks/exhaustive-deps const _callback = useCallback(callback, dependencies); useEffect(() => { const _animation = _callback(); - - setAnimation(_animation); + animation.current = _animation; return () => { - _animation?.revert(); + _animation?.kill(); }; }, [_callback]); diff --git a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.mdx b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.mdx index 4eeaf8e..9bfe2a6 100644 --- a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.mdx +++ b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.mdx @@ -3,3 +3,37 @@ import { Meta } from '@storybook/blocks'; # useExposeAnimation + +The `useExposeAnimation` hook is used to expose an animation in the global animation Map for a given +reference (not necessarily an HTMLElement). + +```tsx +const Component = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation(() => gsap.from(...), []); + useExposeAnimation(animation, ref); + + return
; +}); +``` + +# useExposedAnimation + +The `useExposedAnimation` hook is used to get an animation that is exposed in the global animation +Map for a given reference (not necessarily an HTMLElement). The hook will trigger a rerender when +the animation for the reference changes. + +> Note: The return value will most likely be empty during the first render pass + +```tsx +function MyComponent(): ReactElement { + const ref = useRef(null); + const animation = useExposedAnimation(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimation', animation); + + return ; +} +``` + +# getAnimation diff --git a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.stories.tsx b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.stories.tsx new file mode 100644 index 0000000..ff888fd --- /dev/null +++ b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.stories.tsx @@ -0,0 +1,51 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import { ensuredForwardRef } from '@mediamonks/react-hooks'; +import gsap from 'gsap'; +import { useEffect, useRef, type ReactElement } from 'react'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { getAnimation, useExposeAnimation, useExposedAnimation } from './useExposeAnimation.js'; + +export default { + title: 'hooks/useExposeAnimation', +}; + +const Child = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation( + () => + gsap.from( + { + value: 0, + }, + { + value: 1, + }, + ), + [], + ); + + useExposeAnimation(animation, ref); + + return
Check the console to see the result
; +}); + +export function UseExposedAnimation(): ReactElement { + const ref = useRef(null); + const animation = useExposedAnimation(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimation', animation); + + return ; +} + +export function GetAnimation(): ReactElement { + const ref = useRef(null); + + useEffect(() => { + const a = getAnimation(ref.current); + // eslint-disable-next-line no-console + console.log('getAnimation', a); + }, [ref]); + + return ; +} diff --git a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.test.ts b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.test.ts index b3de1d1..217c4a7 100644 --- a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.test.ts +++ b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.test.ts @@ -1,85 +1,89 @@ +import { jest } from '@jest/globals'; import { renderHook } from '@testing-library/react'; import { gsap } from 'gsap'; +import { useRef } from 'react'; import { useAnimation } from '../useAnimation/useAnimation.js'; import { getAnimation, useExposeAnimation, useExposedAnimation } from './useExposeAnimation.js'; describe('useExposeAnimation', () => { it('should not crash', () => { - const ref = Symbol('reference'); + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); - renderHook(() => { - useExposeAnimation(undefined, ref); + useExposeAnimation(useRef(), ref); + + return { + ref, + }; }); - expect(getAnimation(ref)).toBeUndefined(); + expect(getAnimation(hook.result.current.ref.current)).toBeUndefined(); }); it('should return animation when animation is exposed for reference', () => { - const ref = Symbol('reference'); + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); - renderHook(() => { - const timeline = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); - useExposeAnimation(timeline, ref); + return { + ref, + animation, + }; }); - expect(getAnimation(ref)).not.toBeUndefined(); + expect(getAnimation(hook.result.current.ref.current)).not.toBeUndefined(); }); - it('should return undefined when animation is cleared', () => { - const ref = Symbol('reference'); + it('should return undefined when unmounted', () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); - renderHook(() => { - const timeline = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); - useExposeAnimation(timeline, ref); + return { + ref, + animation, + }; }); - renderHook(() => { - useExposeAnimation(undefined, ref); - }); + hook.unmount(); - expect(getAnimation(ref)).not.toBeUndefined(); + expect(getAnimation(hook.result.current.ref.current)).toBeUndefined(); }); }); describe('useExposedAnimation', () => { - it('should return undefined', () => { - const ref = Symbol('reference'); - - const hook = renderHook(() => useExposedAnimation(ref)); - - expect(hook.result.current).toBeUndefined(); + beforeAll(() => { + jest.useFakeTimers(); }); - it('should return updated animation', () => { - const ref = Symbol('reference'); - - const parent = renderHook(() => useExposedAnimation(ref)); + afterAll(() => { + jest.useRealTimers(); + }); - // Child - const child = renderHook( - ({ value }) => { - const timeline = useAnimation(() => gsap.to({ value: 0 }, { value }), [value]); + it('should return undefined', () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); - useExposeAnimation(timeline, ref); + return useExposedAnimation(ref); + }); - return timeline; - }, - { - initialProps: { - value: 1, - }, - }, - ); + expect(hook.result.current).toBeUndefined(); + }); - const firstTimeline = child.result.current; + it('should not return undefined', async () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); - expect(parent.result.current).toEqual(firstTimeline); + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); - child.rerender({ value: 2 }); - parent.rerender(); + return useExposedAnimation(ref); + }); - expect(parent.result.current).not.toEqual(firstTimeline); + expect(hook.result.current).toBeUndefined(); }); }); diff --git a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.ts b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.ts index e2beba0..eea7ac5 100644 --- a/packages/react-animation/src/useExposeAnimation/useExposeAnimation.ts +++ b/packages/react-animation/src/useExposeAnimation/useExposeAnimation.ts @@ -1,34 +1,8 @@ -import { useEffect, useState } from 'react'; - -class AnimationsMap extends Map { - private readonly callbacks = new Set<() => void>(); - - public set(key: unknown, value: gsap.core.Animation): this { - const result = super.set(key, value); - - for (const callback of this.callbacks) { - callback(); - } - - return result; - } - - public listen(callback: () => void): () => void { - this.callbacks.add(callback); - - return () => { - this.callbacks.delete(callback); - }; - } -} +import { useEffect, useState, type RefObject } from 'react'; +import { animations } from '../animations.js'; /** - * Global map of animations that can be accessed by reference - */ -export const animations = new AnimationsMap(); - -/** - * Tries to get animation from global animations map using given reference + * Tries to get animation from global animations map for given reference */ export function getAnimation(reference: unknown): gsap.core.Animation | undefined { return animations.get(reference); @@ -38,16 +12,19 @@ export function getAnimation(reference: unknown): gsap.core.Animation | undefine * Hook to store animation using a reference in global animations map */ export function useExposeAnimation( - animation: gsap.core.Animation | undefined, - reference: unknown, + animation: RefObject, + reference: RefObject, ): void { useEffect(() => { - if (animation) { - animations.set(reference, animation); + const _reference = reference.current; + const _animation = animation.current; + + if (_animation) { + animations.set(_reference, _animation); } return () => { - animations.delete(reference); + animations.delete(_reference); }; }, [animation, reference]); } @@ -55,13 +32,15 @@ export function useExposeAnimation( /** * Hook to get animation from global animations map using given reference */ -export function useExposedAnimation(ref: unknown): gsap.core.Animation | undefined { - const [animation, setAnimation] = useState(animations.get(ref)); +export function useExposedAnimation( + ref: RefObject, +): T | undefined { + const [animation, setAnimation] = useState(); useEffect( () => animations.listen(() => { - setAnimation(animations.get(ref)); + setAnimation(animations.get(ref.current)); }), [ref], ); diff --git a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx index 996b339..ac35fa7 100644 --- a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx +++ b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx @@ -1,5 +1,68 @@ -import { Meta } from '@storybook/blocks'; +import { Meta, Canvas, Story, Source } from '@storybook/blocks'; +import { Example } from './useScrollAnimation.stories'; # useScrollAnimation + +The `useScrollAnimation` hook is used to created an animation with GSAP that uses ScrollTrigger. The +animation is killed when the component unmounts, the animation is updated when one of the +dependencies change. ScrollTrigger is refreshed when an animation instance created with +`useScrollTrigger` is updated. + +## Usage + +The function signature for `useScrollAnimation` is exactly the same as `useAnimation`. + +An animation is created in the callback function of the useAnimation hook. The callback hook should +return an animation instance. The `useScrollAnimation` hook returns a RefObject with the animation +instance, the animation instance is availble on mount. + +The callback accepts a `gsap.core.Animation` instance as a return type. You can use tweens +(`gsap.to`, `gsap.from`, `gsap.fromTo`) and `gsap.timelines` to create an animation. + +```tsx +function ScrollAnimation(): ReactElement { + const ref = useRef(null); + + useScrollAnimation( + () => + gsap.to(ref.current, { + scale: 0.5, + ease: 'power3.out', + scrollTrigger: { + pin: ref.current, + scrub: true, + markers: true, + end: '+=150%', + }, + }), + [], + ); + + return ( +
+
+
+ ); +} +``` + +> Note: do not scrub a React component's root. GSAP modifies the DOM, React won't know what element +> to unmount when the component with a pinned animation must removed from the vDOM. + +## Example + +Keep scrolling to see the animation. + + + + diff --git a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.stories.tsx b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.stories.tsx new file mode 100644 index 0000000..b1820bc --- /dev/null +++ b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.stories.tsx @@ -0,0 +1,44 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import gsap from 'gsap'; +import { useRef, type ReactElement } from 'react'; +import { useScrollAnimation } from './useScrollAnimation.js'; + +export default { + title: 'hooks/useScrollAnimation', + parameters: { + layout: 'fullscreen', + }, +}; + +export function Example(): ReactElement { + const ref = useRef(null); + + useScrollAnimation( + () => + gsap.to(ref.current, { + scale: 0.5, + ease: 'power3.out', + scrollTrigger: { + pin: ref.current, + scrub: true, + markers: true, + end: '+=100%', + }, + }), + [], + ); + + return ( +
+
+
+ ); +} diff --git a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.ts b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.ts index 4c9faa2..e513e5d 100644 --- a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.ts +++ b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.ts @@ -1,19 +1,36 @@ import gsap from 'gsap'; import ScrollTrigger from 'gsap/ScrollTrigger'; -import { useEffect } from 'react'; +import { type RefObject, useEffect, useRef } from 'react'; +import { animations } from '../animations.js'; +import { useExposeAnimation } from '../index.js'; import { useAnimation } from '../useAnimation/useAnimation.js'; gsap.registerPlugin(ScrollTrigger); +/** + * Hook to create animation that make use of ScrollTrigger, ScrollTrigger is refreshed + * when the global animations map is updated. + * + * Note: do not scrub a React component's root because React won't know what element to + * unmount when the component with a pinned animation is must removed from the vDOM. + */ export function useScrollAnimation( callback: () => T | undefined, dependencies: ReadonlyArray, -): T | undefined { +): RefObject { const animation = useAnimation(callback, dependencies); - useEffect(() => { - ScrollTrigger.refresh(); - }, [animation]); + // Expose animation so that we can leverage the listener from the global animations map + const ref = useRef(Symbol('useScrollAnimation')); + useExposeAnimation(animation, ref); + + useEffect( + () => + animations.listen(() => { + ScrollTrigger.refresh(); + }), + [], + ); return animation; } diff --git a/packages/react-animation/tsconfig.build.json b/packages/react-animation/tsconfig.build.json index 4fd6ecf..3e6ba57 100644 --- a/packages/react-animation/tsconfig.build.json +++ b/packages/react-animation/tsconfig.build.json @@ -4,5 +4,11 @@ "declaration": true, "outDir": "./dist" }, - "exclude": ["**/*.test.ts", "**/*.test.tsx", "jest.config.ts"] + "exclude": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.tsx", + "**/*.stories.ts", + "jest.config.ts" + ] } diff --git a/tsconfig.json b/tsconfig.json index 2781639..f84c151 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ESNext", - "module": "ESNext", + "module": "NodeNext", "strict": true } }