loading
Warning:
The one thing to get right The browser snapshots the page before your update callback and again when that callback's task completes. In Blazor the DOM changes on a render, not on an assignment - so a callback that just sets a field and returns produces two identical snapshots and no animation. Await the render. The pattern below does that reliably.

Support check

IsSupported

Treat view transitions as a progressive enhancement. When this is false, make the same change without one - the page still works, it just doesn't animate. Start returns null in that case and deliberately does not run your callback, so 'did the update happen' stays a question with one answer.

C#
@inject Bit.Butil.ViewTransition viewTransition

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

Awaiting the render

Start

A TaskCompletionSource completed from OnAfterRenderAsync is the reliable way to say 'the DOM has caught up'. Set it up before requesting the render, hand the transition a callback that awaits it, and the browser's second snapshot lands after Blazor has actually painted the new state.

C#
private TaskCompletionSource? _rendered;

protected override void OnAfterRender(bool firstRender)
{
    // Whoever is waiting for a render gets released here, and only here.
    _rendered?.TrySetResult();
}

private Task NextRender()
{
    _rendered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
    StateHasChanged();
    return _rendered.Task;
}

private async Task Toggle()
{
    var handle = await viewTransition.Start(
        updateState: () => _expanded = !_expanded,
        render: NextRender);

    if (handle is null)
    {
        // no view transitions here - same change, no animation
        _expanded = !_expanded;
        return;
    }

    await handle.WaitForFinished();
}
Live sample
One
Two
Three
transition output
Results will appear here when you interact with the samples.

Reordering a list

Start / view-transition-name

Give each item a stable view-transition-name and the browser animates every one of them from its old position to its new one - the effect that normally needs a FLIP implementation. Nothing here computes a position; the whole animation is the browser's.

Razor
@inject Bit.Butil.ViewTransition viewTransition

@* Each row carries its own name, stable across the reorder - that is what lets the browser match
   the before and after and animate the row from one position to the other. Two elements with the
   same name at the same time abort the transition. *@
@foreach (var item in _items)
{
    <div style="view-transition-name:[email protected]">@item.Title</div>
}

@code {
    private List<Item> _items = [];

    private async Task Shuffle() =>
        await viewTransition.Start(
            updateState: () => _items = [.. _items.OrderBy(_ => Random.Shared.Next())],
            render: NextRender);
}
Live sample
Alpha
Bravo
Charlie
Delta
Echo
list output
Results will appear here when you interact with the samples.

Waiting and skipping

WaitForReady / WaitForFinished / Skip / WasSkipped

Awaiting is optional - the animation runs whether or not anyone watches. WaitForReady fires when the pseudo-element tree exists and the animation is about to run, which is the moment to start anything that has to move in lockstep. Skip jumps to the end state: the DOM update still applies and WaitForFinished still completes, so it is the right move when the user acts again before the last transition finished.

C#
var handle = await viewTransition.Start(updateState: Change, render: NextRender);
if (handle is null) return;

await handle.WaitForReady();      // pseudo-elements exist, animation about to run
await handle.WaitForFinished();   // page has settled

if (handle.WasSkipped)
{
    // the browser (or Skip) dropped the animation - the DOM still updated
}

// cut it short:
await handle.Skip();
Live sample
phase output
Results will appear here when you interact with the samples.

Across a navigation

IsCrossDocumentSupported / EnableCrossDocument / DisableCrossDocument

View Transitions level 2 animates across a real navigation, between two documents. There is no scripted switch for it - the whole opt-in is the @view-transition { navigation: auto; } at-rule, which EnableCrossDocument installs as a stylesheet. Both documents have to opt in, and the navigation has to be same-origin.

C#
// once, at start-up
await viewTransition.EnableCrossDocument();

// with types the arriving document's CSS can select on
await viewTransition.EnableCrossDocument(["forward"]);
Live sample
Note:
Blazor's router doesn't navigate An in-app route change never leaves the document, so no cross-document transition can happen for it - that is what Start above is for. This applies to navigations that really do load a new document.
cross-document output
Results will appear here when you interact with the samples.

The two hook points

OnPageSwap / OnPageReveal

pageswap fires on the outgoing document just before it is snapshotted - the last chance to set up what the transition animates away from. pagereveal fires on the incoming one just before its first paint, where the arriving page decides how the transition should look, or skips it. NavigationType is what tells a back navigation from a forward one, so the animation can go the right way.

C#
_swapSub = await viewTransition.OnPageSwap(e =>
{
    if (e.HasTransition && e.NavigationType == "traverse") MarkAsBackNavigation();
});

_revealSub = await viewTransition.OnPageReveal(e => { /* on the new document */ });
Live sample
pageswap / pagereveal output
Results will appear here when you interact with the samples.
Note:
The animation itself is CSS Butil starts and controls the transition; which elements morph into which is decided by giving them a matching view-transition-name, and the look is styled through the ::view-transition-* pseudo-elements. The default cross-fade needs no CSS at all - everything above uses inline view-transition-name values and nothing else.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes document.startViewTransition. Returns default (false) during prerender/SSR instead of throwing.
Start
ValueTask<ViewTransitionHandle?> Start(Func<Task> updateDom, string[]? types = null)
Runs the update inside a transition. The callback must not return until the DOM has actually updated. Null when unsupported - and then the callback is NOT run.
Start
ValueTask<ViewTransitionHandle?> Start(Action updateState, Func<Task> render, string[]? types = null)
The same, with the two halves named, so it is obvious that the render is the part that has to be awaited.
DisposeAsync
ValueTask DisposeAsync()
On scope/circuit teardown, skips any transition still running.
ViewTransitionHandle.WaitForReady
Task WaitForReady()
Completes when the pseudo-element tree exists and the animation is about to run. Also completes on a skip.
ViewTransitionHandle.WaitForFinished
Task WaitForFinished()
Completes when the animation has ended and the page is in its new state.
ViewTransitionHandle.WasSkipped
bool WasSkipped { get; }
True when the animation was dropped - by Skip, or by the browser when the update took too long or the page was hidden. The DOM update still applied.
ViewTransitionHandle.Skip
ValueTask Skip()
Jumps to the end state immediately, dropping the animation.
IsCrossDocumentSupported
ValueTask<bool> IsCrossDocumentSupported()
True when the runtime implements cross-document transitions - the @view-transition opt-in plus pageswap/pagereveal. Independent of IsSupported.
IsCrossDocumentEnabled
ValueTask<bool> IsCrossDocumentEnabled()
True when this document has been opted in through EnableCrossDocument.
EnableCrossDocument
ValueTask<bool> EnableCrossDocument(string[]? types = null)
Opts this document into animating across same-origin navigations, by installing the @view-transition rule.
DisableCrossDocument
ValueTask DisableCrossDocument()
Removes the opt-in, so navigations stop animating.
OnPageSwap
Task<ButilSubscription> OnPageSwap(Action<CrossDocumentTransitionEvent> handler)
Fires on the outgoing document just before the browser snapshots it. Keep the handler synchronous - the document is on its way out.
OnPageReveal
Task<ButilSubscription> OnPageReveal(Action<CrossDocumentTransitionEvent> handler)
Fires on the incoming document just before its first paint.
An unhandled error has occurred. Reload 🗙