Skip to content

Latest commit

 

History

History
1066 lines (841 loc) · 45.4 KB

README.md

File metadata and controls

1066 lines (841 loc) · 45.4 KB

Bit.Bmotion

A Blazor-native animation library inspired by Motion (Framer Motion). Springs, gestures, keyframes, variants, drag, layout (FLIP) animations, shared-element transitions and exit animations - no manual JavaScript wiring required. All animation math runs in C#; the slim browser bridge is auto-loaded for you.

Hybrid engine: compositor-eligible animations (tweens and zero-velocity springs on transform/opacity - i.e. most enter/exit/hover/variant animations) are pre-sampled in C# and handed to the browser's Web Animations API, so they play off the main thread with zero per-frame interop. Everything else runs on the C# rAF engine.

Targets .NET 8, 9, and 10 · Full support on Blazor WebAssembly. On Blazor Server, compositor-eligible animations play normally (they need only async interop), and drag follows the pointer a round trip behind (one position, and one OnDrag, per frame); features that require the per-frame loop - inertia (a throw lands where it would have come to rest), color/dimension interpolation, keyframe arrays off the compositor - degrade to instant state changes.


Table of Contents


Installation

dotnet add package Bit.Bmotion

Register the services in Program.cs:

using Bit.Bmotion;

builder.Services.AddBitBmotionServices();

The browser bridge (bit-bmotion.js) ships as a static web asset of the package and is imported automatically the first time an animation runs, so no manual <script> tag is required.


Quick Start

@using Bit.Bmotion

<Bmotion Initial="Bm.To(opacity: 0, y: 20)"
         Animate="Bm.To(opacity: 1, y: 0)">
    <div>Hello, Bmotion!</div>
</Bmotion>

That's it - the element fades in and slides up on first render.


The Bm facade

Bm is the terse entry point for the whole hot path - it reads like motion.dev inside Razor:

Bm.To(opacity: 1, x: 100, scale: 1.2)            // an animation target
Bm.To(scale: [1, 1.4, 0.8, 1])                   // keyframes are just another value shape
Bm.Spring(stiffness: 200, damping: 20)           // physics spring
Bm.Spring(bounce: 0.4, duration: 0.6)            // intuitive duration-based spring
Bm.Tween(0.4, BmEase.InOut, repeat: BmRepeat.Mirror())
Bm.Inertia(velocity: 500)
Bm.Stagger(0.08, from: BmStaggerFrom.Center)     // delay generator for multi-element animations
Bm.Current                                        // wildcard keyframe: "the element's current value"

Bm.Value(0.0)                                     // a reactive motion value
Bm.Velocity(x)                                    // a motion value carrying x's units/sec (useVelocity)
Bm.Clamp(0, 10, v) · Bm.Wrap(0, 10, v) · Bm.Mix(a, b, t) · Bm.MapRange(v, [0, 100], [0, 1])

Every transition is reusable: transition.WithDelay(seconds) returns a copy with a new delay, so one configured spring can drive a whole staggered set without them fighting over its Delay.

Bm.To(...) returns a BmProps; every parameter is optional:

Group Properties
Transforms x, y, z, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ, skewX, skewY, perspective, originX, originY
Visual opacity, backgroundColor, color, borderColor, outlineColor, fill, stroke, width, height, borderRadius, boxShadow, filter
Layout / box-model top, left, right, bottom, margin, padding, gap
Typography letterSpacing, lineHeight, fontSize
Misc CSS clipPath, backgroundPosition, backgroundSize
Motion path offsetPath, offsetDistance
SVG d (shape morph), pathLength, pathOffset, pathSpacing (stroke drawing)
Escape hatches cssVars (custom properties), css (any other CSS property), transition

Anything without a typed parameter goes through css, which takes dash-case or camelCase keys: Bm.To(css: new() { ["mix-blend-mode"] = "screen" }).

Security: string-valued properties are written verbatim into the element's inline style. They are intended for developer-authored values; binding untrusted end-user input risks CSS injection.


Components

Bmotion

<Bmotion> is the core component. It wraps the element you author and adds animation superpowers: you write the animated element as plain markup inside <Bmotion>, and at render time Bmotion injects the engine id, the initial inline style, and pathLength into the first root HTML element of the child content - the Blazor equivalent of React's cloneElement. No context, no attribute splatting.

<Bmotion Initial="Bm.To(opacity: 0, scale: 0.9)"
         Animate="Bm.To(opacity: 1, scale: 1)"
         Exit="Bm.To(opacity: 0, scale: 0.9)"
         WhileHover="Bm.To(scale: 1.05)"
         WhileTap="Bm.To(scale: 0.97)"
         Transition="Bm.Spring(stiffness: 200, damping: 20)">
    <section class="my-card">
        <p>Content</p>
    </section>
</Bmotion>

Additional root nodes render unchanged; the root that receives the animation must be a plain HTML element, not a component.

Plain expressions need no @() in non-string attributes. When the expression embeds string literals, single-quote the attribute: WhileHover='Bm.To(backgroundColor: "#8a66ff")'.

Parameters

