DEV Community

Cover image for react-atom-trigger v2: A Scroll Trigger Recipe
innrVoice
innrVoice

Posted on

react-atom-trigger v2: A Scroll Trigger Recipe

Some react-waypoint instinct, a bit of IntersectionObserver magic, a lot of geometry and just enough scheduler spice to keep it sane.

I did not want to write just another "how to use my library" article. Not because usage is not important. It is important. But the docs already exist, the examples exist, Storybook exists and I think nobody needs one more article where I just say "install package, import component, pass onEnter".

That would be useful maybe, but also a bit boring. And if there is one thing I try to avoid (with mixed success) it is writing boring things about boring things.

So this article is more about why react-atom-trigger v2 became what it is.

  • Why it is not just another wrapper around IntersectionObserver.
  • Why geometry became the main source of truth.
  • Why there is a scheduler baked in.
  • Why I still think scroll triggers are not as "solved" as it may look from far away.

And probably why I again spent more time than expected on something that at first looked like a simple component.

The old problem that somehow still exists


The problem itself is simple:

Run some code when something enters or leaves the visible area.

That's it. Sounds primitive.

  • You scroll to a section, animation starts.
  • You reach the end of the list, load more happens.
  • A block appears below a fixed header, some "do something" trick.

  • A card becomes 75% visible and it is counted as viewed.

  • A trigger is already visible on page load? Maybe fire immediately, but also maybe mark it as initial, because it is not exactly the same thing as scrolling into view.

Nothing magical.

And yet this problem has been annoying web developers for a very long time. There were old-style scroll listeners. There was react-waypoint, which many people used and probably many people still remember with warm feelings. Then IntersectionObserver arrived and, in many cases, really made life better.

So why create another library?

Good question. I asked myself the same thing. More than once.

The answer is somewhat simple. I wanted a very specific kind of predictability.

Not a huge framework for scroll effects. Not animation magic. Not a universal visibility demiurge.

Just a small thing that tells me: this trigger is now inside, this trigger is now outside and the decision was made from actual geometry that I can reason about.

Maybe not a very glamorous dream, but we work with what we have.

The legacy side: why react-waypoint was nice

I always liked the idea behind react-waypoint.

It had a very human API. You place a waypoint somewhere and you get onEnter / onLeave. It also had offsets, custom scrollable ancestors and the general feeling of "I just want to know when the user passed this point".

I believe that mental model is still good.

In some ways, react-atom-trigger is closer to that spirit than to modern IntersectionObserver wrappers. Not because I wanted to clone react-waypoint, but because I still think the waypoint mental model is useful.

A trigger is not always a visibility detector in the abstract browser API sense. Sometimes it is literally a little marker in your layout.

Like:

<AtomTrigger onEnter={() => startSomething()} />
Enter fullscreen mode Exit fullscreen mode

This is very different from saying:

"I want to observe a DOM element and receive an IntersectionObserverEntry whenever the intersection ratio crosses a configured threshold..."

Both are valid. But they are not the same mental model.

The older scroll-listener approach had its own problems of course.

If every instance listens to scroll and calculates its own position, this can become real messy. If you need fixed header offsets, custom roots, layout changes, restored scroll positions or many triggers on the page, the responsibility starts leaking into your application code.

And when implementation details leak too much, the simple API stops feeling simple.

The modern side: IntersectionObserver is good, but...

I want to be clear here, because this is easy to misunderstand.

I do not think IntersectionObserver is bad. Quite the opposite. It exists for a very good reason.

Before it, everyone was doing their own scroll listeners, measuring elements, comparing rectangles and hoping not to destroy performance (or sanity). IntersectionObserver moved a lot of this work into the browser and gave devs a better primitive for lazy loading, visibility tracking, impressions and all that.

For many use cases, an IO-first React library is exactly the right choice.

If all you need is:

const { ref, inView } = useInView()
Enter fullscreen mode Exit fullscreen mode

then yes, use that.

It is simple, mature and... probably enough.

But I did not want react-atom-trigger v2 to be just another nice React skin over IntersectionObserver.

The reason is that IO is asynchronous and threshold-based.

It tells you when the browser thinks an intersection crossed some configured boundary. This is powerful, but it is not the same as saying:

"I want to calculate my own effective root, apply my own margin logic, look at the current rectangles and decide now"

That difference sounds small until you meet some real layouts. And real layouts are usually not polite.

  • There is a fixed header.
  • There is a custom scroll container.
  • The root ref is not ready yet.
  • The element is already visible on load.
  • A section changed height because some async content appeared.
  • A threshold should mean "75% of this actual child element", not "some marker-like thing touched the viewport".
  • A margin should behave consistently because you chose it, not because one browser interpreted it in a way you now have to remember.

