Reliable scroll triggers for real React layouts

react-atom-trigger is a small React library that runs your code when something enters or leaves the view.

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.

If you used react-waypoint before, this is a simpler alternative. It can also replace React Intersection Observer based solutions when you need predictable enter/leave behavior in real layouts.

Install

# pnpm
pnpm add react-atom-trigger

# npm
npm install react-atom-trigger

# yarn
yarn add react-atom-trigger

The published package does not enforce a specific Node.js engine.

The public React compatibility contract for v2 is the published peer range: 16.8 through 19.x.

Get started

Most of the time you just render AtomTrigger and handle enter and leave.

A common setup is rootMargin for trigger zone offsets and oncePerDirection when you want one enter and one leave.

<AtomTrigger
  onEnter={event => {
    console.log('entered', event);
  }}
  onLeave={event => {
    console.log('left', event);
  }}
  rootMargin="-100px 0px 0px 0px"
  oncePerDirection
/>

It's easier to understand when you see it working.

Browse the Storybook

See the full interactive demo set in Storybook.

What changed in v2

In v1 you had to pass scroll position and container dimensions yourself.

In v2 AtomTrigger handles observation internally. It listens to scroll, resize and layout shifts and samples geometry on its own.

So most setups became just “render it and pass callbacks”.

  • No external scroll plumbing
  • Works with viewport or custom containers
  • Handles fixed headers and offsets
  • Can observe a real child element when threshold matters
  • Emits richer event data

How it works

AtomTrigger uses a mixed approach.

Geometry is the source of truth for enter and leave.

IntersectionObserver wakes things up when the browser notices nearby layout changes, geometry decides what actually entered or left.

rootMargin is handled by the library itself, so the behavior stays consistent instead of depending on native observer quirks.

Because of that it reacts not only to scroll, but also to window resize, root resize, sentinel resize and layout shifts.

That is the main reason v2 can support custom margin-aware behavior and still react to browser-driven layout changes.

  • reacts to scroll
  • reacts to window resize
  • reacts to root resize
  • reacts to sentinel resize
  • reacts to layout shifts that move the observed element even if no scroll event happened

API

The main props look like this.

The event payload is library-owned geometry data. It is not a native IntersectionObserverEntry.

fireOnInitialVisible lets you opt into an initial enter, and the payload exposes isInitial so you can treat restored visibility differently from scroll-driven transitions.

If root or rootRef is passed explicitly but is not ready yet, observation pauses until that real root exists. It does not silently fall back to the viewport.

In child mode, plain elements like div or section just work. If you use a custom component, the ref still has to make it down to a real DOM element. In React 19 that can be a ref prop, and in React 18 or older you usually do it with React.forwardRef.

v2.1 no longer exports scroll or viewport helper hooks. AtomTrigger observes internally, so use your app's own hooks when you need those values directly.

AtomTriggerProps

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;
}

AtomTriggerEvent

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;
}

Props in short

  • onEnter, onLeave, onEventtrigger callbacks with a rich event payload.
  • childrenobserve one real child element instead of the internal sentinel. Custom components need the received ref to reach a DOM node.
  • onceallow only the first transition overall.
  • oncePerDirectionallow one enter and one leave.
  • fireOnInitialVisibleemit an initial enter when observation starts and the trigger is already active.
  • disabledstop observing without unmounting the component.
  • thresholda number from 0 to 1. It affects enter, not leave.
  • rootuse a specific DOM element as the visible area. If it is explicitly passed as null, observation pauses instead of falling back to the viewport.
  • rootRefsame idea as root, but better when the container is created in JSX. If both are passed, rootRef wins, and an unresolved ref pauses observation.
  • rootMarginexpand or shrink the effective root. String values use IntersectionObserver-style syntax, and arrays are [top, right, bottom, left] pixels.
  • classNameapplies only to the internal sentinel.

Upgrading from v1

This is a breaking release.

This is not just a prop rename release.

Please re-check the behavior in the real UI after upgrading, especially if you use threshold, rootMargin, custom scroll containers, or any code that was sensitive to the exact timing of the old callback.

The new version samples geometry internally, so some edge timing can feel a bit different near boundaries.

The short version

  1. callback became onEnter, onLeave, and onEvent.
  2. behavior is gone.
  3. triggerOnce became once or oncePerDirection.
  4. scrollEvent, dimensions, and offset are gone.
  5. legacy helper hooks are no longer exported in v2.1.
  6. use your app's own scroll or viewport hooks if needed.

For the real upgrade notes and examples, see MIGRATION.md on GitHub.