loading

Observe an element

ObserveIntersection

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.

Razor
@inject IJSRuntime js

<div @ref="target">Watch me</div>

@code {
    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}");
            }
        });
    }
}
Live sample
Scroll down inside this box...
observed target
...and back up again.
observe output
Results will appear here when you interact with the samples.

Thresholds

IntersectionObserverOptions.Thresholds

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.

Razor
@inject IJSRuntime js

<div @ref="target">...</div>

@code {
    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],
        });
    }
}
Live sample
Scroll down inside this box...
observed target
...and back up again.
thresholds output
Results will appear here when you interact with the samples.

Root margin

IntersectionObserverOptions.RootMargin

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.

Razor
@inject IJSRuntime js

<div @ref="target">...</div>

@code {
    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",
        });
    }
}
Live sample
Scroll down inside this box...
observed target
...and back up again.
root margin output
Results will appear here when you interact with the samples.

Rate limiting

IntersectionObserverOptions.MinInterval

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.

Razor
@inject IJSRuntime js

<div @ref="target">...</div>

@code {
    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),
        });
    }
}
Live sample
Scroll down inside this box...
observed target
...and back up again.
rate limiting output
Results will appear here when you interact with the samples.

Reading entries

IntersectionObserverEntry

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.

Razor
@inject IJSRuntime js

<div @ref="target">...</div>

@code {
    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
        });
    }
}
Live sample
Scroll down inside this box...
observed target
...and back up again.
entries output
Results will appear here when you interact with the samples.

Stopping

ButilSubscription

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.

Razor
@implements IAsyncDisposable

private ButilSubscription? subscription;

public async ValueTask DisposeAsync()
{
    if (subscription is not null)
    {
        await subscription.DisposeAsync();
    }
}
Live sample
stopping output
Results will appear here when you interact with the samples.
Note:
The root is always the viewport This wrapper does not expose the 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

Member
Signature
Description
ObserveIntersection
Task<ButilSubscription> ObserveIntersection(IJSRuntime js, Action<IntersectionObserverEntry[]> handler, IntersectionObserverOptions? options = null)
Extension method on ElementReference. Starts observing and returns a disposable subscription.
IntersectionObserverOptions.RootMargin
string? RootMargin { get; set; }
CSS-style margin around the root, e.g. "0px 0px -50px 0px".
IntersectionObserverOptions.Thresholds
double[]? Thresholds { get; set; }
One or more thresholds in [0, 1]. Defaults to a single 0 threshold.
IntersectionObserverOptions.MinInterval
TimeSpan? MinInterval { get; set; }
The shortest time between two calls into the handler. null or zero (the default) forwards every batch. Applied in JavaScript, leading-edge with a trailing send.
IntersectionObserverEntry.IsIntersecting
bool IsIntersecting { get; set; }
True when the target intersects the root with at least one threshold.
IntersectionObserverEntry.IntersectionRatio
double IntersectionRatio { get; set; }
Fraction of the target's bounding rect that intersects the root, in [0, 1].
IntersectionObserverEntry.Time
double Time { get; set; }
When the intersection was detected (DOMHighResTimeStamp, ms).
IntersectionObserverEntry.BoundingClientRect
Rect? BoundingClientRect { get; set; }
The target's bounding rectangle.
IntersectionObserverEntry.IntersectionRect
Rect? IntersectionRect { get; set; }
The visible portion of the target.
IntersectionObserverEntry.RootBounds
Rect? RootBounds { get; set; }
The root's bounding rectangle.
ButilSubscription.Id
Guid Id { get; }
The internal listener id of the subscription.
ButilSubscription.DisposeAsync
ValueTask DisposeAsync()
Stops observing and releases the interop reference. Idempotent.
An unhandled error has occurred. Reload 🗙