Parameter Type Description
ChildContent RenderFragment The animated element as plain markup; Bmotion automatically injects the engine id, initial inline style, and pathLength into the first root HTML element
Id string? Stable element id used as the element's identity; takes precedence over an id authored on the element
Initial BmTarget? Starting state (props or false to disable the enter animation)
Animate BmTarget? Target state; animates on mount and on every change
Exit BmTarget? State to animate to before unmounting (requires a presence component)
WhileHover / WhileTap / WhileFocus / WhileDrag / WhileInView BmTarget? Gesture overlays; automatically revert when the gesture ends
Transition BmTransition? Timing/physics for all of this element's transitions
Variants BmVariants? Named animation states
State / InitialState string? Active / initial variant name (razor-literal friendly)
Custom object? Data passed to dynamic variants
Inherit bool Default true. false cuts this element out of an ancestor's variant cascade (it still cascades its own variants downward)
TransformTemplate BmTransformTemplate? Rewrites the composed transform string - reorder the components, or keep one of your own in front
GesturePropagation bool Default true. false stops this element's tap/pan from also reaching gesture-enabled ancestors
Values Dictionary<string, BmValue<double>>? Motion-value bindings (style={{ x }} equivalent)
StringValues Dictionary<string, BmValue<string>>? String motion-value bindings for any CSS property (useMotionTemplate equivalent, see Motion values)
Drag, DragConstraints, DragElastic, DragMomentum, DragSnapToOrigin, DragDirectionLock, DragTransition, DragHandle, DragControls, DragListener See Drag
Timeline BmScrollTimeline? Drives Animate from scroll position on the browser's native scroll timelines (see Scroll timelines)
Layout BmLayout Automatic FLIP layout animations (true, BmLayout.Position or BmLayout.Size)
LayoutId string? Shared-element transitions (see Layout & shared elements)
Once / Viewport bool / BmViewport? Viewport tracking for WhileInView (Viewport.Root measures against a scroll container instead of the page)
OnUpdate Action<IReadOnlyDictionary<string, string>>? Per-frame callback with the CSS flushed this frame (no re-render). An element with one animates on the C# frame loop rather than the compositor, so there are frames to report
Prerender BmPrerenderMode? What the element renders while prerendered / statically rendered; overrides the global option (see Prerendering)

Plain HTML attributes (class, role, data-*, …) go directly on the element you author inside the child content. Your own inline style just works: the engine's initial style is merged before your declarations, so anything you write wins conflicts. If you author an id, it is honored and adopted as the engine id (the Id parameter takes precedence over both).

<Bmotion ...>
    <div style="border:1px solid #ccc;" />
</Bmotion>

Controlling the transform string

TransformTemplate rewrites the transform Bmotion composes - to reorder the components, or to keep one of your own in front of the animated ones:

@* stays centred on its own anchor whatever the animation does *@
<Bmotion Animate="Bm.To(scale: 1.2)"
         TransformTemplate="(_, generated) => $&quot;translate(-50%, -50%) {generated}&quot;">
    <div class="pin" />
</Bmotion>

It receives the element's live transform components (px and degrees) and the string Bmotion would have written, and applies on every path that writes a transform - the frame loop, the pre-first-paint inline style, instant Set calls and the keyframes handed to the compositor - so the element never flickers between a templated and an untemplated transform.

Nested gestures

