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.
- scroll-driven UI (sticky headers, scene transitions)
- triggering animations with precise timing
- layouts with dynamic content or frequent reflows
- containers with custom scroll roots
v2 is a breaking release. If you are coming from v1.x, please check
MIGRATION.md.
v2.1 removes the helper hooks useScrollPosition and useViewportSize. AtomTrigger does its own observation and does not depend on those hooks.
If you want to stay on the old API:
# pnpm
pnpm add react-atom-trigger@^1
# npm
npm install react-atom-trigger@^1
# yarn
yarn add react-atom-trigger@^1# pnpm
pnpm add react-atom-trigger
# npm
npm install react-atom-trigger
# yarn
yarn add react-atom-triggerThe published package does not enforce a specific Node.js engine. Runtime compatibility is determined by your React version, browser target and bundler setup.
The public React compatibility contract for v2 is the published peer range: React 16.8 through
19.x.
react-atom-trigger uses a mixed approach.
- Geometry is the real source of truth for
enterandleave. IntersectionObserveris 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.rootMarginlogic 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:
- scroll
- window resize
- root resize
- sentinel resize
- nearby layout shifts that move the observed element even if no scroll event happened
This allows it to support margin-aware behavior while still reacting to browser-driven changes.
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
import React from 'react';
import { AtomTrigger } from 'react-atom-trigger';
export function Example() {
return (
<AtomTrigger
onEnter={event => {
console.log('entered', event);
}}
onLeave={event => {
console.log('left', event);
}}
rootMargin="0px 0px 160px 0px"
oncePerDirection
/>
);
}If you want an already-visible trigger to behave like a normal first enter, pass
fireOnInitialVisible.
import React from 'react';
import { AtomTrigger } from 'react-atom-trigger';
export function RestoredScrollExample() {
return (
<AtomTrigger
fireOnInitialVisible
onEnter={event => {
if (event.isInitial) {
console.log('started visible after load');
return;
}
console.log('entered from scrolling');
}}
/>
);
}If you pass one top-level child, AtomTrigger observes that element directly instead of rendering its own sentinel.
import React from 'react';
import { AtomTrigger } from 'react-atom-trigger';
export function HeroTrigger() {
return (
<AtomTrigger threshold={0.75} onEnter={() => console.log('hero is mostly visible')}>
<section style={{ minHeight: 240 }}>Hero content</section>
</AtomTrigger>
);
}This is usually the better mode when threshold should depend on a real element size.
Intrinsic elements such as <div> and <section> work automatically.
If you use a custom component, the ref that AtomTrigger passes down still has to reach a real DOM
element:
- in React 19, the component can receive
refas a prop and pass it through - in React 18 and older, use
React.forwardRef
If the ref never reaches a DOM node, child mode cannot observe anything.
If a custom child renders a placeholder first and only exposes its DOM node a moment later,
AtomTrigger waits briefly before showing the missing-ref warning so normal async mount flows do
not get flagged too early.
interface AtomTriggerProps {
onEnter?: (event: AtomTriggerEvent) => void;
onLeave?: (event: AtomTriggerEvent) => void;
onEvent?: (event: AtomTriggerEvent) => void;
children?: React.ReactNode;
once?: boolean;
oncePerDirection?: boolean;
fireOnInitialVisible?: boolean;
disabled?: boolean;
threshold?: number;
root?: Element | null;
rootRef?: React.RefObject<Element | null>;
rootMargin?: string | [number, number, number, number];
className?: string;
}onEnter,onLeave,onEvent: trigger callbacks with a rich event payload.children: observe one real child element instead of the internal sentinel.once: allow only the first transition overall.oncePerDirection: allow oneenterand oneleave.fireOnInitialVisible: emit an initialenterwhen observation starts and the trigger is already active.disabled: stop observing without unmounting the component.threshold: a number from0to1. It affectsenter, notleave.root: use a specific DOM element as the visible area.rootRef: same idea asroot, but better when the container is created in JSX. If both are passed,rootRefwins.root/rootRef: if you pass one explicitly but it is stillnull, observation pauses until that real root exists. It does not silently fall back to the viewport.rootMargin: expand or shrink the effective root. String values useIntersectionObserver-style syntax. A four-number array is treated as[top, right, bottom, left]in pixels.className: applies only to the internal sentinel.
type AtomTriggerEvent = {
type: 'enter' | 'leave';
isInitial: boolean;
entry: AtomTriggerEntry;
counts: {
entered: number;
left: number;
};
movementDirection: 'up' | 'down' | 'left' | 'right' | 'stationary' | 'unknown';
position: 'inside' | 'above' | 'below' | 'left' | 'right' | 'outside';
timestamp: number;
};type AtomTriggerEntry = {
target: Element;
rootBounds: DOMRectReadOnly | null;
boundingClientRect: DOMRectReadOnly;
intersectionRect: DOMRectReadOnly;
isIntersecting: boolean;
intersectionRatio: number;
source: 'geometry';
};The payload is library-owned geometry data. It is not a native IntersectionObserverEntry.
isInitial is true only for the synthetic first enter created by
fireOnInitialVisible.
- In sentinel mode,
thresholdis usually only interesting if your sentinel has real width or height. The default sentinel is almost point-like. - The internal sentinel intentionally uses a non-block display so it behaves like a point-like marker instead of stretching into a full-width placeholder.
- Child mode needs exactly one top-level child and any custom component used there needs to pass the received ref through to a DOM element.
- In React 19, a plain function component can also work in child mode if it passes the received
refprop through to a DOM element. - If you pass
rootorrootRefexplicitly and it is not ready yet, observation pauses instead of falling back to the viewport. rootMarginis handled by the library geometry logic.IntersectionObserveris only used as a nearby wake-up signal for layout shifts.
The short version:
callbackbecameonEnter,onLeaveandonEvent.behavioris gone.triggerOncebecameonceoroncePerDirection.scrollEvent,dimensionsandoffsetare gone.- Legacy helper hooks are no longer exported in
v2.1. Use your app's own scroll or viewport hooks when needed.
For the real upgrade notes and examples, see MIGRATION.md.
This package is built with tsdown.
lib/index.js
lib/index.umd.js
lib/index.d.ts
When the UMD bundle is loaded directly in the browser, the library is exposed as window.reactAtomTrigger.
Storybook is the easiest way to see how the component behaves.
AtomTrigger Demo: regular usage examples.Extended Demo: a larger animated interaction demo that shows AtomTrigger driving scene changes, event timing and more realistic scroll-based UI behavior.Internal Tests: interaction stories used for local checks and Storybook tests.
To run Storybook locally:
pnpm storybookThe latest Storybook build for react-atom-trigger is also available at
storybook.atomtrigger.dev.
Quick way to tweak it in the browser.
- Basic sentinel example
- Child mode threshold example
- Fixed header offset example
- Initial visible on load example
- Horizontal scroll container example
pnpm install
pnpm lint
pnpm test
pnpm test:coverage
pnpm test:storybook
pnpm build
pnpm format:checkCoverage note:
pnpm test:coverageis the official unit coverage signal used in CI and Codecov.pnpm test:storybookis a separate browser regression gate and is not merged into the official coverage number.
Build:
pnpm build:sbOutput:
storybook-static/