Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
19 changes: 13 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
7 changes: 2 additions & 5 deletions src/AtomTrigger.core.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,20 +248,17 @@ 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(() => {});
const { root } = setupRootHarness({ onEnter, threshold: [0.75, 0.25] });

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.',
);
});

Expand Down
14 changes: 2 additions & 12 deletions src/AtomTrigger.geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.',
);
Expand All @@ -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();
});
Expand Down
12 changes: 0 additions & 12 deletions src/AtomTrigger.geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../../../index')>('../../../index');

Expand All @@ -21,11 +27,25 @@ vi.mock('../../../index', async () => {
});

vi.mock('./Plane', () => ({
Plane: () => <div data-testid="mock-plane" />,
Plane: ({ mode, direction, onFlightComplete }: MockAircraftProps) => (
<div
data-testid="mock-plane"
data-mode={mode}
data-direction={direction ?? ''}
onTransitionEnd={onFlightComplete}
/>
),
}));

vi.mock('./Heli', () => ({
Helicopter: () => <div data-testid="mock-helicopter" />,
Helicopter: ({ mode, direction, onFlightComplete }: MockAircraftProps) => (
<div
data-testid="mock-helicopter"
data-mode={mode}
data-direction={direction ?? ''}
onTransitionEnd={onFlightComplete}
/>
),
}));

vi.mock('./Scene', () => ({
Expand Down Expand Up @@ -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(<AnimationDemo defaultShowTriggers scrollBehavior="instant" />);

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(<AnimationDemo initialMode="sunset" defaultShowTriggers scrollBehavior="instant" />);
const root = setupScrollGeometry();
Expand Down
10 changes: 5 additions & 5 deletions src/stories/components/AnimationDemo/AnimationDemo.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export type JumpDefinition = {
direction: AnimationTransitionDirection;
};

export const defaultTransitionMap = {
export const defaultTransitionMap: Record<
AnimationTriggerId,
Partial<Record<AnimationTransitionDirection, AnimationTransition>>
> = {
top: {
up: {
nextMode: 'day',
Expand All @@ -40,10 +43,7 @@ export const defaultTransitionMap = {
aircraft: 'plane',
},
},
} satisfies Record<
AnimationTriggerId,
Partial<Record<AnimationTransitionDirection, AnimationTransition>>
>;
};

export const triggerDefinitions: readonly TriggerDefinition[] = [
{
Expand Down
6 changes: 6 additions & 0 deletions src/stories/components/AnimationDemo/AnimationDemo.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@
will-change: transform, filter, opacity;
}

.vehicleImage {
display: block;
width: 100%;
height: auto;
}

.vehicleIdle {
opacity: 0;
}
Expand Down
Loading
Loading