DEV Community

Cover image for Observing Position Changes of HTML Elements Using IntersectionObserver
AJK-Essential
AJK-Essential

Posted on • Edited on

Observing Position Changes of HTML Elements Using IntersectionObserver

An experimental exploration of detecting element movement using IntersectionObserver, its limitations, and practical trade-offs.


Credits:

I want to clarify that while the code in this post was developed entirely by me, the idea behind this implementation originated from this StackOverflow discussion:

“How to observe DOM element position changes”https://stackoverflow.com/questions/59792071/how-to-observe-dom-element-position-changes

This Stackoverflow discussion presented the concept, but not an implementation. I explored the idea, built the implementation myself, and wrote this article to explain how it works and how to use it. Many thanks to the original author of the StackOverflow post for the inspiration.


TL;DR:

An experiment to see if we can use an IntersectionObserver to observe position changes of elements.


Quick Navigation


Important Disclaimer

This approach is not a foolproof way to detect position changes.

IntersectionObserver was not designed to be a general-purpose position-change detector, and all approaches discussed in this article, work within its constraints.

Known limitations include:

  • Element resizes may not be detected reliably
  • Viewport resizes can invalidate internal assumptions
  • Some layout or transform changes may not trigger observer callbacks
  • The browser may skip intersection updates under heavy load
  • Rapid observer teardown and reconfiguration can cause missed transitions

These techniques should be viewed as best-effort motion detection strategies, not as guaranteed replacements for explicit layout observation.

The goal of this article is to explore how far IntersectionObserver can be pushed — and where it breaks down.


The Approach

IntersectionObserver is typically used to detect visibility changes, but it also
reports partial intersections, which makes it possible to infer relative motion
between a target and its observing root.

Another important feature is the ability to dynamically resize the observer’s root
using rootMargin. This effectively lets us control the size of the “capturing window”
used for observation.

By combining high-resolution thresholds with dynamic root resizing, we can approximate
fine-grained position change detection without continuously reading layout.


Terminology note:

Throughout this article, the terms viewport window and wide window are used interchangeably to refer to the viewport-sized IntersectionObserver root.

The term fine window refers to the dynamically resized observer root that tightly wraps the visible intersection rectangle of the target.


Tracking Modes

1. Wide Tracking (Visibility Detection)

We use a wide capturing window that covers the entire visual area (the viewport).

This mode is used to determine whether the target is inside or outside the visual area.

Once the observer reports that the target intersects the viewport (even at its boundary), we know the target is visible.


2. Fine Tracking (Precise Movement)

When the target is within the visual area, we switch to a small capturing window that tightly wraps around the target element.

With the high-resolution threshold array, even very small movements of the target produce detectable changes in intersection ratio.

This allows us to track motion with high precision.


Determining When the Target Has Moved

Before diving into the detection logic, here are a few important observations (assuming the fine window is used only when the target is either at the boundary of the viewport or entirely within it):

Window Size Intersection Ratio Position Description
Fine > 0 and < 1 Touching the boundary of the viewport
Fine > 0 and < 1 Touching the boundary of the fine window
Fine 0 Outside the fine window but inside the viewport
Fine 0 Outside the viewport
Fine 1 Completely inside the fine window
Wide > 0 and < 1 Touching the boundary of the viewport
Wide 0 Outside the viewport
Wide 1 Completely inside the viewport

Determining the Movement Strategy

Approach 1

Movement Flow (Text-Only)

START -> STATE: WIDE  (Visibility Detection)
  - Observe using a viewport-sized window
  - intersectionRatio > 0  -> switch to FINE
  - intersectionRatio == 0 -> remain in WIDE

STATE: FINE  (Precise Tracking)
  - Observe using a tightly wrapped window
  - intersectionRatio == 1        -> stationary
  - 0 < intersectionRatio < 1     -> moving
  - intersectionRatio == 0        -> switch to WIDE

Notes: If the `intersection-ratio` while in the fine
window state is less than 1, movement has started.

Particulary if the intersection-ratio is 0, we switch
back to wide window detection to start the process
again.

(We are relying on the large threshold of the fine
window for fine-grained movement detection (0<IR<1))
Enter fullscreen mode Exit fullscreen mode

This approach works well in most cases, but it exposes an important limitation.

When we rely entirely on the fine window for all cases where 0 < intersectionRatio < 1, tracking can become unreliable as the intersection ratio approaches 0. This is especially noticeable when the target moves along the viewport boundary rather than directly out of it.

In such cases, the observer may miss movement updates entirely, and once those updates are missed, the subsequent motion is no longer captured correctly. As a result, the tracking logic can fall out of sync with the actual position of the target.


Approach 2 — Observer + RAF Hybrid Tracking

Approach 2 builds on the limitations observed in Approach 1 by combining
IntersectionObserver with requestAnimationFrame (RAF).

Instead of relying entirely on intersectionRatio to infer motion, this
approach uses the observer mainly for visibility detection and state
transitions
, while actual movement is detected by comparing bounding box
changes over time
.

