loading
Note:
Why Blazor's own @onpointermove isn't enough A user agent may coalesce pointer samples before dispatching them - how many pointermove events a pen's stream turns into is up to the implementation, and it is typically far fewer than the pen produced. Blazor's PointerEventArgs has no way back to the DOM event, so the coalesced list cannot be reached from it - this service attaches its own listener instead.
Warning:
Chatty by design Every frame crosses interop, which under Blazor Server is a message per frame over the circuit. Track the smallest element you can, and detach as soon as the gesture is over.

Support check

IsSupported / SupportsPrediction

IsSupported reports getCoalescedEvents; where it is false tracking still works, each frame just carries a single sample - so it is a fidelity check, not a gate. Prediction is Chromium only.

C#
@inject Bit.Butil.PointerTracker pointerTracker

var coalesced = await pointerTracker.IsSupported();
var predicted = await pointerTracker.SupportsPrediction();
Live sample
support check output
Results will appear here when you interact with the samples.

Track a surface at full sample rate

Track

Drag across the pad below. 'Samples this frame' is how many positions the browser had merged into the one event it delivered - draw through all of them and a fast stroke stays a curve instead of becoming a polyline. Dispose the returned subscription when the gesture is over.

Razor
@implements IAsyncDisposable
@inject Bit.Butil.PointerTracker pointerTracker

<div @ref="_surface" style="touch-action:none">...</div>

@code {
    private ElementReference _surface;
    private ButilSubscription? _sub;
    private readonly List<(double X, double Y)> _path = [];

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender is false) return;

        _sub = await pointerTracker.Track(_surface, frame =>
        {
            // Coalesced carries every sample the device produced between frames, not only the one
            // the browser dispatched - which is the difference between a smooth line and a polygon.
            foreach (var sample in frame.Coalesced)
            {
                _path.Add((sample.X, sample.Y));
            }
            InvokeAsync(StateHasChanged);
        });
    }

    public async ValueTask DisposeAsync()
    {
        if (_sub is not null) await _sub.DisposeAsync();
    }
}
Live sample
Drag here
Last frame - · 0 coalesced · 0 predicted · pressure 0.00
Totals 0 frames · 0 samples - the gap between these two is what an ordinary pointermove handler throws away.
tracking output
Results will appear here when you interact with the samples.

Drawing ahead with predicted events

PointerFrame.Predicted

Predicted samples are guesses, and they are wrong often enough to matter. The usual use is a provisional 'ink ahead' segment that is thrown away and redrawn on the next real frame - it removes perceived latency without ever committing a predicted point to the document.

C#
// committed ink
foreach (var sample in frame.Coalesced) CommitPoint(sample);

// provisional ink, discarded next frame
ClearProvisional();
foreach (var sample in frame.Predicted) DrawProvisional(sample);

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime implements getCoalescedEvents.
SupportsPrediction
ValueTask<bool> SupportsPrediction()
True when the runtime implements getPredictedEvents - Chromium only.
Track
Task<ButilSubscription> Track(ElementReference element, Action<PointerFrame> handler, bool includePredicted = false, params string[] events)
Watches an element's pointer input at full sample rate. Defaults to pointermove alone.
DisposeAsync
ValueTask DisposeAsync()
Detaches every tracker registered through this instance and releases its interop reference.
An unhandled error has occurred. Reload 🗙