Pointer gestures bubble, so pressing a tappable child also presses its tappable parent. GesturePropagation="false" stops that for tap and pan, exactly as DragPropagation does for drag (hover is unaffected - pointerenter/pointerleave don't bubble in the first place):

<Bmotion WhileTap="Bm.To(scale: 0.98)">          @* the card *@
    <div class="card">
        <Bmotion WhileTap="Bm.To(scale: 0.9)" GesturePropagation="false">
            <button>Only this presses</button>
        </Bmotion>
    </div>
</Bmotion>

Event callbacks

OnHoverStart / OnHoverEnd
OnTapStart / OnTap / OnTapCancel
OnFocusStart / OnFocusEnd
OnPanStart / OnPan / OnPanEnd                  (BmPanInfo)
OnDragStart / OnDrag / OnDragEnd
OnDirectionLock                                (BmDragAxis - the axis DragDirectionLock resolved)
OnLayoutAnimationStart / OnLayoutAnimationComplete
OnAnimationStart / OnAnimationComplete         (BmProps? - the resolved target)
OnViewportEnter / OnViewportLeave

Instance methods (via @ref)

<Bmotion @ref="_box" ...>
    <div />
</Bmotion>

@code {
    private Bmotion _box = default!;

    Task Pulse() => _box.AnimateAsync(Bm.To(scale: 1.2), Bm.Spring(bounce: 0.5)).AsTask();
    void Freeze() => _box.Pause();          // also: Resume(), SetPlaybackRate(2), Stop(), Set(...), SetAsync(...)
}

BmotionAnimatePresence

Wraps conditional content to enable exit animations. Children remain in the DOM while their exit animation plays, then are removed.

<BmotionAnimatePresence IsPresent="@_show" Mode="BmPresenceMode.Wait">
    <Bmotion Initial="Bm.To(opacity: 0)"
             Animate="Bm.To(opacity: 1)"
             Exit="Bm.To(opacity: 0)">
        <div>I animate in and out!</div>
    </Bmotion>
</BmotionAnimatePresence>
Parameter Type Description
IsPresent bool Controls whether the child content is present
Mode BmPresenceMode Sync (default), Wait (exit finishes before re-enter) or PopLayout (exiting content pops out of the layout flow so siblings reflow immediately)
OnExitComplete EventCallback Fires when all exit animations finish

BmotionPresenceSwitch

Animates between items - the paging / toast idiom covered by motion.dev's keyed AnimatePresence. When Item changes, the outgoing subtree plays its Exit before the new item enters (it keeps rendering the old item while exiting, because the content is a template of the item):

<BmotionPresenceSwitch Item="_page" Context="pageNumber">
    <Bmotion Initial="Bm.To(opacity: 0, x: 40)"
             Animate="Bm.To(opacity: 1, x: 0)"
             Exit="Bm.To(opacity: 0, x: -40)">
        <div>Page @pageNumber</div>
    </Bmotion>
</BmotionPresenceSwitch>

Mode defaults to Wait; Sync overlaps exit and enter. OnExitComplete fires per exit.

BmotionPresenceGroup

Keyed list presence - motion.dev's AnimatePresence around a collection. Render one template per item; removed items play their Exit before leaving the DOM, added items play their enter, and an item re-added mid-exit cancels the exit. Just mutate the list:

<BmotionPresenceGroup Items="_messages" ItemKey="m => m.Id" Context="message">
    <Bmotion Initial="Bm.To(opacity: 0, x: 40)"
             Animate="Bm.To(opacity: 1, x: 0)"
             Exit="Bm.To(opacity: 0, scale: 0.9)">
        <div class="toast">@message.Text</div>
    </Bmotion>
</BmotionPresenceGroup>

@code {
    private List<Message> _messages = [];   // Add/Remove and the animations follow
}
Parameter Type Description
Items IEnumerable<TItem> The current items, in render order
ItemKey Func<TItem, object>? Stable identity across renders (defaults to the item itself); keys must be unique
Mode BmPresenceMode Sync (default) or PopLayout - exiting items pop to position: absolute at their spot so siblings reflow immediately (give the container position: relative)
OnExitComplete EventCallback Fires each time a removed item finishes exiting

BmotionReorderGroup

Drag-to-reorder lists - motion.dev's Reorder.Group/Reorder.Item in one component. Every item is draggable along the list axis, siblings spring out of the way during the drag (transform-based preview, no re-renders), and the new order is committed to the bound list on release:

<BmotionReorderGroup @bind-Items="_tracks" ItemKey="t => t.Id" Context="track">
    <div class="row">@track.Title</div>
</BmotionReorderGroup>
Parameter Type Description
Items / ItemsChanged List<TItem> The list being reordered (@bind-Items supported)
ItemKey Func<TItem, object>? Stable identity across renders; keys must be unique
Axis BmDragAxis Y (vertical, default) or X (horizontal); wrapping grids are not supported
WhileDrag BmTarget? Overlay while dragging (default: slight scale-up)
HandleSelector string? CSS selector of a drag grip inside each item; the rest of the row stays clickable
Transition BmTransition? Spring for sibling displacement and the release settle
OnReorder EventCallback Fires after a reorder is committed

BmotionSplitText

Staggered text animation - motion.dev's splitText and GSAP's SplitText, with no DOM surgery. The text is split in C# and every unit is rendered as its own <Bmotion> element, so there is nothing to re-split on re-render and nothing for a script to undo:

<BmotionSplitText Text="Every character"
                  By="BmSplitBy.Chars"
                  Initial="Bm.To(opacity: 0, y: 24, rotate: -12)"
                  Animate="Bm.To(opacity: 1, y: 0, rotate: 0)"
                  Stagger="Bm.Stagger(0.03)"
                  Transition="Bm.Spring(bounce: 0.35, duration: 0.7)" />
Parameter Type Description
Text string? The text to split and animate
By BmSplitBy Chars (default), Words, or Lines (splits on authored newlines)
Initial / Animate / Exit BmTarget? Forwarded to every unit
WhileHover / WhileTap / WhileInView BmTarget? Per-unit gestures - the hover lands on the one character the pointer is over
Once / Viewport bool / BmViewport? Viewport options for WhileInView
Transition BmTransition? Timing for every unit; each unit's stagger offset is added to its delay
Stagger BmStagger? The cascade across units. Default Bm.Stagger(0.03)
As / Class / Style / UnitClass string? The container element/styling and the per-unit class
Accessible bool Default true: aria-label on the container, aria-hidden on the units
OnComplete EventCallback Fires once the last unit finishes

The split is not a bag of one-character spans: whitespace is rendered as whitespace rather than as units, and in Chars mode each word is wrapped in its own inline-block. So the text wraps between words (never mid-word), a selection copies the original string back, and a screen reader reads the sentence once instead of spelling it out.

Every unit is a real animated element, so Chars on a paragraph means hundreds of them. Split headlines by character; split body copy by Words or Lines.

BmotionConfig

Provides global animation defaults to an entire subtree via cascading values.

<BmotionConfig Transition="Bm.Tween(0.2)" TransitionSpeed="2">
    <!-- all Bmotion elements inside inherit these defaults; run twice as fast -->
</BmotionConfig>
Parameter Type Description
Transition BmTransition? Default transition for all descendants
ReduceMotion bool? null = respect OS preference, true = always reduce, false = always animate
TransitionSpeed double Playback rate: 2 = twice as fast, 0.5 = half speed, 0 = instant

Transitions

Three concrete types under the abstract BmTransition, each carrying only its own knobs:

// Tween (duration + easing)
Bm.Tween(0.4, BmEase.InOut, delay: 0.1)

// Spring - physics parameters…
Bm.Spring(stiffness: 200, damping: 15, mass: 1)
// …or the intuitive duration-based form (visual seconds + bounciness 0-1)
Bm.Spring(bounce: 0.4, duration: 0.5)

// Inertia (momentum deceleration)
Bm.Inertia(velocity: 500, timeConstant: 700, min: 0, max: 1000)
Bm.Inertia(velocity: 500, modifyTarget: Bm.SnapTo(100))   // coast, then settle on a 100px grid

Repeat via BmRepeat (no more int.MaxValue sentinel):

Bm.Tween(1.2, repeat: BmRepeat.Forever)          // loop forever
Bm.Tween(1.2, repeat: BmRepeat.Mirror())         // ping-pong forever
Bm.Spring(repeat: BmRepeat.Loop(3, delay: 0.3))  // 3×, 300 ms apart
Bm.Tween(0.5, repeat: 2)                         // implicit int conversion

Per-property overrides and orchestration live on the base type:

new BmTween
{
    Duration = 0.4,
    Properties = new() { ["opacity"] = Bm.Tween(0.1) },  // opacity snaps faster
}

A target can also embed its own transition, which wins over the component's Transition:

Bm.To(x: 100, transition: Bm.Spring(bounce: 0.6))

Arcs

path: Bm.Arc(...) bends the straight line between two points into a curve. There is no path data to author - the curve is generated from wherever the element is to wherever it is going, so it keeps working when either end moves:

<Bmotion Animate="Bm.To(x: 220, y: 90)"
         Transition="Bm.Tween(0.8, path: Bm.Arc(strength: 0.8, rotate: 1))">
    <div class="card" />
</Bmotion>
Option Default Meaning
strength 0.5 How far it bends, as a fraction of the distance travelled. 1 peaks a full travel-distance off the line
peak 0.5 Where the curve crests - 0 towards the start, 1 towards the end
direction Auto Which side it bulges. Auto arcs upward, which reads as "thrown"
rotate 0 How much the element turns to follow the curve: 1 points it along the tangent

The timing still comes from the transition, so a spring on a path arcs and overshoots. An arc needs both x and y as single values in the same target - a keyframe sequence on either axis is already describing its own path, so it is left alone. Because the two axes have to move together, an arc runs on the C# frame loop rather than the compositor (so it is a Blazor WebAssembly feature).

Keyframes

Every property accepts a single value or a keyframe sequence via collection expressions:

<Bmotion Animate="Bm.To(scale: [1, 1.3, 0.8, 1.1, 1], rotate: [0, 15, -10, 5, 0])"
         Transition="Bm.Tween(1.2, BmEase.InOut, repeat: BmRepeat.Mirror())">
    <div />
</Bmotion>

<Bmotion Animate='Bm.To(backgroundColor: ["#6c47ff", "#ff4785", "#6c47ff"])'
         Transition="Bm.Tween(3, BmEase.Linear, repeat: BmRepeat.Forever)">
    <div />
</Bmotion>
  • times: [0, 0.2, 0.5, 1] on Bm.Tween sets custom keyframe offsets.
  • eases: [BmEase.CircIn, BmEase.CircOut, ...] gives each keyframe segment its own curve (one entry per segment; the last entry repeats when the array is shorter): Bm.Tween(2, eases: [BmEase.CircOut, BmEase.CircIn, BmEase.BackOut]).
  • Bm.Current inside a sequence is a wildcard for the element's current value: x: [Bm.Current, 100] continues seamlessly from wherever the element is now.
  • Complex strings interpolate: between two values with the same shape, every number and embedded color animates - filter: "blur(0px) brightness(1)" → "blur(8px) brightness(1.4)", multi-part boxShadows, matching gradients, mixed-unit strings. Shapes that don't match snap to the target instead.

Variants

Named states declared once, selected by name - with razor-literal-friendly State / InitialState parameters. The active state propagates to descendants automatically:

<Bmotion Variants="_list" InitialState="hidden"
         State='@(_open ? "visible" : "hidden")'
         Transition="Bm.Tween(staggerChildren: 0.08, delayChildren: 0.2)">
    <div>
        <Bmotion Variants="_item">
            <div>Item 1</div>
        </Bmotion>
        <Bmotion Variants="_item">
            <div>Item 2</div>
        </Bmotion>
    </div>
</Bmotion>

@code {
    private readonly BmVariants _list = new()
    {
        ["hidden"]  = Bm.To(opacity: 0),
        ["visible"] = Bm.To(opacity: 1),
    };

    private readonly BmVariants _item = new()
    {
        ["hidden"]  = Bm.To(opacity: 0, x: -30),
        // a variant can embed its own transition
        ["visible"] = Bm.To(opacity: 1, x: 0, transition: Bm.Spring(stiffness: 300)),
    };
}

Orchestration

A variant container's transition carries the orchestration knobs for the subtree beneath it:

Bm.Tween(0.3, staggerChildren: 0.08, delayChildren: 0.2)

// "the panel opens, then its contents cascade in": children wait out the container's own
// animation, so the offset stays correct when you change the container's duration
Bm.Tween(0.4, staggerChildren: 0.06, when: BmWhen.BeforeChildren)
// the mirror image: the contents leave, then the panel closes behind them
Bm.Tween(0.3, staggerChildren: 0.06, when: BmWhen.AfterChildren)

// the cascade doesn't have to run first-to-last - it can radiate from anywhere
Bm.Tween(0.3, childStagger: Bm.Stagger(0.06, from: BmStaggerFrom.Center))
Bm.Tween(0.3, childStagger: Bm.Stagger(0.04, grid: (cols: 6, rows: 4)))

childStagger takes the same BmStagger the programmatic API uses, so from origins, grids and fully custom (index, total) => delay generators all work. It supersedes the flat staggerChildren interval; delayChildren still adds on top of either.

A spring has no true end, so BeforeChildren uses Bm.Spring(duration:) when set and estimates from the physics otherwise. AfterChildren waits for the real cascade: the latest child's stagger slot plus that child's own resolved transition.

Inherit="false" cuts one element out of the cascade entirely - it stops reacting to the label coming down while everything below it still inherits the variants it defines:

<Bmotion Variants="_list" State="visible" Transition="Bm.Tween(staggerChildren: 0.08)">
    <div>
        <Bmotion Variants="_item"><div>In the cascade</div></Bmotion>
        <Bmotion Inherit="false" Animate="Bm.To(opacity: 1)">
            <div>Runs to its own timing</div>
        </Bmotion>
    </div>
</Bmotion>

Children animated by the cascade raise OnAnimationStart / OnAnimationComplete exactly as an element with its own Animate does - so "the last item has arrived" is one callback away.

Dynamic variants receive the component's Custom parameter:

_item.Add("visible", custom => Bm.To(x: 10 * (int)custom!));
<Bmotion Variants="_item" Custom="@i">
    <div />
</Bmotion>

Drag

Motion-style flat parameters:

<Bmotion Drag="true" DragElastic="0.5">
    <div />
</Bmotion>

<Bmotion Drag="BmDrag.X"
         DragConstraints="BmDragConstraints.Horizontal(-200, 200)"
         DragMomentum="true"
         DragSnapToOrigin="false"
         DragDirectionLock="true"
         DragTransition="Bm.Spring(stiffness: 400, damping: 35)">
    <div />
</Bmotion>

Constraints can also be element bounds (motion.dev's dragConstraints={ref}): the container is measured at each drag start, so responsive layout changes just work.

<Bmotion Drag="true" DragConstraints="BmDragConstraints.Parent()">
    <div />   @* stays inside its parent element *@
</Bmotion>

<Bmotion Drag="true" DragConstraints='BmDragConstraints.Within(".drop-zone")'>
    <div />   @* stays inside the first element matching the selector *@
</Bmotion>

Handles and drag controls. DragHandle restricts the drag to a grip inside the element; BmDragControls (motion.dev's useDragControls) starts the drag from any other element - pair it with DragListener="false" so the controls are the only trigger:

<Bmotion Drag="true" DragHandle=".grip">
    <div class="row"><span class="grip">⠿</span> Only the grip drags</div>
</Bmotion>

<div class="track" @onpointerdown="e => _controls.StartAsync(e)">
    <Bmotion Drag="BmDrag.X" DragControls="_controls" DragListener="false"
             DragConstraints="BmDragConstraints.Parent()">
        <div class="thumb" />   @* press anywhere on the track to grab the thumb *@
    </Bmotion>
</div>

@code {
    private readonly BmDragControls _controls = new();
}

Snapping on release. A DragTransition authored as an inertia configures the momentum itself - including modifyTarget, which decides where the coast comes to rest. That is how a carousel pages and a slider clicks onto a grid: the fling projects a resting position, and modifyTarget rounds it. Bm.SnapTo builds the usual two shapes for you:

@* release anywhere on the track; settle on the nearest 120px stop *@
<Bmotion Drag="BmDrag.X" DragConstraints="BmDragConstraints.Parent()"
         DragTransition="Bm.Inertia(modifyTarget: Bm.SnapTo(120))">
    <div />
</Bmotion>

@* a three-page carousel: snap to whichever page the fling was heading for *@
<Bmotion Drag="BmDrag.X"
         DragTransition="Bm.Inertia(modifyTarget: Bm.SnapTo([0, -320, -640]))">
    <div />
</Bmotion>

With a modifyTarget set, even a slow release coasts to the nearest stop instead of resting where the pointer left it. Snapping happens before constraint clamping, so a snapped target can never land outside the bounds.

Per-edge elasticity. DragElastic accepts a uniform value or per-edge values (unspecified edges are rigid):

<Bmotion Drag="true" DragConstraints="BmDragConstraints.Parent()"
         DragElastic="BmDragElastic.Edges(right: 0.9, bottom: 0.9)">
    <div />
</Bmotion>

Reconfiguring a drag mid-gesture ends it. Changing any gesture parameter (Drag, DragConstraints, DragElastic, DragHandle, ...) while a drag is in progress re-attaches the element's listeners, which releases the drag first (OnDragEnd fires, momentum and snap-back run as for a pointer-up). Keep those parameters stable for the duration of a drag - bind them to state you change on OnDragEnd, not on OnDrag.

Layout & shared elements

Layout plays a FLIP animation whenever a re-render moves or resizes the element:

@* animate position + size *@
<Bmotion Layout="true" ...>
    <div />
</Bmotion>

@* position only - no scale distortion on text *@
<Bmotion Layout="BmLayout.Position" ...>
    <div />
</Bmotion>

@* size only - the element snaps to its new spot and only the box grows/shrinks *@
<Bmotion Layout="BmLayout.Size" ...>
    <div />
</Bmotion>

Four options control how the projection is measured:

Parameter Use it when
LayoutAnchor The wrong part of the box appears to stay still. Default TopLeft; BmLayoutAnchor.Center makes a resizing box grow evenly around its middle
LayoutScroll The element lives in a scrolling container. Without it, scrolling that container between the two measurements reads as the element having moved, and it visibly jumps
LayoutRoot The element is position: fixed. It stays put while the page scrolls, so it is measured in viewport rather than document coordinates
LayoutDependency Measuring on every re-render is costing you a forced reflow. Point it at the state the layout depends on and unrelated renders stop measuring
@* an accordion panel inside a scrolling list, measured only when it actually opens/closes *@
<Bmotion Layout="BmLayout.Size"
         LayoutAnchor="BmLayoutAnchor.Center"
         LayoutScroll="true"
         LayoutDependency="_isOpen"
         OnLayoutAnimationComplete="@(() => _settled = true)">
    <div class="panel" />
</Bmotion>

LayoutId connects elements across mounts: when one element unmounts and another mounts with the same id, the new one FLIPs from where the old one was - the tab-underline / card-to-detail idiom:

@if (tab == _activeTab)
{
    <Bmotion LayoutId="underline">
        <div style="position:absolute;bottom:0;left:0;right:0;height:3px;background:#6c47ff;" />
    </Bmotion>
}

Wrap independent groups in <BmotionLayoutGroup Name="sidebar"> to namespace their ids.


Scroll timelines

Timeline drives Animate from scroll position instead of time, on the browser's native ScrollTimeline / ViewTimeline. The keyframes are pre-sampled in C# once and handed over; after that there is no scroll handler, no frame loop and no interop - the animation runs on the compositor.

@* a reading-progress bar: no scroll handler, no disposal, no state *@
<Bmotion Timeline="BmScrollTimeline.Page()" Animate="Bm.To(scaleX: [0, 1])">
    <div class="progress-bar" style="transform-origin:0 50%;" />
</Bmotion>

@* the element's own journey through the viewport: 0 as it enters, 1 as it leaves *@
<Bmotion Timeline="BmScrollTimeline.View()"
         Animate="Bm.To(opacity: [0, 1, 1, 0], y: [40, 0, 0, -40])">
    <div class="card" />
</Bmotion>
Source Progress measured over
BmScrollTimeline.Page(axis) the whole document's scroll
BmScrollTimeline.Container(selector, axis) one scroll container's scroll
BmScrollTimeline.View(axis, range) the animated element's journey through the scrollport
BmScrollTimeline.ViewOf(selector, axis, range) another element's journey, driving this one

range takes CSS animation-range syntax ("entry 0% cover 50%") and needs native support.

  • Only transform components and opacity can be scroll-driven - they are what the browser can interpolate for us. A target touching anything else falls back to the ordinary time-based path.
  • Transition does not apply while a timeline is attached: scroll position is the progress.
  • A timeline owns the properties it animates, so don't also aim a gesture or a second animation at them.
  • Without native scroll timelines the bridge scrubs the same Web Animation from one passive scroll listener - still no per-frame interop, still the browser interpolating. Custom range strings need the native API and the fallback covers the full journey instead.

Use BmotionScrollTracker (see Motion values) instead when you need the scroll progress as a number - to drive C# state, compose it with other values, or animate a property the browser can't own.


Programmatic API

BmotionAnimateService (inject as Motion) animates elements by CSS selector or ElementReference - no <Bmotion> wrapper needed:

@inject BmotionAnimateService Motion

@code {
    async Task Animate()
    {
        var controls = await Motion.AnimateAsync("#target", Bm.To(x: 100, opacity: 0.5), Bm.Tween(0.6));
        controls.Pause();          // playback controls: Pause / Play / SetSpeed / Stop / Complete
        controls.SetSpeed(2);
        await controls;            // directly awaitable
    }

    // Stagger across all matched elements
    Task Cascade() => Motion.AnimateAsync(".item", Bm.To(opacity: 1, y: 0),
        Bm.Spring(), stagger: Bm.Stagger(0.08, from: BmStaggerFrom.Center)).AsTask();

    // Animate a raw number (counters, canvas, anything outside the DOM)
    Task CountUp() => Motion.AnimateAsync(0, 100,
        v => { _count = (int)v; StateHasChanged(); }, Bm.Tween(1.5));
}

Sequences

Multi-step timelines with motion.dev-style at offsets:

var seq = new BmSequence()
    .Add("#box", Bm.To(x: 100), Bm.Tween(0.5))
    .Add("#box", Bm.To(y: 50), Bm.Tween(0.3), at: "-0.1")   // overlap previous end by 0.1s
    .Label("burst")
    .Add(".dot", Bm.To(scale: [1, 1.4, 1]), at: "burst");   // at a named label

var controls = await Motion.RunAsync(seq);

at accepts: "+0.5" / "-0.2" (relative to previous end), "<" / "<0.3" (previous start), "1.5" (absolute), or a label name.

The whole timeline runs on one playhead, driven by the animation clock rather than by wall-clock timers, so the returned controls govern the gaps between steps as well as the steps themselves: controls.Pause() genuinely holds the sequence instead of letting its later steps start on time, and controls.SetSpeed(3) compresses the silences by the same factor as the movement. (On Blazor Server, where there is no frame loop, the gaps fall back to wall-clock timers along with everything else that needs the loop.)


Motion values

A reactive value graph, fully composable and bindable to elements:

var x = Bm.Value(0.0);
x.Subscribe(v => Console.WriteLine(v));
x.SetSync(100);
x.GetVelocity();                                  // units/sec
x.Jump(0);                                        // set without feeding physics

var angle = x.Transform([0, 200], [0, 360]);      // range mapping
var raw   = x.Transform([0, 200], [0, 360], clamp: false);  // extrapolate past the ends
var label = x.Transform(v => $"{v:0}px");         // arbitrary derivation (useTransform)
var speed = Bm.Velocity(x);                       // units/sec as its own value (useVelocity)
var smooth = Motion.Spring(x, Bm.Spring(stiffness: 120));  // spring follower (useSpring)

await Motion.AnimateAsync(x, 200, Bm.Spring());   // animate the value itself

Bind values straight to element properties - changes flush to the DOM each frame without re-rendering (the style={{ x }} equivalent):

<Bmotion Values='new() { ["x"] = _x, ["rotate"] = _angle }'>
    <div />
</Bmotion>

Bm.Template composes motion values into a CSS string (motion.dev's useMotionTemplate), and StringValues binds string values to any CSS property:

var blur = Bm.Value(0.0);
var filter = Bm.Template(() => $"blur({blur.Value}px)", blur);
<Bmotion StringValues='new() { ["filter"] = _filter }'>
    <div />
</Bmotion>

BmotionScrollTracker exposes scroll positions as motion values, and can track a target element's journey through the viewport between configurable offsets (motion.dev's useScroll({ target, offset })):

await Scroll.ObserveAsync(new BmScrollOptions
{
    TargetId = "hero",
    Offset = ["start end", "end start"],   // 0 when hero enters at the bottom, 1 when it leaves at the top
}, _ => Task.CompletedTask);

// then compose: Scroll.TargetProgressValue.Transform([0, 1], [0, -120])

Scroll-linked animations compose end-to-end:

@inject BmotionScrollTracker Scroll

<Bmotion Values="_bar">
    <div style="transform-origin:0 50%;" />
</Bmotion>

@code {
    private Dictionary<string, BmValue<double>> _bar = default!;

    protected override void OnInitialized()
        => _bar = new() { ["scaleX"] = Scroll.ProgressYValue };

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) await Scroll.ObserveAsync(_ => Task.CompletedTask);
    }

    // BmotionScrollTracker is transient and owned by this component:
    public ValueTask DisposeAsync() => Scroll.DisposeAsync();
}

Prerendering & static SSR

While a page is prerendered (or statically rendered with no interactivity at all) there is no browser attached, so the C# engine can't run. By default every element is served at its Initial state and animates once the runtime is interactive - which for an Initial="Bm.To(opacity: 0)" element means invisible content until the WebAssembly runtime has downloaded. Choose something better globally, or per element with the Prerender parameter:

builder.Services.AddBitBmotionServices(o => o.Prerender = BmPrerenderMode.Animate);
<!-- App.razor <head> - needed for BmPrerenderMode.Animate -->
<link rel="stylesheet" href="_content/Bit.Bmotion/bit-bmotion.css" />
BmPrerenderMode Served HTML When the runtime takes over
Initial Default. The Initial state The enter animation plays
Final The resting state - visible, indexable content, no animation Already at rest; the enter is skipped
Animate The resting state plus the enter animation compiled to pure CSS, playing the moment the HTML arrives On WebAssembly it continues the CSS animation from where it got to; elsewhere it lands at rest. Never replayed

Blazor doesn't hydrate prerendered markup - the interactive renderer throws it away and renders every component again. That is why Final/Animate also affect the takeover: every element prerendered at rest inside an interactive island persists an entry through PersistentComponentState (to that island's runtime), and an element mounting in the runtime's first render claims one and renders straight into its resting state instead of jumping back to Initial and playing its enter a second time. Entries are matched by what the element looks like at rest, so content that only exists once interactive finds none and enters normally, as do elements that mount later (after data loads, on navigation). An enter animation that repeats forever has no resting state, so the runtime restarts it.

