Orchestration
Coordinating motion across elements and time: reveal on scroll, stagger a group, compose an imperative timeline, slide a selection marker, and cross-fade same-document navigations. Every curve is still a C#-solved spring baked to linear(); JS only executes.
In view
Reveal an element the first time it scrolls into view. The CSS tier does the animating: each .motion-in-view-* class starts the element hidden and transitions it in when [data-in-view] is set. An IntersectionObserver from CreateInViewAsync sets that attribute, so the reveal itself is zero per-frame JS. Splat the class with Motion.InView, then attach the observer.
@using Navius.Motion
@inject IJSRuntime JS
@* CSS tier: the reveal class on each element. *@
<div @ref="_group" class="grid grid-cols-3 gap-3">
@foreach (var card in cards)
{
<div @attributes="Motion.InView(Preset.FadeUp)">@card</div>
}
</div>
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
var interop = new MotionJsInterop(JS);
// One observer reveals the group and staggers the children.
_reveal = await interop.CreateInViewAsync(
_group, new InViewOptions(Amount: 0, Once: true,
Stagger: StaggerOptions.Of(90, StaggerFrom.First)));
}
}There is one .motion-in-view-* class per preset (fade, fade-up, zoom, pop, the four slides), matching the presence set. Passing a Stagger to the options makes the same intersection fan the attribute out to the direct children, with each child's delay set up front, so a whole group reveals in sequence off one observer.
Motion.InView
Splat helper (CSS tier): the .motion-in-view-<preset> class plus an optional delay var. Attach the observer separately.
| Prop | Type | Default |
|---|---|---|
| Motion.InView(Preset preset, double delayMs = 0) | IReadOnlyDictionary<string, object> | - |
MotionJsInterop
Sets data-in-view while the element intersects. The callback overload also reports coarse enter/leave edges via OnInView(bool).
| Prop | Type | Default |
|---|---|---|
| CreateInViewAsync(ElementReference element, InViewOptions options) | Task<InViewMotion> | - |
| CreateInViewAsync<T>(ElementReference element, InViewOptions options, DotNetObjectReference<T> callback) | Task<InViewMotion> | - |
InViewOptions
Serialized to camelCase. Amount is the visibility threshold (0 = any pixel, 1 = fully visible); Once keeps data-in-view after the first entry; a non-null Stagger fans the reveal out to the children.
| Prop | Type | Default |
|---|---|---|
| Amount | double | 0 |
| Margin | string | "0px" |
| Once | bool | false |
| Stagger | StaggerOptions? | null |
InViewMotion
The live observer. RefreshAsync re-reads the child list after a staggered group's children change (a no-op without a stagger); dispose to disconnect.
| Prop | Type | Default |
|---|---|---|
| RefreshAsync() | Task | - |
| DisposeAsync() | ValueTask | - |
Stagger
A stagger is a per-child delay schedule written into --navius-motion-delay, which the enter and in-view classes already read. MotionStagger.Delays is the authoring surface (index-aligned delays in milliseconds); the same distance-from-anchor formula is duplicated in navius-motion.js and kept in lock-step by a test. Use CreateStaggerAsync to apply the vars to a group revealed by other means, or the Stagger option above when the in-view observer drives the reveal.
The From anchor sets who moves first. Center uses fractional distance for an even count (Motion's stagger semantics), so delays can be half-steps.
// Authoring: the delay each child would take, index-aligned.
double[] delays = MotionStagger.Delays(count: 5, stepMs: 60, from: StaggerFrom.Center);
// -> [120, 60, 0, 60, 120] (center moves first)
// Runtime: apply the vars to a group revealed by other means.
var stagger = await interop.CreateStaggerAsync(
_container, StaggerOptions.Of(60, StaggerFrom.Center));
// after the children change:
await stagger.RefreshAsync();MotionStagger
The authoring/spec surface for the delay schedule.
| Prop | Type | Default |
|---|---|---|
| MotionStagger.Delays(int count, double stepMs, StaggerFrom from = StaggerFrom.First) | double[] | - |
| StaggerFrom.ToToken() | string | - |
StaggerFrom
The anchor a stagger fans out from.
| Prop | Type | Default |
|---|---|---|
| StaggerFrom.First | - | first child moves first |
| StaggerFrom.Last | - | last child moves first |
| StaggerFrom.Center | - | centre moves first (half-steps for an even count) |
StaggerOptions
Serialized to camelCase. Build from a typed anchor with StaggerOptions.Of; pass to CreateStaggerAsync or as InViewOptions.Stagger.
| Prop | Type | Default |
|---|---|---|
| Step | double | 50 |
| From | string | "first" |
| StaggerOptions.Of(double step, StaggerFrom from) | StaggerOptions | - |
MotionJsInterop
Set the per-child delay vars from a schedule, with no in-view coupling. RefreshAsync re-reads the child list.
| Prop | Type | Default |
|---|---|---|
| CreateStaggerAsync(ElementReference container, StaggerOptions options) | Task<StaggerMotion> | - |
| StaggerMotion.RefreshAsync() | Task | - |
Sequences
MotionSequence is the imperative plane: a fluent builder that composes a multi-element timeline in C# and compiles it to a serialized program the executor plays. Springs bake to linear() at Build time and every segment's at offset resolves to an absolute start time, so the runtime just plays one WAAPI animation per segment on a shared clock. That makes Seek a single scrub with no per-frame loop.
@inject IJSRuntime JS
MotionFrame[] rise =
{
new(Opacity: 0, Transform: "translateY(40px)"),
new(Opacity: 1, Transform: "translateY(0px)"),
};
// Four bars, each overlapping the previous by 100ms, on a snappy spring.
var program = new MotionSequence()
.To("[data-seq='1']", rise, Spring.Snappy)
.To("[data-seq='2']", rise, Spring.Snappy, at: "-100")
.To("[data-seq='3']", rise, Spring.Snappy, at: "-100")
.To("[data-seq='4']", rise, Spring.Snappy, at: "-100")
.Build();
// Scope selectors to _root so several sequences never collide.
_sequence = await interop.RunSequenceAsync(program, _root);
await _sequence.PlayAsync();
await _sequence.SeekAsync(_sequence.DurationMs); // scrub the shared clockA segment's at is resolved against a running cursor (the end of the previous segment):
The at grammar
| Prop | Type | Default |
|---|---|---|
| null | - | plays right after the previous segment |
| "+120" | - | starts 120ms after the previous segment |
| "-80" | - | overlaps the previous segment by 80ms |
| "<" | - | starts together with the previous segment |
| "400" | - | a bare number is an absolute start time |
| "label" | - | starts at a Label's marked time |
Play resumes from the current position, and segments that already finished hold on their final frame rather than replaying (they are filled both ways). A plain resume would otherwise re-run the finished segments while the rest continue and desync the timeline. To replay from the top, call Stop (which rewinds every segment) and then Play.
Own the limits honestly: no per-frame or onUpdate callbacks, no nested sequences, no infinite iterations, and one segment targets one element (a selector's first match, scoped to the run root, or an @ref).
MotionSequence
The builder. To adds a segment; Label marks the cursor for a later at; Build compiles to a MotionProgram.
| Prop | Type | Default |
|---|---|---|
| To(string selector, MotionFrame[] keyframes, Spring spring, string? at = null) | MotionSequence | - |
| To(string selector, MotionFrame[] keyframes, double durationMs, string easing = "linear", string? at = null) | MotionSequence | - |
| To(ElementReference element, MotionFrame[] keyframes, Spring spring, string? at = null) | MotionSequence | - |
| To(ElementReference element, MotionFrame[] keyframes, double durationMs, string easing = "linear", string? at = null) | MotionSequence | - |
| Label(string name) | MotionSequence | - |
| Build() | MotionProgram | - |
| ReduceMotion | string | "user" |
MotionJsInterop
Play a compiled program. The root overload scopes selector targets to one element so several sequences never collide.
| Prop | Type | Default |
|---|---|---|
| RunSequenceAsync(MotionProgram program) | Task<MotionSequenceHandle> | - |
| RunSequenceAsync(MotionProgram program, ElementReference root) | Task<MotionSequenceHandle> | - |
MotionSequenceHandle
Drives the whole timeline. DurationMs is the resolved total length; dispose to cancel every segment.
| Prop | Type | Default |
|---|---|---|
| DurationMs | double | - |
| PlayAsync() | Task | - |
| PauseAsync() | Task | - |
| SeekAsync(double ms) | Task | - |
| StopAsync() | Task | - |
| WaitForFinishAsync() | Task | - |
| DisposeAsync() | ValueTask | - |
Selection indicator
NaviusSelectionIndicator is a standalone spring-animated marker (the Tabs and NavigationMenu indicator math, generalized). Place it inside the container it should track, give that container position: relative, pass the container's @ref, and change the Active token whenever the active element changes. The marker slides to the element matching ActiveSelector: position rides a compositor transform and its size animates, so a pill or underline tracks items of differing size. It is resize-aware.
<div @ref="_row" class="relative inline-flex gap-1">
<NaviusSelectionIndicator Container="_row" Active="_selected" Spring="Spring.Snappy"
class="pointer-events-none rounded-lg bg-primary" />
@for (var i = 0; i < labels.Length; i++)
{
var index = i;
<button data-active="@(_selected == index ? "" : null)"
@onclick="() => _selected = index">@labels[index]</button>
}
</div>NaviusSelectionIndicator
Renders one div (a single element, per ADR-0003); style it via splat. It self-wires once the Container ref is populated.
| Prop | Type | Default |
|---|---|---|
| Container | ElementReference (EditorRequired) | - |
| Active | object? | - |
| ActiveSelector | string | "[data-active]" |
| Spring | Spring | Spring.Snappy |
| Axis | string | "both" |
| ReduceMotion | string | "user" |
| Attributes | IDictionary<string, object>? (splat) | - |
To drive the marker yourself, the interop mirrors the component: CreateSelectionIndicatorAsync with options built by MotionPrograms.SelectionIndicator (spring baked to a linear() easing), then call UpdateAsync after the active element changes.
SelectionIndicatorOptions
Serialized to camelCase. Axis is x, y or both; Easing is the baked spring the marker moves with. Build with MotionPrograms.SelectionIndicator.
| Prop | Type | Default |
|---|---|---|
| CreateSelectionIndicatorAsync(ElementReference container, ElementReference indicator, SelectionIndicatorOptions options) | Task<SelectionIndicatorMotion> | - |
| MotionPrograms.SelectionIndicator(Spring? spring = null, string activeSelector = "[data-active]", string axis = "both", string reduceMotion = "user") | SelectionIndicatorOptions | - |
| SelectionIndicatorMotion.UpdateAsync() | Task | - |
Page transitions
NaviusPageTransition is experimental: treat the API surface as unstable. It is scoped to same-document navigation only. Where the View Transition API is unsupported, or under prefers-reduced-motion, navigation falls back to instant with no visual effect.
Wrap the region whose content changes across navigations (typically the router region) in one NaviusPageTransition. It registers a NavigationManager location-changing handler and snapshots the change through document.startViewTransition. Because Blazor renders the new page through its own cycle rather than inside the transition callback, the handler blocks the navigation until the browser has captured the old snapshot, then resolves the transition once the new render has settled. Use one wrapper per app (a single in-flight transition).
@* Wrap the router region once, near the app root. Experimental; same-document only. *@
<NaviusPageTransition>
<Router AppAssembly="@typeof(App).Assembly">
...
</Router>
</NaviusPageTransition>NaviusPageTransition
Same-document transitions for the WASM router. Reduced or unsupported falls back to instant navigation.
| Prop | Type | Default |
|---|---|---|
| ChildContent | RenderFragment? | - |
| ReduceMotion | string | "user" |
| SettleFrames | int | 2 |