After enough of those small irritations I started to feel that IntersectionObserver should not be the judge.

It should be a signal.

Something that says: "Hey, maybe geometry changed. You may want to check."

But not the thing that decides the final enter or leave.

Geometry as the boring source of truth

So react-atom-trigger v2 took a different route.

The main decision is simple:

Geometry is the source of truth.

react-atom-trigger samples rectangles and decides based on them.

The event payload is library-owned geometry data, not a native IntersectionObserverEntry. IntersectionObserver is used only as one of the ways to wake things up when the browser notices that something may have changed.

This may sound slightly stubborn and maybe it is. But it gives a very useful kind of stubbornness.

  • The library can handle rootMargin itself.
  • It can decide what the effective root is.
  • It can compare the target with that effective root.
  • It can react to scroll, root resize, sentinel resize, window resize and layout shifts.
  • It can say that an initial visible event is initial, instead of pretending that everything is just one generic visibility change.

In other words, v2 tries to reduce magic.

Not remove all magic, of course. This is frontend and we are not in paradise. But at least make the trigger logic easier to explain.

Sentinel mode and child mode

One thing I wanted to make explicit in v2 is that there are two different use cases hiding under the same "scroll trigger" umbrella.

The first one is marker-based.

You just want to place a trigger point in the layout.

<AtomTrigger onEnter={() => console.log("entered")} />


Enter fullscreen mode Exit fullscreen mode

This is sentinel mode. The component renders its own small internal element and observes it. In practice, it behaves almost like a point.

That is great when you want to say: "When the user reaches this place, do something".

But it is not great when you say:

<AtomTrigger threshold={0.75} />
Enter fullscreen mode Exit fullscreen mode

because then the obvious question appears:

75% of what?

A point-like sentinel does not have meaningful "75% visible" semantics unless you give it size. This is one of those things that sounds obvious only after somebody says it out loud.

That is why child mode exists.


<AtomTrigger threshold={0.75} onEnter={() => console.log("hero is mostly visible")}>
  <section>Hero</section>
</AtomTrigger>
Enter fullscreen mode Exit fullscreen mode

Now the actual child is observed. The threshold is based on a real element with a real size. That makes much more sense when the trigger condition depends on visibility of the element itself.

Of course, this also brings the boring React reality. If the child is a custom component, the ref must reach a real DOM node. If it does not, there is nothing to measure.

Not very romantic. But better to be honest about boring things.

Fixed headers, margins, and other small traps

Fixed headers are one of those problems that look too simple to deserve attention.

You have a header. It covers the top part of the viewport. You want the trigger to happen not when the element touches the viewport top but when it reaches the visible area below the header.

Everyone who worked on scroll UI probably met this little devil.

You can solve it with offsets. You can solve it with rootMargin. You can solve it in different ways. But I wanted the margin logic inside react-atom-trigger to belong to the same geometry engine as everything else.

So rootMargin in v2 is not delegated as the final truth to native IntersectionObserver. The library parses it, applies it to the effective root and makes decisions based on that.

This matters because once margins become part of the library-owned geometry model they stop being a separate browser-dependent mystery layer.

At least that was the goal.

Does this mean no edge cases can exist? Of course not. Give frontend enough layout combinations and it will always find a new way to break you your assumptions.

But the mental model becomes simpler.

There is a root.
There is a margin.
There is a target.
The library calculates geometry and decides.

I can live with that.

Initial visibility is not the same as scrolling


Another small thing that mattered to me. What happens when the trigger is already visible when observation starts?

  • Maybe the user refreshed the page and the browser restored scroll position.
  • Maybe routing returned them to the same place.
Maybe the layout is short.
  • Maybe your trigger is simply already inside the viewport.

Should onEnter fire?

Sometimes yes. Sometimes no. But if it does, I wanted it to be possible to know that this was not caused by scrolling into view.

That is why v2 has fireOnInitialVisible, and the event contains isInitial.

This is not a huge feature. It will not make anyone clap in a conference hall. But it removes another small ambiguity. And many small ambiguities together are exactly what make scroll-trigger behavior feel like black magic sometimes.

Layout shifts without scroll


This is maybe one of the main practical reasons for the mixed approach.

Things move without scrolling.

Images load.
Fonts swap.
Accordions open.
Content appears after API response.
A parent container changes size.
The root itself resizes.

The old naive mental model says: "listen to scroll and check positions"

But no scroll happened.