How Animate works, and what it covers:

  • A <Bmotion> renders no element of its own, so it can't add a <style> block (a sibling would break your :first-child selectors). The animation travels in the element's inline style as custom properties, and the stylesheet holds generic @keyframes that read them back. Without the stylesheet nothing reads them and the element simply renders at rest, like Final.
  • Tweens and zero-velocity springs (sampled to CSS linear()), delays, variant stagger (from origins included), delayChildren, BeforeChildren, TransitionSpeed, loops and mirrors, and keyframe sequences of up to 6 evenly spaced frames all compile. So do transforms, opacity, colours (when Initial gives a starting colour), sizes, filters, shadows and path drawing.
  • Anything CSS can't play faithfully - inertia, arcs, springs with initial velocity, unevenly spaced times, differing per-segment eases, RepeatDelay, BmRepeatType.Reverse, AfterChildren, transform components with different transitions - renders at rest instead. An SVG d morph can't be served at rest at all, so that element keeps the Initial behaviour.
  • prefers-reduced-motion is honoured by media query, following the same ReducedMotion policy as the runtime (transforms snap, opacity and colour still fade).
  • WhileInView can't be triggered without the runtime, so those elements keep their resting state and reveal once interactive - except with scripting disabled, where the stylesheet shows them as they would look in view.
  • Timeline-driven elements keep the Initial behaviour: progress 0 is their starting point.