This makes motion tracking reliable even when the target moves close to or
along the viewport boundaries.


Key Idea

  • IntersectionObserver is used to:
    • Detect whether the target is inside or outside the viewport
    • Switch between wide and fine capturing windows
  • requestAnimationFrame is used to:
    • Track real movement by comparing getBoundingClientRect() values
    • Confirm when movement has stopped

This removes the dependency on unstable intersection ratios when they approach
zero.


Movement Flow (Text-Only)


START -> STATE: WIDE (Viewport Detection)

Observe using a viewport-sized window

intersectionRatio > 0 -> switch to FINE

intersectionRatio == 0 -> remain in WIDE

STATE: FINE (Boundary Awareness)

Observe using a tightly wrapped window

If bounding box changes:
- Report position
- Enter RAF-based tracking

If intersectionRatio == 0:
- Switch back to WIDE

STATE: RAF TRACKING (True Motion Detection)

Compare boundingClientRect on every frame

If position changes:
- Report movement
- Reset stop timer

If position remains stable for N frames:
- Consider motion stopped
- Switch back to WIDE
Enter fullscreen mode Exit fullscreen mode

Why This Works Better Than Approach 1

In Approach 1, tracking could fail when the intersectionRatio became very small,
especially when the target moved along the viewport boundary instead of directly
out of it.

In this approach:

  • Once movement is detected, RAF takes over
  • Motion is detected using actual geometry, not intersection ratios
  • Boundary conditions no longer cause missed updates

This ensures continuous and reliable tracking even in edge cases.


Limitation

This approach relies on calling getBoundingClientRect() on every animation frame during RAF-based tracking.

Because getBoundingClientRect() is a layout-dependent API, frequent access can increase CPU usage and may trigger layout thrashing if the browser is forced to recalculate layout repeatedly.

This overhead is especially noticeable when monitoring performance using Chrome DevTools (for example, via the Performance panel), where increased layout and scripting activity can be observed during continuous tracking.

So far, this is the primary limitation identified with this approach.


Approach 3 — Intersection-Ratio–Driven Motion Detection


TL;DR:

Movement is detected when the intersection ratio reported by the fine window differs from the ratio previously reported by the viewport window.


Approach 3 takes a different direction compared to the previous approaches.

Instead of using layout reads (getBoundingClientRect()) or per-frame checks, this approach relies entirely on intersection ratio changes to infer motion.

The key idea is that motion is inferred by comparing the intersection ratio reported by the fine window with the intersection ratio previously reported by the viewport (wide) window.

When the target first intersects the viewport, the intersection ratio reported by the viewport-sized observer is stored. After switching to the fine window, motion is assumed to have occurred only if the intersection ratio reported by the fine window differs from that previously recorded viewport intersection ratio.

Readers are encouraged to refer back to the “Determining When the Target Has Moved” table above, which shows how intersection ratios map to boundary contact, full containment, and exit conditions for both wide and fine windows.

The fine window tightly wraps the visible intersection rectangle of the target with a ~1px margin (implemented via rootMargin). Because this window closely tracks the visible portion of the target, even lateral or boundary-parallel movement usually causes the fine-window intersection ratio to diverge from the viewport ratio, triggering motion detection.


Core Idea

  • Start with a wide (viewport-sized) capturing window.
  • When the target becomes visible, switch to a fine window around the visible portion of the target.
  • Store the intersection ratio reported by the wide window.
  • If the fine window later reports a different intersection ratio, motion is assumed to have started.
  • If the ratio remains the same, the element is assumed to be stationary.

This allows motion detection without per-frame geometry checks.


Movement Flow (Text-Only)

START -> STATE: WIDE

Observe with viewport-sized window

Store intersectionRatio as viewportIR

If intersectionRatio > 0 -> switch to FINE

STATE: FINE

Observe with tightly wrapped window

If intersectionRatio !== viewportIR:
-> motion detected
-> switch back to WIDE

Else:
-> assume stationary
Enter fullscreen mode Exit fullscreen mode

Variants

Approach 3 can be implemented in two variants depending on the desired trade-off between smoothness and robustness.


Variant A — Single Observer (Lightweight)

This variant uses a single IntersectionObserver and relies solely on fine-window intersection ratio deltas.

Characteristics

  • Lowest runtime overhead
  • Smoothest behavior during continuous motion
  • No layout reads
  • No secondary observers

Trade-off

  • Rare edge cases (such as missed callbacks during observer reconfiguration) may go undetected

Recommended when

  • Performance and smoothness are critical
  • Best-effort motion detection is acceptable
  • Occasional missed transitions are tolerable

Variant B — Dual Observer (Robust)

This variant introduces a backup viewport observer that watches for intersection ratio changes at all times.

If the primary observer misses a transition, the backup observer forces a reset back to the wide-window detection state.

