ViewTransition
Let the browser animate between two states of your page: it snapshots what's there, you change it, and it cross-fades or morphs from one to the other - no animation library, and no keyframes to write.
@inject Bit.Butil.ViewTransition viewTransitionMDN reference
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.
@inject Bit.Butil.ViewTransition viewTransition
var supported = await viewTransition.IsSupported();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.
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();
}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.
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>
}
{
private List<Item> _items = [];
private async Task Shuffle() =>
await viewTransition.Start(
updateState: () => _items = [.. _items.OrderBy(_ => Random.Shared.Next())],
render: NextRender);
}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.
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();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.
_swapSub = await viewTransition.OnPageSwap(e =>
{
if (e.HasTransition && e.NavigationType == "traverse") MarkAsBackNavigation();
});
_revealSub = await viewTransition.OnPageReveal(e => { /* on the new document */ });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
ValueTask<bool> IsSupported()ValueTask<ViewTransitionHandle?> Start(Func<Task> updateDom, string[]? types = null)ValueTask<ViewTransitionHandle?> Start(Action updateState, Func<Task> render, string[]? types = null)ValueTask DisposeAsync()Task WaitForReady()Task WaitForFinished()bool WasSkipped { get; }ValueTask Skip()ValueTask<bool> IsCrossDocumentSupported()ValueTask<bool> IsCrossDocumentEnabled()ValueTask<bool> EnableCrossDocument(string[]? types = null)ValueTask DisableCrossDocument()Task<ButilSubscription> OnPageSwap(Action<CrossDocumentTransitionEvent> handler)Task<ButilSubscription> OnPageReveal(Action<CrossDocumentTransitionEvent> handler)