IntersectionObserver
Know when an element enters or leaves the viewport without polling: ObserveIntersection is an extension method on any ElementReference that streams strongly typed entries to a C# callback - the foundation for lazy loading, infinite scroll, scroll-spy navs and view-tracking.
Capture the element reference, then call ObserveIntersection with the IJSRuntime and a handler. The browser reports an initial entry immediately, then again every time the visibility crosses a threshold. Scroll the box below (or the whole page) after starting to see entries arrive.
IJSRuntime js
<div @ref="target">Watch me</div>
{
private ElementReference target;
private ButilSubscription? subscription;
private async Task Start()
{
subscription = await target.ObserveIntersection(js, entries =>
{
foreach (var entry in entries)
{
Console.WriteLine($"visible={entry.IsIntersecting} ratio={entry.IntersectionRatio:F2}");
}
});
}
}By default the observer only fires at the 0 boundary - fully hidden versus at-all visible. Pass an array of thresholds between 0 and 1 to get a callback each time the visible fraction crosses one of them, which is how progress-style reveal effects are built.
IJSRuntime js
<div @ref="target">...</div>
{
private ElementReference target;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await target.ObserveIntersection(js, entries =>
{
var ratio = entries[^1].IntersectionRatio;
// fires at 0%, 25%, 50%, 75% and 100% visibility
}, new IntersectionObserverOptions
{
Thresholds = [0, 0.25, 0.5, 0.75, 1],
});
}
}RootMargin grows or shrinks the intersection root with CSS-style margins before visibility is computed. Negative margins make the observer report the element later (it must be well inside the viewport); positive margins fire earlier - the classic trick for pre-loading images a screen ahead.
IJSRuntime js
<div @ref="target">...</div>
{
private ElementReference target;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await target.ObserveIntersection(js, entries =>
{
// reported once the target is 80px inside the viewport
}, new IntersectionObserverOptions
{
RootMargin = "-80px 0px -80px 0px",
});
}
}A scroll through a long list crosses thresholds on nearly every frame, and each crossing is an interop round trip. MinInterval caps how often the handler is called. The gate is leading-edge with a trailing send, so the batch that leaves the element in its settled state is always delivered: a lazy-loading tracker never misses the 'now visible' it is waiting for, it only hears it a little later. Observe below and scroll the target in and out.
IJSRuntime js
<div @ref="target">...</div>
{
private ElementReference target;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await target.ObserveIntersection(js, entries =>
{
// called at most once every 200 ms, and always once more
// after the scroll settles
}, new IntersectionObserverOptions
{
Thresholds = [0, 0.25, 0.5, 0.75, 1],
MinInterval = TimeSpan.FromMilliseconds(200),
});
}
}Each callback receives the latest batch of entries. Beyond IsIntersecting and IntersectionRatio, every entry carries the target's bounding rect, the intersection rect, the root bounds and a high-resolution timestamp.
IJSRuntime js
<div @ref="target">...</div>
{
private ElementReference target;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
await target.ObserveIntersection(js, entries =>
{
var entry = entries[^1];
var visible = entry.IsIntersecting; // bool
var ratio = entry.IntersectionRatio; // 0..1
var when = entry.Time; // ms, DOMHighResTimeStamp
Rect? bounds = entry.BoundingClientRect; // target geometry
Rect? overlap = entry.IntersectionRect; // visible part
Rect? root = entry.RootBounds; // root geometry
});
}
}ObserveIntersection returns a ButilSubscription. Disposing it unobserves the element and releases the interop reference - do it when the user navigates away at the latest, as this page does in DisposeAsync.
IAsyncDisposable
private ButilSubscription? subscription;
public async ValueTask DisposeAsync()
{
if (subscription is not null)
{
await subscription.DisposeAsync();
}
}root option, so visibility is measured against the browser
viewport. That still accounts for clipping by scrollable ancestors - a target hidden inside a scrolled
container reports as not intersecting - which covers the vast majority of lazy-loading scenarios.
API reference
Task<ButilSubscription> ObserveIntersection(IJSRuntime js, Action<IntersectionObserverEntry[]> handler, IntersectionObserverOptions? options = null)string? RootMargin { get; set; }double[]? Thresholds { get; set; }TimeSpan? MinInterval { get; set; }bool IsIntersecting { get; set; }double IntersectionRatio { get; set; }double Time { get; set; }Rect? BoundingClientRect { get; set; }Rect? IntersectionRect { get; set; }Rect? RootBounds { get; set; }Guid Id { get; }ValueTask DisposeAsync()