BmotionCapabilities.IsPrerendering tells your own components when they are being prerendered (SupportsFrameLoop then describes the prerendering server, not the runtime that takes over).


Examples

See the Demo samples app for runnable examples of basic animations, gestures, springs, drag, variants & stagger, keyframes, enter/exit transitions, presence switching, layout (FLIP) animations, scroll-linked motion values and programmatic control.


MCP server (for AI agents)

The demo hosts an MCP server at /mcp, so an AI agent can write Bmotion code from what the library actually is rather than from what it remembers. Point an MCP client at https://localhost:5071/mcp; every tool is also a plain HTTP GET under /api/mcp/... if you just want to look, and the demo's own /mcp-server page documents and exercises all of them.

What sets it apart from a documentation server is that half of its tools answer by running the real animation engine off-screen - a headless IBmotionInterop lets it construct a genuine BmotionAnimationEngine with no DOM and advance the frame clock itself, so a three-second spring is measured in microseconds:

  • SimulateBmotionTransition plays a transition and reports how long it takes to settle, how far it overshoots and what the curve looks like. Nothing in a transition states its own settle time - a spring's falls out of the physics - so this is the only way to know what one does before shipping it. Pass several at once, separated by semicolons, to rank them side by side.
  • AnalyzeBmotionAnimation starts an animation and reports which playback path the engine chose - the browser compositor, or the C# frame loop. That choice is exactly what decides whether the animation plays or silently snaps on Blazor Server, and nothing in a build log says which.
  • ReviewBmotionCode checks written markup for the mistakes that compile cleanly and then do nothing: an Exit with no presence component, a @foreach with no @key, a spring whose stiffness the engine discards because bounce or duration was set alongside it.
  • GetBmotionRecipes / GetBmotionRecipe hand over complete, copy-pasteable patterns - a staggered list, a modal, a scroll reveal, a shared-element transition - each with the caveat that is not visible in the code.
  • SearchBmotion covers this guide, every public member, the animatable properties, the easing presets, the recipes, the setup guides and the demo's sources at once, with the exact follow-up call on each hit.
  • The exact API (GetBmotionApiList / GetBmotionApiDetails) is reflected out of the shipped assembly, with every parameter's type, XML documentation and real default value - which in an animation library is the behaviour.
  • Resources (bmotion://guide/..., bmotion://api/..., bmotion://recipes) and prompts for the four common jobs: building an animation, adding Bmotion to an app, tuning how something feels, and debugging an animation that does not move.

Start with the SearchBmotion tool, or read GetBmotionOverview once for the rules that are not visible in any signature. See McpController and BmotionMotionLab.


Accessibility

Tap gestures are keyboard-accessible out of the box: when a tappable element has focus, Enter and Space press and release it exactly like a pointer tap (WhileTap plays, OnTapStart/OnTap fire; losing focus mid-press cancels). Give the element tabindex="0" if it isn't natively focusable.

Bmotion honours the user's prefers-reduced-motion preference. Choose how, globally, at registration:

builder.Services.AddBitBmotionServices(o => o.ReducedMotion = BmReducedMotionMode.User);
BmReducedMotionMode Behaviour
IgnoreUnlessConfigured Default (back-compat). OS preference respected only inside a <BmotionConfig>.
User Recommended. Respect the OS preference everywhere - the web-platform default.
Always Always reduce, regardless of the OS.
Never Never reduce, regardless of the OS.

When motion is reduced, Bmotion follows Motion's "user" semantics: transforms, layout and dimension changes snap to their target instantly, while opacity and colour still animate - a softer, more correct reduction than collapsing every property to instant. Stagger delays are dropped too, so a reduced list appears at once instead of trickling in.

A local <BmotionConfig ReduceMotion="true|false"> always overrides the global mode for its subtree (null = follow the mode):

<BmotionConfig ReduceMotion="true">   @* force-reduce this subtree *@
    ...
</BmotionConfig>

Migration note. The default remains IgnoreUnlessConfigured so existing apps are unaffected. New apps should set ReducedMotion = BmReducedMotionMode.User to match the platform default; this is planned to become the default in a future major version.


License

MIT