diff --git a/README.md b/README.md index 10966ad..1121f5e 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,20 @@ [![codecov](https://codecov.io/gh/innrvoice/react-atom-trigger/branch/master/graph/badge.svg)](https://codecov.io/gh/innrvoice/react-atom-trigger) [![bundle size](https://codecov.io/github/innrvoice/react-atom-trigger/branch/master/graph/bundle/react-atom-trigger-esm/badge.svg)](https://app.codecov.io/github/innrvoice/react-atom-trigger/bundles/master/react-atom-trigger-esm) -`react-atom-trigger` helps with the usual "run some code when this thing enters or leaves view" problem. -It is a lightweight React alternative to `react-waypoint`, written in TypeScript. +`react-atom-trigger` helps with the usual "run some code when this thing enters or leaves view" problem in React. + +It is designed for scroll-triggered UI and viewport-based interactions, focusing on predictable enter/leave behavior in real layouts where scroll, resize and layout shifts all affect visibility. + +It is a lightweight React alternative to `react-waypoint`. + +It can also be used as a higher-level alternative to React Intersection Observer based solutions when you need more predictable scroll-trigger behavior. + +## Typical use cases + +- scroll-driven UI (sticky headers, scene transitions) +- triggering animations with precise timing +- layouts with dynamic content or frequent reflows +- containers with custom scroll roots ## Breaking changes @@ -50,7 +62,7 @@ The public React compatibility contract for `v2` is the published peer range: Re `react-atom-trigger` uses a mixed approach. - Geometry is the real source of truth for `enter` and `leave`. -- `IntersectionObserver` is only there to wake things up when the browser notices a nearby layout shift. +- `IntersectionObserver` is only there to wake things up when the browser notices a nearby layout shift. You can think of it as: IntersectionObserver wakes things up, geometry decides what actually happened. - `rootMargin` logic is handled by the library itself, so it stays consistent and does not depend on native observer quirks. In practice this means `AtomTrigger` reacts to: @@ -61,7 +73,28 @@ In practice this means `AtomTrigger` reacts to: - sentinel resize - nearby layout shifts that move the observed element even if no scroll event happened -This is the main reason `v2` can support custom margin-aware behavior and still react to browser-driven layout changes. +This allows it to support margin-aware behavior while still reacting to browser-driven changes. + +## When to use vs react-intersection-observer + +`react-intersection-observer` is a lightweight React wrapper around the browser’s IntersectionObserver API. + +It is a great fit when: + +- you only need to know if something is visible +- async observer timing is acceptable +- you want a simple hook like `useInView` + +However, IntersectionObserver is designed as an asynchronous visibility signal managed by the browser. It does not provide exact geometry-based control over enter/leave transitions. + +In fast scroll or layout-heavy UIs, this can lead to missed intermediate states or non-intuitive enter/leave timing. + +Use `react-atom-trigger` when: + +- you need predictable enter/leave behavior +- layout shifts should be handled consistently +- margins and thresholds must behave the same across cases +- visibility should be derived from actual element geometry rather than observer callbacks ## Quick start diff --git a/package.json b/package.json index ff3f434..dc9ebaa 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,22 @@ { "name": "react-atom-trigger", "version": "2.1.0", - "description": "Geometry-based scroll trigger for React with precise enter/leave control. A modern alternative to react-waypoint.", + "description": "Geometry-based scroll trigger for React with predictable enter/leave behavior. A modern alternative to react-waypoint and React Intersection Observer solutions.", "keywords": [ - "intersection", - "observer", - "on-scroll", + "element visibility", + "in view", + "intersection observer", "react", + "react intersection observer", + "react waypoint alternative", "scroll", - "scroll-into-view", - "v2" + "scroll animation", + "scroll detection", + "scroll trigger", + "scroll-trigger", + "viewport", + "visibility", + "waypoint" ], "homepage": "https://atomtrigger.dev", "bugs": { diff --git a/src/AtomTrigger.core.test.tsx b/src/AtomTrigger.core.test.tsx index c1f9c9e..3a8692c 100644 --- a/src/AtomTrigger.core.test.tsx +++ b/src/AtomTrigger.core.test.tsx @@ -248,7 +248,7 @@ describe('AtomTrigger threshold behavior', () => { expect(onEnter).toHaveBeenCalledTimes(2); }); - it('warns and uses the first numeric threshold entry from untyped array input', () => { + it('warns and falls back to zero for untyped array threshold input', () => { setNodeEnv('development'); const onEnter = vi.fn(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -256,12 +256,9 @@ describe('AtomTrigger threshold behavior', () => { scrollElement(root, 0); scrollElement(root, 65); - expect(onEnter).toHaveBeenCalledTimes(0); - - scrollElement(root, 68); expect(onEnter).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith( - '[react-atom-trigger] `threshold` expects a single number in v2. Using the first finite numeric entry.', + '[react-atom-trigger] `threshold` must be a finite number between 0 and 1. Falling back to 0.', ); }); diff --git a/src/AtomTrigger.geometry.test.ts b/src/AtomTrigger.geometry.test.ts index 3924011..3e76167 100644 --- a/src/AtomTrigger.geometry.test.ts +++ b/src/AtomTrigger.geometry.test.ts @@ -147,16 +147,6 @@ describe('AtomTrigger geometry helpers', () => { }); describe('normalizeThreshold', () => { - it('uses the first finite entry from array input', () => { - process.env.NODE_ENV = 'development'; - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - expect(normalizeThreshold([Number.NaN, 0.25])).toBe(0.25); - expect(warn).toHaveBeenCalledWith( - '[react-atom-trigger] `threshold` expects a single number in v2. Using the first finite numeric entry.', - ); - }); - it('falls back to 0 for nullish values', () => { expect(normalizeThreshold(null)).toBe(0); expect(normalizeThreshold(undefined)).toBe(0); @@ -167,6 +157,7 @@ describe('AtomTrigger geometry helpers', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); expect(normalizeThreshold('nope')).toBe(0); + expect(normalizeThreshold([Number.NaN, 0.25])).toBe(0); expect(warn).toHaveBeenCalledWith( '[react-atom-trigger] `threshold` must be a finite number between 0 and 1. Falling back to 0.', ); @@ -187,9 +178,8 @@ describe('AtomTrigger geometry helpers', () => { process.env.NODE_ENV = 'production'; const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(normalizeThreshold([Number.NaN, 0.25])).toBe(0.25); - expect(normalizeThreshold([Number.NaN])).toBe(0); expect(normalizeThreshold('nope')).toBe(0); + expect(normalizeThreshold([Number.NaN, 0.25])).toBe(0); expect(normalizeThreshold(1.5)).toBe(1); expect(warn).not.toHaveBeenCalled(); }); diff --git a/src/AtomTrigger.geometry.ts b/src/AtomTrigger.geometry.ts index 66be236..822bb0f 100644 --- a/src/AtomTrigger.geometry.ts +++ b/src/AtomTrigger.geometry.ts @@ -160,18 +160,6 @@ function clampThreshold(value: number): number { } export function normalizeThreshold(threshold: unknown): number { - if (Array.isArray(threshold)) { - if (process.env.NODE_ENV === 'development') { - warnOnce( - '[react-atom-trigger] `threshold` expects a single number in v2. Using the first finite numeric entry.', - ); - } - const firstNumeric = threshold.find( - (value): value is number => typeof value === 'number' && Number.isFinite(value), - ); - return clampThreshold(firstNumeric ?? 0); - } - if (threshold === null || threshold === undefined) { return 0; } diff --git a/src/stories/components/AnimationDemo/AnimationDemo.component.test.tsx b/src/stories/components/AnimationDemo/AnimationDemo.component.test.tsx index 41efc20..7de87d9 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.component.test.tsx +++ b/src/stories/components/AnimationDemo/AnimationDemo.component.test.tsx @@ -5,6 +5,12 @@ import type { AtomTriggerEvent, AtomTriggerProps } from '../../../index'; const atomTriggerProps: AtomTriggerProps[] = []; +type MockAircraftProps = { + mode: string; + direction: string | null; + onFlightComplete?: () => void; +}; + vi.mock('../../../index', async () => { const actual = await vi.importActual('../../../index'); @@ -21,11 +27,25 @@ vi.mock('../../../index', async () => { }); vi.mock('./Plane', () => ({ - Plane: () =>
, + Plane: ({ mode, direction, onFlightComplete }: MockAircraftProps) => ( +
+ ), })); vi.mock('./Heli', () => ({ - Helicopter: () =>
, + Helicopter: ({ mode, direction, onFlightComplete }: MockAircraftProps) => ( +
+ ), })); vi.mock('./Scene', () => ({ @@ -145,6 +165,38 @@ describe('AnimationDemo component behavior', () => { expect(screen.getByTestId('animation-demo-last-event').textContent).toContain('moving down'); }); + it('keeps in-flight aircraft mounted and retints them when the scene changes again', () => { + render(); + + const middleTriggerOnEnter = atomTriggerProps[1]?.onEnter; + const bottomTriggerOnEnter = atomTriggerProps[2]?.onEnter; + + act(() => { + middleTriggerOnEnter?.(createEvent('up')); + }); + + expect(screen.getAllByTestId('mock-plane')).toHaveLength(1); + expect(screen.getAllByTestId('mock-plane')[0]?.getAttribute('data-mode')).toBe('sunset'); + expect(screen.getAllByTestId('mock-helicopter')).toHaveLength(1); + expect(screen.getAllByTestId('mock-helicopter')[0]?.getAttribute('data-mode')).toBe('sunset'); + + act(() => { + bottomTriggerOnEnter?.(createEvent('up')); + }); + + const planes = screen.getAllByTestId('mock-plane'); + const helicopters = screen.getAllByTestId('mock-helicopter'); + expect(planes).toHaveLength(2); + expect(helicopters).toHaveLength(1); + expect(planes[0]?.getAttribute('data-mode')).toBe('night'); + expect(planes[1]?.getAttribute('data-mode')).toBe('night'); + expect(helicopters[0]?.getAttribute('data-mode')).toBe('night'); + + fireEvent.transitionEnd(planes[0]); + + expect(screen.getAllByTestId('mock-plane')).toHaveLength(1); + }); + it('resets the scroll position and returns to the configured initial mode', () => { render(); const root = setupScrollGeometry(); diff --git a/src/stories/components/AnimationDemo/AnimationDemo.config.ts b/src/stories/components/AnimationDemo/AnimationDemo.config.ts index f0354d2..f2ef1bd 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.config.ts +++ b/src/stories/components/AnimationDemo/AnimationDemo.config.ts @@ -17,7 +17,10 @@ export type JumpDefinition = { direction: AnimationTransitionDirection; }; -export const defaultTransitionMap = { +export const defaultTransitionMap: Record< + AnimationTriggerId, + Partial> +> = { top: { up: { nextMode: 'day', @@ -40,10 +43,7 @@ export const defaultTransitionMap = { aircraft: 'plane', }, }, -} satisfies Record< - AnimationTriggerId, - Partial> ->; +}; export const triggerDefinitions: readonly TriggerDefinition[] = [ { diff --git a/src/stories/components/AnimationDemo/AnimationDemo.module.css b/src/stories/components/AnimationDemo/AnimationDemo.module.css index c245797..69be5f6 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.module.css +++ b/src/stories/components/AnimationDemo/AnimationDemo.module.css @@ -299,6 +299,12 @@ will-change: transform, filter, opacity; } +.vehicleImage { + display: block; + width: 100%; + height: auto; +} + .vehicleIdle { opacity: 0; } diff --git a/src/stories/components/AnimationDemo/AnimationDemo.tsx b/src/stories/components/AnimationDemo/AnimationDemo.tsx index d9c22f0..9755dd1 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.tsx +++ b/src/stories/components/AnimationDemo/AnimationDemo.tsx @@ -16,6 +16,7 @@ import { } from './AnimationDemo.state'; import { createJumpEvent, getTargetScrollTop, scrollRootToPosition } from './AnimationDemo.utils'; import type { + AircraftAnimation, AnimationMode, AnimationTransition, AnimationTransitionDirection, @@ -30,6 +31,26 @@ export type AnimationDemoProps = { onModeChange?: (mode: AnimationMode, event: AtomTriggerEvent) => void; }; +type FlightAircraft = Extract; + +type ActiveFlight = { + id: number; + aircraft: FlightAircraft; + direction: AnimationTransitionDirection; +}; + +function getFlightAircraft(activeAircraft: AircraftAnimation): FlightAircraft[] { + if (activeAircraft === 'both') { + return ['plane', 'helicopter']; + } + + if (activeAircraft === 'plane' || activeAircraft === 'helicopter') { + return [activeAircraft]; + } + + return []; +} + export function AnimationDemo({ initialMode = 'day', viewportHeight = 720, @@ -45,6 +66,9 @@ export function AnimationDemo({ }); const [showTriggers, setShowTriggers] = React.useState(defaultShowTriggers); const [state, dispatch] = React.useReducer(animationDemoReducer, initialMode, createInitialState); + const [activeFlights, setActiveFlights] = React.useState([]); + const nextFlightIdRef = React.useRef(0); + const queuedTransitionCountRef = React.useRef(0); const previousModeRef = React.useRef(state.mode); const pendingJumpRef = React.useRef<{ triggerId: AnimationTriggerId; @@ -68,6 +92,37 @@ export function AnimationDemo({ previousModeRef.current = state.mode; }, [onModeChange, state.lastEvent, state.mode]); + React.useEffect(() => { + const transitionDirection = state.transitionDirection; + + if ( + state.transitionCount === 0 || + state.transitionCount === queuedTransitionCountRef.current || + !transitionDirection + ) { + return; + } + + queuedTransitionCountRef.current = state.transitionCount; + const flightAircraft = getFlightAircraft(state.activeAircraft); + if (flightAircraft.length === 0) { + return; + } + + setActiveFlights(currentFlights => [ + ...currentFlights, + ...flightAircraft.map(aircraft => { + const flight: ActiveFlight = { + id: nextFlightIdRef.current, + aircraft, + direction: transitionDirection, + }; + nextFlightIdRef.current += 1; + return flight; + }), + ]); + }, [state.activeAircraft, state.mode, state.transitionCount, state.transitionDirection]); + const dispatchTransition = React.useCallback( ( transition: AnimationTransition, @@ -146,10 +201,13 @@ export function AnimationDemo({ type: 'reset', mode: initialMode, }); + setActiveFlights([]); + queuedTransitionCountRef.current = 0; }, [initialMode, scrollBehavior]); - const planeActive = state.activeAircraft === 'plane' || state.activeAircraft === 'both'; - const helicopterActive = state.activeAircraft === 'helicopter' || state.activeAircraft === 'both'; + const removeFlight = React.useCallback((flightId: number) => { + setActiveFlights(currentFlights => currentFlights.filter(flight => flight.id !== flightId)); + }, []); const scrollHint = scrollHintsByMode[state.mode]; return ( @@ -181,22 +239,25 @@ export function AnimationDemo({
- - + {activeFlights.map(flight => + flight.aircraft === 'plane' ? ( + removeFlight(flight.id)} + /> + ) : ( + removeFlight(flight.id)} + /> + ), + )}
{scrollHint} diff --git a/src/stories/components/AnimationDemo/Heli.tsx b/src/stories/components/AnimationDemo/Heli.tsx index 4908828..aa40685 100644 --- a/src/stories/components/AnimationDemo/Heli.tsx +++ b/src/stories/components/AnimationDemo/Heli.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styles from './AnimationDemo.module.css'; +import helicopterSvg from './assets/helicopter.svg'; import { classNames } from './classNames'; import type { AnimationMode, AnimationTransitionDirection } from './types'; import { useRestartAnimation } from './useRestartAnimation'; @@ -8,6 +9,7 @@ export type HelicopterProps = { mode: AnimationMode; isActive: boolean; direction: AnimationTransitionDirection | null; + onFlightComplete?: () => void; }; const phaseClassNames: Record = { @@ -17,13 +19,22 @@ const phaseClassNames: Record = { night: styles.vehicleNight, }; -export function Helicopter({ mode, isActive, direction }: HelicopterProps) { +export function Helicopter({ mode, isActive, direction, onFlightComplete }: HelicopterProps) { const isUpwardFlight = direction === 'up'; const hasStarted = useRestartAnimation(isActive, direction); + const handleTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + if (event.target === event.currentTarget && event.propertyName === 'transform') { + onFlightComplete?.(); + } + }, + [onFlightComplete], + ); return ( ); diff --git a/src/stories/components/AnimationDemo/Plane.tsx b/src/stories/components/AnimationDemo/Plane.tsx index 61a1fa6..6bf99cc 100644 --- a/src/stories/components/AnimationDemo/Plane.tsx +++ b/src/stories/components/AnimationDemo/Plane.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styles from './AnimationDemo.module.css'; +import planeSvg from './assets/plane.svg'; import { classNames } from './classNames'; import type { AnimationMode, AnimationTransitionDirection } from './types'; import { useRestartAnimation } from './useRestartAnimation'; @@ -8,6 +9,7 @@ export type PlaneProps = { mode: AnimationMode; isActive: boolean; direction: AnimationTransitionDirection | null; + onFlightComplete?: () => void; }; const phaseClassNames: Record = { @@ -17,13 +19,22 @@ const phaseClassNames: Record = { night: styles.vehicleNight, }; -export function Plane({ mode, isActive, direction }: PlaneProps) { +export function Plane({ mode, isActive, direction, onFlightComplete }: PlaneProps) { const isDownwardFlight = direction === 'down'; const hasStarted = useRestartAnimation(isActive, direction); + const handleTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + if (event.target === event.currentTarget && event.propertyName === 'transform') { + onFlightComplete?.(); + } + }, + [onFlightComplete], + ); return ( ); diff --git a/src/stories/components/AnimationDemo/assets/helicopter.svg b/src/stories/components/AnimationDemo/assets/helicopter.svg new file mode 100644 index 0000000..e4f9b9c --- /dev/null +++ b/src/stories/components/AnimationDemo/assets/helicopter.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/stories/components/AnimationDemo/assets/plane.svg b/src/stories/components/AnimationDemo/assets/plane.svg new file mode 100644 index 0000000..8d795c7 --- /dev/null +++ b/src/stories/components/AnimationDemo/assets/plane.svg @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts index fc781e8..14461e2 100644 --- a/src/types/assets.d.ts +++ b/src/types/assets.d.ts @@ -2,3 +2,8 @@ declare module '*.png' { const src: string; export default src; } + +declare module '*.svg' { + const src: string; + export default src; +}