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.
IJSRuntime js
<div @ref="box">...</div>
<button @onclick="Pulse">Animate</button>
{
private ElementReference box;
private async Task Pulse()
{
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.
IJSRuntime js
<div @ref="box">...</div>
{
private ElementReference box;
private AnimationHandle? handle;
private async Task Run(AnimationKeyframes keyframes)
{
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.
IJSRuntime js
<div @ref="box">...</div>
{
private ElementReference box;
private AnimationHandle? handle;
private async Task Control(AnimationKeyframes keyframes)
{
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 entirely
}
}WhenFinished 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.
IJSRuntime js
if (isVisible)
{
<div @ref="panel">...</div>
}
{
private bool isVisible = true;
private ElementReference panel;
private static readonly AnimationKeyframes fadeOut =
[
new() { ["opacity"] = "1" },
new() { ["opacity"] = "0" },
];
private async Task Hide()
{
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;
StateHasChanged();
}
}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.
IJSRuntime js
<div @ref="_bar" style="transform-origin:left; height:3px; background:#1276C6"></div>
{
private ElementReference _bar;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
// A reading-progress bar, driven by the page's own scroll rather than by time - so nothing
// here ticks, and nothing has to be cancelled when the reader stops scrolling.
await _bar.Animate(js,
[new() { ["transform"] = "scaleX(0)" }, new() { ["transform"] = "scaleX(1)" }],
new AnimationOptions
{
Fill = "forwards",
Timeline = new AnimationTimelineOptions { Type = "scroll", Axis = "block" }
});
// ... or 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.
IJSRuntime js
<div @ref="_box">...</div>
{
private ElementReference _box;
private async Task Keep(AnimationKeyframes keyframes)
{
var handle = await _box.Animate(js, keyframes, new AnimationOptions { Fill = "forwards" });
await handle.WhenFinished();
// Writes the animated values into the element's own inline style, so cancelling the
// animation afterwards leaves the element where it ended rather than snapping back.
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.
IJSRuntime js
<div @ref="_box">...</div>
{
private ElementReference _box;
private async Task Inspect()
{
// Everything animating on the element, whoever started it - CSS transitions and animations
// included, not only what this code created.
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()