Characteristics

  • More resilient to missed callbacks
  • Better recovery during observer teardown and reconfiguration
  • Slightly higher CPU and callback overhead
  • Can feel less smooth under heavy performance monitoring

Trade-off

  • Additional observer increases runtime work

Recommended when

  • Reliability is more important than minimal overhead
  • Edge cases must be minimized
  • Public demos or production scenarios require extra safety

Limitations

Even with a tightly constructed fine window, this approach fundamentally relies on intersection area changes.

Motion that preserves the intersection area exactly may not be detected. In practice, these cases are rare and typically insignificant for real-world UI interactions, making this trade-off acceptable for many use cases.


Note:

The demo and source code for Approach 3 use the dual-observer (robust) variant for improved reliability.


Demos & Source Code

Each approach is fully implemented and demonstrated below.

The demos allow you to visually inspect behavior near viewport boundaries and during fine-grained motion.


Approach 1 — IntersectionObserver Only

Demo

https://ajk-essential.github.io/Position-Observer/one-io-only/index.html

Source Code

https://github.com/AJK-Essential/Position-Observer/blob/main/src/one-io-only/position-observer.ts

Summary

Pure IntersectionObserver–based tracking using wide and fine windows.

Susceptible to missed updates when the intersection ratio approaches zero near boundaries.


Approach 2 — IntersectionObserver + RAF Hybrid

Demo

https://ajk-essential.github.io/Position-Observer/one-io-with-continuous-poll-raf/index.html

Source Code

https://github.com/AJK-Essential/Position-Observer/blob/main/src/one-io-with-continuous-poll-raf/position-observer.ts

Summary

Uses IntersectionObserver for state transitions and requestAnimationFrame with
getBoundingClientRect() for reliable motion detection.

Most robust approach, with higher CPU and layout cost during active tracking.

Note:

This implementation uses two requestAnimationFrame loops:
One RAF continuously tracks movement while the element is in motion.
A second RAF confirms that motion has fully stopped by waiting for a stable position over a short duration.
These two loops can be merged into a single RAF in simpler implementations, at the cost of slightly weaker stop detection (for example, increased sensitivity to brief pauses or compositor jitter).


Approach 3 — Intersection-Ratio–Driven Detection

Demo

https://ajk-essential.github.io/Position-Observer/two-io-differing-ir/index.html

Source Code

https://github.com/AJK-Essential/Position-Observer/blob/main/src/two-io-differing-ir/position-observer.ts

Summary

Infers motion by comparing intersection ratios between wide and fine windows.

Avoids layout reads entirely and offers a balance between performance and accuracy.

Note:

The demo and source code for Approach 3 use the dual-observer (robust) variant
to minimize missed transitions during observer reconfiguration.


Choosing the Right Approach

Each approach explores a different trade-off space.

Approach 1

  • Minimal logic
  • Observer-only
  • Can miss updates near boundaries

Approach 2

  • Most reliable
  • Uses real geometry
  • Higher CPU and layout cost

Approach 3

  • Observer-only
  • No layout reads
  • Relies on intersection-area changes
  • Best balance for many real-world cases

If correctness under all conditions is critical, polling layout remains the only
guaranteed solution. If performance and simplicity matter more, observer-based
approaches can be surprisingly effective.


Conclusion

IntersectionObserver is not a position observer — but with careful window
construction, threshold tuning, and state management, it can be used to approximate
position changes surprisingly well.

Each approach in this article demonstrates a different way of working around the
observer’s limitations:

  • By refining intersection windows
  • By detecting ratio deltas instead of absolute values
  • By combining observers with animation-frame polling when necessary

None of these techniques are perfect, and none should be treated as universally correct.
However, they can be extremely useful in cases where continuous layout reads are too
expensive
and best-effort motion detection is acceptable.

As with most performance-sensitive UI problems, the right solution depends on context.


Feel free to explore, test, and modify the code in this GitHub repo:

🔗 https://github.com/AJK-Essential/Position-Observer

If you find improvements or bugs, I’d love to hear about them!

Thanks for reading — hope this was helpful ✨

Top comments (2)

Collapse
 
itihon profile image
itihon

I wanted to thank you for your article. This is an interesting approach. I started to work with your solution a couple of weeks ago, it helped me to figure out how to make "capturing window" dimensions consistent on desktop and mobile screens. The problem of viewport size change can be solved by setting rootMargin in percents relation of target's coordinates to viewport's dimensions. But this way, "capturing window" starts running away from target on viewport resize. Although it triggers position change detection, it creates other possibilities to fail. Also it seems impossible to reliably detect target's resize. So I tried another approach, to use four observers: one for each target's side. In case you want check what I finally ended up with, it's here: Position observer demo.

Collapse
 
ajk-essential profile image
AJK-Essential • Edited

Your demo looks great, and I really like the alternate approach you shared.

Leaving resize handling out was a deliberate choice, so consumers can decide when to disconnect and re-observe on viewport or target size changes and keep things simple.

Thanks again for the thoughtful feedback! 😊