IntersectionObserver

IntersectionObserver is a Java wrapper around the browser's IntersectionObserver API. It lets you detect when Vaadin components become visible (or hidden) within the viewport or a scrollable container, and react in server-side Java code.

Why use it?

Detecting whether an element is visible on screen is surprisingly hard to do correctly with scroll listeners alone. The browser's IntersectionObserver handles this efficiently and this helper brings it to server-side Java.

Common use cases:

  • Lazy loading – load data or images only when a placeholder scrolls into view.
  • Infinite scroll – append more content when the user reaches the bottom of a list.
  • Visibility tracking – trigger animations or analytics when an element first appears.
  • Resource management – pause expensive operations (video, polling) for off-screen components.

Key features:

  • Observe intersection with the viewport or with a specific scrollable container (custom root).
  • Events are debounced (100 ms by default) – intersection changes are accumulated on the client and sent to the server in a single roundtrip.
  • Proper cleanup on detach – observers are removed automatically when the component leaves the UI.

Getting an instance

For viewport-level observation (is the component visible in the browser window?):

// Using the current UI (most common)
IntersectionObserver io = IntersectionObserver.get();

// Or for a specific UI
IntersectionObserver io = IntersectionObserver.of(myUi);

For observation within a specific scrollable container (e.g. a Scroller):

IntersectionObserver io = IntersectionObserver.of(myUi, myScroller);

The viewport observer is a singleton per UI (like ResizeObserver). Root-specific observers are created per call – keep a reference if you need to add observations later.

Observing a component

Pass a component and a listener that receives an IntersectionEntry:

IntersectionObserver.get().observe(myComponent, entry -> {
    if (entry.isIntersecting()) {
        // Component is now visible
    }
    double ratio = entry.intersectionRatio(); // 0.0 .. 1.0
});

You can chain multiple observe calls:

IntersectionObserver.get()
    .observe(banner, entry -> {
        if (entry.isIntersecting()) {
            trackImpression(banner);
        }
    })
    .observe(lazyImage, entry -> {
        if (entry.isIntersecting()) {
            lazyImage.setSrc(actualImageUrl);
        }
    });

The IntersectionEntry record

Field Type Description
isIntersecting() boolean true when the component overlaps the root
intersectionRatio() double Fraction of the component that is visible (0.0 to 1.0)

Fire-once observation

A common pattern is to react only the first time a component becomes visible, then stop observing it to avoid further server traffic:

var io = IntersectionObserver.get();
io.observe(myComponent, entry -> {
    if (entry.isIntersecting()) {
        doSomething();
        io.unobserve(myComponent);
    }
});

Stopping observation

Pass the same listener reference to remove a specific listener:

IntersectionObserver.IntersectionListener listener = entry -> { /* ... */ };
io.observe(component, listener);

// Later:
io.unobserve(component, listener);

Or stop observing a component entirely (removes all listeners):

io.unobserve(component);

Configuration

Custom root (scrollable container)

By default the viewport is used as the intersection root. To observe visibility within a scrollable container like VScroller, pass it as the root:

VScroller scroller = new VScroller(content);
scroller.setHeight("400px");

var io = IntersectionObserver.of(UI.getCurrent(), scroller);
io.observe(someChild, entry -> {
    // fires when someChild scrolls into/out of the scroller's visible area
});

Thresholds

Control at which visibility ratios the observer fires:

// Fire when the component is 0%, 50%, and 100% visible
io.withThresholds(0, 0.5, 1.0);

The default threshold is 0 – the listener fires as soon as even one pixel is visible (and again when the component is fully hidden).

Root margin

Expand or shrink the root's detection area. This is useful for triggering actions before an element actually scrolls into view (pre-loading):

// Start loading 200px before the element becomes visible
io.withRootMargin("200px");

Debounce timeout

Intersection changes are accumulated on the client side and flushed to the server after a debounce timeout. This prevents flooding the server during rapid scrolling. The default is 100 ms.

io.withDebounceTimeout(250); // 250 ms

Full example: marking rows visible in a Scroller

This example creates a scrollable list of 100 rows. As the user scrolls, each row's text is updated the first time it becomes visible:

@Route
public class ScrollerExample extends VVerticalLayout {

    public ScrollerExample() {
        VerticalLayout content = new VerticalLayout();
        Span[] rows = new Span[100];
        for (int i = 0; i < 100; i++) {
            rows[i] = new Span("Row " + i);
            content.add(rows[i]);
        }

        VScroller scroller = new VScroller(content);
        scroller.setHeight("300px");
        scroller.setWidth("300px");
        add(scroller);

        addAttachListener(e -> {
            var io = IntersectionObserver.of(e.getUI(), scroller);
            for (Span row : rows) {
                io.observe(row, entry -> {
                    if (entry.isIntersecting()) {
                        row.setText(row.getText() + " now visible");
                        io.unobserve(row);
                    }
                });
            }
        });
    }
}

The addAttachListener ensures the UI is available when creating the observer. Each row is unobserved after its first intersection to avoid repeated server visits.