The IO-first mental model says: "wait for the observer callback"

That can work. And often does. But I did not want the final behavior to depend only on that. I wanted layout-related signals to wake up the scheduler and then real geometry should be sampled again.

Again. Not because I hate IntersectionObserver. It is useful.

But I wanted the actual truth to be this simple rectangle check, not the callback shape.

Why this still makes sense when there are many triggers

There was one more thing I've taken care about in v2.

A geometry-first approach is nice in theory, but it stops being nice very quickly if every trigger on the page starts doing its own private work.

That was not the direction I wanted.

So the idea in v2 is simpler than the code behind it: triggers that belong to the same root should share work as much as possible.

Scroll, resize and nearby layout-change signals get collected, then geometry is sampled in a coordinated way instead of every instance reacting like a small isolated universe.

The point is not to be clever. The point is to avoid doing the same work again and again just because a page happens to have many triggers on it.

This is where the internal scheduler comes in. Not as a heroic architecture but more as a small grown-up supervision mechanism inside the library.

If there are many triggers in the same scroll container, they can share one root-level scheduler. That scheduler collects invalidations and batches sampling through the browser frame cycle.

So instead of every trigger instantly measuring everything whenever something happens, the library can wait for the next frame and process registrations together.

This is also why the observer inside the scheduler uses a broad area and works more like a "wake up, something around here changed" signal. It is not there to be the visibility authority. It is there to help the geometry engine know when it may need to check again.

I like this split.

  • Observer as signal
  • Geometry as truth
  • Scheduler as traffic controller

Maybe too dramatic for a small React package, but still.

This part is important because otherwise the whole geometry-first story would sound more romantic than practical. With that approach in place, the library is not just saying "I want exact geometry". It is also saying "I do not want to pay for that in the dumbest possible way".

And yes. When the last trigger for a root disappears, the shared machinery can disappear too. No need to keep ghosts of old scroll listeners around. We already have enough ghosts in frontend.

So what is v2 actually trying to be?

I would not describe react-atom-trigger as a replacement for every visibility library.

That sounds too confident and also simply not true.

If you want native IO semantics in React, there are good libraries for that. If you want to track whether something is in view in the most browser-native way, use them. I am not here to start a holy war about observers. Nobody needs that, especially me.

react-atom-trigger is for a slightly different mood. It is for cases where you want scroll-trigger behavior that feels more like:
"I placed this trigger here. I gave it a root and maybe a margin. I want to know when it enters or leaves according to that geometry".

  • It is for fixed headers.
  • For custom containers.
  • For threshold based on an actual child.
  • For initial visible state.
  • For layout shifts.
  • For pages where many triggers should not each behave like they are the center of the universe.

And maybe also for people who used react-waypoint before and miss that direct onEnter / onLeave feeling, but want something more aligned with modern React layouts.

At least that was the intention.

A small example, because we still need one.

Not a tutorial. Just enough to show the idea.

<AtomTrigger
  rootMargin="-100px 0px 0px 0px"
  oncePerDirection
  onEnter={event => {
    console.log("entered", event.position, event.movementDirection);
  }}
  onLeave={event => {
    console.log("left", event.position);
  }}
/>
Enter fullscreen mode Exit fullscreen mode

This is the basic form.

A marker in the layout.
Margin applied by the library.
Callbacks with event data that belongs to react-atom-trigger.

And when threshold has impact:

<AtomTrigger threshold={0.75}>
  <section>
    <h2>Pricing</h2>
    <p>Some content that has real size.</p>
  </section>
</AtomTrigger>
Enter fullscreen mode Exit fullscreen mode

Now the observed thing is not an abstract sentinel. It is the section itself.

Not rocket science. Just trying to make the obvious thing behave like the obvious thing. Which in frontend is already a decent ambition.

Final thoughts

react-atom-trigger v2 came from a pretty small irritation.

  • I wanted scroll triggers that were simple to use but did not hide too much of the important behavior behind the browser observer model.
  • I wanted geometry to be the thing that decides.
  • I wanted IntersectionObserver to help, not rule.
  • I wanted margins to be consistent.
  • I wanted initial visible state to be explicit.
  • I wanted many triggers on a page to share work instead of multiplying it in the most naive possible way.

That is basically the whole story.

It is not a huge philosophical breakthrough. Maybe it is just a small practical library solving a small practical problem.

But small practical problems have a funny habit of becoming very annoying when they appear in every project.

So v2 is my attempt to make this one a bit less annoying.

Not perfect, not universal,
not magical. Just more predictable.

And sometimes that is already enough.

Top comments (0)