Animation
Drive the Web Animations API from C#. Animate any ElementReference with a list of keyframes, then play, pause, reverse, seek and cancel the running animation through a handle - the browser's compositor does the work, so the frames never cross the interop boundary.
Animate hangs off
ElementReference itself and takes an IJSRuntime so it can reach the browser.
Capture the element with @ref and animate it from OnAfterRenderAsync or an
event handler.
Animate takes the keyframes to interpolate between and, optionally, the timing options. It returns an AnimationHandle immediately - the animation runs on the browser's own timeline from that moment, with no per-frame interop. Each keyframe is a dictionary of CSS property to value, exactly like the array you would pass to element.animate() in JavaScript.
<div @ref="box">...</div>
{
private ElementReference box;
private async Task Run()
{
await box.Animate(js,
[
new() { ["transform"] = "translateX(0)", ["opacity"] = "1" },
new() { ["transform"] = "translateX(180px)", ["opacity"] = "0.4" },
new() { ["transform"] = "translateX(0)", ["opacity"] = "1" },
],
new AnimationOptions { Duration = 1200, Easing = "ease-in-out" });
}
}AnimationKeyframes is a List of Dictionary<string, string>, so collection expressions and dictionary initializers build one inline. Every property must be spelled the way CSS spells it - kebab-case, not the camelCase form the JavaScript object syntax uses - and every keyframe should list the same properties so the engine has something to interpolate towards. Two keyframes mean 'from' and 'to'; more are distributed evenly across the duration.
AnimationKeyframes pulse =
[
new() { ["transform"] = "scale(1)", ["background-color"] = "#1276C6" },
new() { ["transform"] = "scale(1.35)", ["background-color"] = "#FD7F36" },
new() { ["transform"] = "scale(1)", ["background-color"] = "#1276C6" },
];
await box.Animate(js, pulse, new AnimationOptions { Duration = 900 });AnimationOptions forwards the subset of KeyframeEffectOptions that survives the interop hop: Duration, Delay, EndDelay, Iterations, Easing, Direction, Fill and Composite. Set Iterations to double.PositiveInfinity to loop forever - Butil normalizes it on the JavaScript side, which JSON alone cannot express. Change the values below and re-run to feel the difference.
var options = new AnimationOptions
{
Duration = 1200, // ms
Delay = 0, // ms before the first frame
EndDelay = 0, // ms held after the last frame
Iterations = double.PositiveInfinity, // loop forever
Easing = "cubic-bezier(0.33, 0, 0.67, 1)",
Direction = "alternate", // normal | reverse | alternate | alternate-reverse
Fill = "forwards", // none | forwards | backwards | both | auto
Composite = "replace", // replace | add | accumulate
};
handle = await box.Animate(js, keyframes, options);The returned AnimationHandle is a remote control for the running animation. Play and Pause stop and resume it in place, Reverse flips the direction from the current time, SetPlaybackRate speeds it up or slows it down (negative values run it backwards), Finish jumps to the end and Cancel removes it - snapping the element back to its unanimated styles. Start the looping animation below, then drive it.
handle = await box.Animate(js, keyframes,
new AnimationOptions { Duration = 2000, Iterations = double.PositiveInfinity });
await handle.Pause();
await handle.SetPlaybackRate(0.25); // quarter speed
await handle.Play();
await handle.Reverse();
await handle.Finish(); // jump to the end
await handle.Cancel(); // remove the animation entirelyWhenFinished awaits the animation's own finished promise, which is how you sequence work after an animation without guessing at a Task.Delay. It completes when the animation finishes or is cancelled, so it is safe to await in a teardown path - but an animation with infinite Iterations never finishes, and awaiting one would hang forever.
var handle = await panel.Animate(js, fadeOut,
new AnimationOptions { Duration = 300, Fill = "forwards" });
await handle.WhenFinished();
// the element is now visually gone - safe to drop it from the render tree
isVisible = false;Every handle holds an entry in a JavaScript-side registry so the control methods can find the animation again. Disposing cancels the animation and drops that entry; without it, a Fill of forwards or both leaves the element pinned at its final frame and the registry entry alive for the lifetime of the page. Dispose is idempotent and swallows the usual teardown exceptions, so calling it from DisposeAsync during a circuit shutdown is safe.
IAsyncDisposable
{
private AnimationHandle? handle;
public async ValueTask DisposeAsync()
{
if (handle is not null)
{
await handle.DisposeAsync();
}
}
}Passing a Timeline replaces the clock with a scroll position: a ScrollTimeline follows a scroller, a ViewTimeline follows an element's passage through the scrollport. Duration, Delay and EndDelay stop meaning anything and are not sent - RangeStart and RangeEnd say which part of the scroll the animation occupies, in the CSS syntax of the animation-range property.
// a reading-progress bar, driven by the page's own scroll
await _bar.Animate(js,
[new() { ["transform"] = "scaleX(0)" }, new() { ["transform"] = "scaleX(1)" }],
new AnimationOptions
{
Fill = "forwards",
Timeline = new AnimationTimelineOptions { Type = "scroll", Axis = "block" }
});
// fade an element in as it comes into view
new AnimationTimelineOptions { Type = "view", RangeStart = "entry 0%", RangeEnd = "entry 100%" }A filling animation keeps the element under the animation's control and beats every later style change; the browser also discards one once another supersedes it, taking its visual effect with it. CommitStyles writes the current computed values into the inline style so the state survives without the animation - the usual pairing is WhenFinished, CommitStyles, Cancel. Persist is the blunter alternative, and keeps the fight going.
var handle = await _box.Animate(js, keyframes, new AnimationOptions { Fill = "forwards" });
await handle.WhenFinished();
await handle.CommitStyles();
await handle.Cancel();Unlike an AnimationHandle, which only knows about animations Butil started, this reports CSS animations and transitions too - which is how to answer 'is anything still moving here' before measuring, snapshotting or tearing an element down. CancelAnimations is the blunt companion: a CSS animation has no handle to cancel it through.
var running = await _box.GetAnimations(js, subtree: true);
if (running.Any(a => a.PlayState == "running")) await WaitForIt();
var cancelled = await _box.CancelAnimations(js, subtree: true);Animate is a single interop call that hands the keyframes to the browser; every frame after
that is computed by the compositor. That makes it dramatically cheaper than driving a style property from
a .NET timer, and it is why animations stay smooth on Blazor Server, where every render round-trips over
a circuit.
(prefers-reduced-motion: reduce) matches.
API reference
Task<AnimationHandle> Animate(this ElementReference element, IJSRuntime js, AnimationKeyframes keyframes, AnimationOptions? options = null, ElementReference? timelineSource = null)class AnimationKeyframes : List<Dictionary<string, string>>double Duration = 1000double Delay = 0double EndDelay = 0double Iterations = 1string Easing = "linear"string Direction = "normal"string Fill = "none"string Composite = "replace"ValueTask Play()ValueTask Pause()ValueTask Reverse()ValueTask Cancel()ValueTask Finish()ValueTask WhenFinished()AnimationTimelineOptions? Timeline = nullstring Type = "scroll"string Axis = "block"string RangeStart / RangeEndValueTask<bool> IsTimelineSupported(this ElementReference element, IJSRuntime js)ValueTask<AnimationInfo[]> GetAnimations(this ElementReference element, IJSRuntime js, bool subtree = false)ValueTask<int> CancelAnimations(this ElementReference element, IJSRuntime js, bool subtree = false)ValueTask SetPlaybackRate(double rate)ValueTask<bool> CommitStyles()ValueTask<bool> Persist()ValueTask DisposeAsync()