loading
Note:
Why not a keydown handler A keydown listener knows about Escape and nothing else - not the Android back gesture, not the platform's own close affordance. It also has no idea which of two nested dialogs should close. The browser keeps a close stack: the innermost watcher closes first, and one gesture closes exactly one thing.
Warning:
User activation, and Chromium only A page may keep one "free" watcher at a time; creating a second while the first is active spends a user activation, and without one the browser groups them so a single Escape closes both. Create watchers in response to the interaction that opened the thing. Where IsSupported is false, Create returns null - keep your own key handler as the fallback.

Support check

IsSupported

True when the runtime exposes CloseWatcher. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.

C#
@inject Bit.Butil.CloseWatcher closeWatcher

var supported = await closeWatcher.IsSupported();
Live sample
support check output
Results will appear here when you interact with the samples.

Close a panel the native way

Create

Open the panel below, then press Escape - or use the Android back gesture on a phone. The handler runs, the panel closes, and the gesture is consumed so nothing else reacts to it.

Razor
@implements IAsyncDisposable
@inject Bit.Butil.CloseWatcher closeWatcher

@if (_open)
{
    <div>...</div>
}

@code {
    private bool _open;
    private CloseWatcherHandle? _watcher;

    // Created when the panel opens, so Escape, the Android back gesture and the browser's own close
    // affordance all reach the same handler.
    private async Task Open()
    {
        _open = true;

        _watcher = await closeWatcher.Create(onClose: () =>
        {
            _open = false;
            InvokeAsync(StateHasChanged);
        });
    }

    // Closing by any other route - a button of your own - has to retire the watcher too, or the
    // next Escape closes a panel that is already gone.
    public async ValueTask DisposeAsync()
    {
        if (_watcher is not null) await _watcher.DisposeAsync();
    }
}
Live sample
panel output
Results will appear here when you interact with the samples.

Ask before closing

Create(onClose, onCancel)

Passing an onCancel handler intercepts the close request first, so you can ask 'discard your changes?' and call Close() yourself if the answer is yes. The browser only offers the cancel step while there is a user activation to spend, so a close can still arrive without one - never rely on it firing.

Razor
@inject Bit.Butil.CloseWatcher closeWatcher

@code {
    private bool _open;
    private bool _confirming;
    private CloseWatcherHandle? _watcher;

    private async Task Open()
    {
        _open = true;

        // onCancel runs first and can stop the close; onClose is what actually closes. A cancel
        // needs a prior user activation, so the very first close gesture cannot be intercepted.
        _watcher = await closeWatcher.Create(
            onClose: () => { _open = false; InvokeAsync(StateHasChanged); },
            onCancel: () => { _confirming = true; InvokeAsync(StateHasChanged); });
    }

    // the user confirmed:
    private async Task Confirm() => await _watcher!.Close();
}
Live sample
guarded panel output
Results will appear here when you interact with the samples.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes CloseWatcher.
Create
ValueTask<CloseWatcherHandle?> Create(Action onClose, Action? onCancel = null)
Starts watching for a close request. Null when the runtime has no CloseWatcher or refused to create another one.
Handle.RequestClose
ValueTask RequestClose()
Asks to close, exactly as an Escape press would - the cancel handler runs first and may keep it open.
Handle.Close
ValueTask Close()
Closes without asking - skips the cancel handler and fires the close handler.
Handle.DisposeAsync
ValueTask DisposeAsync()
Deactivates the watcher without firing the close handler. Always dispose - an active watcher eats the next Escape.
DisposeAsync
ValueTask DisposeAsync()
Destroys every watcher created through this instance and releases its interop reference.
An unhandled error has occurred. Reload 🗙