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.
