Two observation modes
Sentinel mode stays tiny and convenient, while child mode observes a real element when threshold precision matters.
API Reference
This is a more detailed explanation of how <AtomTrigger /> works and how to use it.
The goal here is not only to explain what it does, but also why it behaves like that, because this is usually where confusion starts.
Sentinel mode stays tiny and convenient, while child mode observes a real element when threshold precision matters.
fireOnInitialVisible lets restored or pre-visible content emit an immediate enter event without pretending it came from scrolling.
event.isInitial, movement direction, counts, and position make it easier to branch on what actually happened.
<AtomTrigger /> Actually DoesAt the simplest level, it solves this:
Run some code when something enters or leaves the view.
If you used react-waypoint before, this solves basically the same problem.
There are two ways this component works.
This matters because some props behave differently depending on the mode.
If you render it like this:
<AtomTrigger onEnter={() => console.log("entered")} />It renders its own internal <div> and observes that node.
This works fine in most cases, but it is a bit abstract because you do not really "see" what is being tracked.
Important detail:
threshold do not behave very meaningfully unless you explicitly give it size.1px x 1px, but in practice it still behaves almost like a point.If you pass exactly one child:
<AtomTrigger threshold={0.75}>
<section>Hero</section>
</AtomTrigger>Now it observes that element directly.
In practice this is usually better when:
thresholdChild mode requires exactly one top-level child.
If you pass multiple children, it will not work correctly.
This is where people usually get stuck.
If you pass a custom component, the ref from AtomTrigger still has to reach a DOM node.
Otherwise, even though it renders correctly, AtomTrigger cannot access the actual element it needs to measure. Then it just looks like "nothing works".
Rule of thumb:
AtomTrigger needs a real DOM node to measure. Intrinsic elements such as <div>, <section> and <article> work automatically.
Example:
<AtomTrigger threshold={0.5}>
<section>
<h2>Pricing</h2>
<p>...</p>
</section>
</AtomTrigger>A custom component works in child mode only if it passes the received ref down to a DOM element.
For React 18 and older, that usually means React.forwardRef.
For React 19, a plain function component can also work if it accepts ref as a prop and passes it through.
Good:
const Card = React.forwardRef<HTMLDivElement, { children: React.ReactNode }>(function Card(
{ children },
ref,
) {
return <div ref={ref}>{children}</div>;
});
function Example() {
return (
<AtomTrigger threshold={0.5}>
<Card>Mario World</Card>
</AtomTrigger>
);
}Also good in React 19:
function Card({ children, ref }: { children: React.ReactNode; ref?: React.Ref<HTMLDivElement> }) {
return <div ref={ref}>{children}</div>;
}
function Example() {
return (
<AtomTrigger threshold={0.5}>
<Card>Mario World</Card>
</AtomTrigger>
);
}Not enough:
function Card({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
function Example() {
return (
<AtomTrigger threshold={0.5}>
<Card>Mario World</Card>
</AtomTrigger>
);
}This example still renders a <div>, but AtomTrigger cannot reach that DOM node directly because the component never passes the received ref through.
The simplest workaround is often to wrap the custom component in a plain DOM element and observe that wrapper instead:
function Example() {
return (
<AtomTrigger threshold={0.5}>
<div>
<Card>Mario World</Card>
</div>
</AtomTrigger>
);
}If a custom child temporarily renders null or a placeholder before the DOM node exists, AtomTrigger delays the missing-ref warning a bit so that normal async mount flows can settle first.
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;
}onEnterThis fires when the trigger goes from outside to inside the visible area.
In practice this means:
Something entered the view.
Typical use cases:
onLeaveFires when something leaves the visible area.
Typical use cases:
onEventFires for both enter and leave.
Sometimes it is easier to use one handler and branch on:
event.type;onceAllows only the first transition.
After that it stops reacting.
This is useful when:
oncePerDirectionAllows:
enterleaveThis is usually more predictable than once, because you still get both directions.
fireOnInitialVisibleThis one usually causes confusion the first time.
Normally:
That is because technically nothing "entered".
If you enable it, it will fire an enter event immediately if the element is already visible.
But this is not a real transition, so the event contains:
event.isInitial === true;Example:
import React from "react";
import { AtomTrigger } from "react-atom-trigger";
export function RestoredStateExample() {
return (
<AtomTrigger
fireOnInitialVisible
onEnter={(event) => {
if (event.isInitial) {
console.log("started visible after load");
return;
}
console.log("entered from scrolling");
}}
/>
);
}Important detail:
enter.once.oncePerDirection.If something fires "too early", this is often the reason.
disabledDisables observation.
It does not unmount anything. It just stops reactions.
thresholdNumber between 0 and 1.
Controls when enter fires.
0 means any visibility0.5 means half visible1 means fully visibleImportant detail:
threshold only affects enterleave is simpler:
enter, leave fires when the element is fully out of viewFull behavior example with threshold={1}:
enterenter firesleaveleave firesAnother important detail:
threshold is calculated against the effective rootrootMargin affects that effective rootSo if something feels off, check both together.
In sentinel mode, threshold is usually not very meaningful because the sentinel is basically a point.
So in practice:
threshold is mostly useful in child mode, unless you give the sentinel some real width and height via classNamerootDefines what "visible area" means.
Default is the viewport.
If you pass a container, it uses that container instead.
Think of root as:
What counts as visible.
Example:
function Example({ containerElement }: { containerElement: HTMLDivElement | null }) {
return (
<AtomTrigger
root={containerElement}
onEnter={() => {
console.log("entered container viewport");
}}
/>
);
}If you pass root explicitly but it is currently null, observation pauses until that real root exists. It does not silently switch back to the viewport.
rootRefSame as root, but React-friendly.
If both exist, rootRef wins.
Example:
import React from "react";
import { AtomTrigger } from "react-atom-trigger";
export function ScrollBox() {
const containerRef = React.useRef<HTMLDivElement>(null);
return (
<div ref={containerRef} style={{ height: 320, overflowY: "auto" }}>
<div style={{ height: 600 }} />
<AtomTrigger
rootRef={containerRef}
onEnter={() => {
console.log("entered scroll box");
}}
/>
<div style={{ height: 600 }} />
</div>
);
}If rootRef.current is still null, observation pauses until the ref resolves to a real DOM element.
Rule of thumb:
root: viewportrootRef: JSX containerroot: external DOM noderoot / rootRef: paused observation, not viewport fallbackrootMarginThis shifts the boundaries of the root.
Important:
Example:
// Top margin only
<AtomTrigger rootMargin="-100px 0px 0px 0px" />
// Top + bottom
<AtomTrigger rootMargin="-100px 0px -80px 0px" />
// Array version
<AtomTrigger rootMargin={[-100, 0, -80, 0]} />Practical advice:
rootMargin for pixel adjustmentsthreshold for proportionsImportant implementation detail:
rootMargin is handled by the library itselfIntersectionObserver is only used to wake things up when layout changesThat is why behavior is consistent and not dependent on browser quirks.
Design note:
classNameApplies only to the sentinel.
In child mode, style the child instead.
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;
};isInitialtrue only when the event comes from fireOnInitialVisible.
Otherwise it is always false.
movementDirectionTells how things are moving.
Usually:
updownleftrightstationary if an event fired even though the element itself did not really move, which usually means the visible area changed around itunknown if there is not enough previous geometry yet to tell a direction, which most commonly happens on an initial event from fireOnInitialVisiblepositionWhere the element is relative to the root.
Useful if you want more control than just enter and leave.
countsTracks how many times enter and leave happened.
useScrollPosition and useViewportSize are also exported if you want the library helpers.
useScrollPosition(options?: {
target?: Window | HTMLElement | React.RefObject<HTMLElement | null>;
passive?: boolean;
throttleMs?: number;
enabled?: boolean;
}): { x: number; y: number }useViewportSize(options?: {
passive?: boolean;
throttleMs?: number;
enabled?: boolean;
}): { width: number; height: number }Both hooks are SSR-safe and hydration-safe across the supported React range. During hydration, the first client render matches the server snapshot and then refreshes from the live source, including the compat path used when React does not expose useSyncExternalStore. Default throttling is 16ms.
If you pass enabled={false}, the hook pauses its listeners but keeps the latest value it already knows. It does not fake a reset back to zero. When you enable it again, it reads from the source immediately and then continues updating as usual.
If something behaves differently than expected, check these first:
thresholdrootMarginIn practice, most issues come from those three.
If something still feels weird after that, it is usually timing near boundaries and that is expected to be slightly different from older approaches.