_items = [
+ new("A", "#6c47ff"),
+ new("B", "#ff4785"),
+ new("C", "#00c3ff"),
+ new("D", "#ffd447"),
+ new("E", "#44ff90"),
+ ];
+
+ void Shuffle() => _items = [.._items.OrderBy(_ => Random.Shared.Next())];
+
+ void ToggleLayout() => _justifyContent = _justifyContent switch
+ {
+ "flex-start" => "center",
+ "center" => "flex-end",
+ _ => "flex-start"
+ };
+ }
+ """;
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor
new file mode 100644
index 0000000000..d42e2c4a7e
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor
@@ -0,0 +1,142 @@
+@page "/scroll"
+@inject BmotionScrollTracker Scroll
+@implements IAsyncDisposable
+
+Scroll Animations
+
+
+
Window scroll progress
+
+
+ Y: @((_progressY * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% | X: @((_progressX * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%
+
+
+
+
+
Scroll Tracking
+
+ Inject BmotionScrollTracker and call ObserveAsync to get live scroll
+ progress. Use the progress value to drive animations via MotionValue .
+ The progress bar above stays pinned to the top while you scroll.
+
+
+
+
+
+
+
whileInView
+
+ Use WhileInView to animate when the element enters the viewport.
+ Set Once="true" to animate only on first entry.
+
+
+ @for (int i = 0; i < 16; i++)
+ {
+ var color = i % 2 == 0 ? "#6c47ff" : "#ff4785";
+ var idx = i;
+
+ Section @(idx + 1)
+
+ }
+
+
+
+
+@code {
+ private double _progressX;
+ private double _progressY;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await Scroll.ObserveAsync(null, info =>
+ {
+ _progressX = info.ProgressX;
+ _progressY = info.ProgressY;
+ return InvokeAsync(StateHasChanged);
+ });
+ }
+ }
+
+ public async ValueTask DisposeAsync() => await Scroll.DisposeAsync();
+
+ private const string _scrollTrackCode = """
+ @using Bit.Bmotion
+ @inject BmotionScrollTracker Scroll
+ @implements IAsyncDisposable
+
+
+
+
+
+ @code {
+ private double _progressX;
+ private double _progressY;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await Scroll.ObserveAsync(null, info =>
+ {
+ _progressX = info.ProgressX;
+ _progressY = info.ProgressY;
+ return InvokeAsync(StateHasChanged);
+ });
+ }
+ }
+
+ public async ValueTask DisposeAsync() => await Scroll.DisposeAsync();
+ }
+ """;
+
+ private const string _inViewCode = """
+ @using Bit.Bmotion
+
+
+
+
+
whileInView
+
+ @for (int i = 0; i < 16; i++)
+ {
+ var color = i % 2 == 0 ? "#6c47ff" : "#ff4785";
+ var idx = i;
+
+ Section @(idx + 1)
+
+ }
+
+ """;
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor
new file mode 100644
index 0000000000..9214baf063
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor
@@ -0,0 +1,291 @@
+@page "/springs"
+
+Springs
+
+
+
Spring Physics
+
+ Set Transition.Type = BmotionTransitionType.Spring for physically-based motion.
+ Tune Stiffness (snap speed) and Damping (oscillation).
+
+
+
+ @foreach (var cfg in _springs)
+ {
+
+ @cfg.Label
+
+
+ }
+
+
+
_on = !_on)">
+ Animate
+
+
+
+
+
+
+
Bouncy Enter
+
Underdamped springs produce delightful overshoots on mount.
+
+
Re-mount
+
+
+
+
+
+
Repeating Springs
+
+ Springs now honour Repeat , RepeatType and RepeatDelay .
+ Mirror ping-pongs back to the start, Loop replays from the origin.
+
+
+
+
+ Mirror · infinite
+
+
+
+
+ Loop · 3× with delay
+
+
+
+
+
_repeat = !_repeat)">
+ @(_repeat ? "Reset" : "Start repeating")
+
+
+
+
+
+@code {
+ private bool _on;
+ private bool _repeat;
+ private int _mountKey;
+
+ private record SpringDemo(string Label, BmotionTransitionConfig Config);
+
+ private readonly SpringDemo[] _springs =
+ [
+ new("Stiff 400 / Damp 40", BmotionTransitionConfig.Spring(400, 40)),
+ new("Stiff 100 / Damp 10", BmotionTransitionConfig.Spring(100, 10)),
+ new("Stiff 50 / Damp 5", BmotionTransitionConfig.Spring(50, 5)),
+ new("Stiff 300 / Damp 70", BmotionTransitionConfig.Spring(300, 70)),
+ ];
+
+ void Remount() => _mountKey++;
+
+ private const string _springCode = """
+ @using Bit.Bmotion
+
+
+
+
+
Spring Physics
+
+
+ @foreach (var cfg in _springs)
+ {
+
+ @cfg.Label
+
+
+ }
+
+
+
_on = !_on)">
+ Animate
+
+
+
+ @code {
+ private bool _on;
+
+ private record SpringDemo(string Label, BmotionTransitionConfig Config);
+
+ private readonly SpringDemo[] _springs =
+ [
+ new("Stiff 400 / Damp 40", BmotionTransitionConfig.Spring(400, 40)),
+ new("Stiff 100 / Damp 10", BmotionTransitionConfig.Spring(100, 10)),
+ new("Stiff 50 / Damp 5", BmotionTransitionConfig.Spring(50, 5)),
+ new("Stiff 300 / Damp 70", BmotionTransitionConfig.Spring(300, 70)),
+ ];
+ }
+ """;
+
+ private const string _bouncyCode = """
+ @using Bit.Bmotion
+
+
+
+
+
Bouncy Enter
+
Underdamped springs produce delightful overshoots on mount.
+
+
Re-mount
+
+
+ @code {
+ private int _mountKey;
+
+ void Remount() => _mountKey++;
+ }
+ """;
+
+ private const string _repeatCode = """
+ @using Bit.Bmotion
+
+
+
+
+
Repeating Springs
+
+
+
+ Mirror · infinite
+
+
+
+
+ Loop · 3× with delay
+
+
+
+
+
_repeat = !_repeat)">
+ @(_repeat ? "Reset" : "Start repeating")
+
+
+
+ @code {
+ private bool _repeat;
+ }
+ """;
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor
new file mode 100644
index 0000000000..a20896aa1b
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor
@@ -0,0 +1,222 @@
+@page "/variants"
+
+Variants
+
+
+
Variants
+
+ Define named states in a BmotionMotionVariants dictionary and reference them
+ by name in Animate , Initial , etc. Children automatically receive the
+ active variant name and can define their own matching entries.
+
+
+
+
+
+ @for (int i = 0; i < 5; i++)
+ {
+ var idx = i;
+
+ Item @(idx + 1)
+
+ }
+
+
+
+
+
_visible = !_visible)">
+ @(_visible ? "Hide" : "Show") list
+
+
+
+
+
+
+
Variant Orchestration
+
+ Use StaggerChildren and DelayChildren in the container's
+ transition to choreograph child animations.
+
+
+
+
+ @for (int i = 0; i < 4; i++)
+ {
+
+ }
+
+
+
+
_visible2 = !_visible2)">
+ @(_visible2 ? "Hide" : "Show") boxes
+
+
+
+
+
+@code {
+ private bool _visible = true;
+ private bool _visible2 = true;
+
+ // Typed BmotionAnimationTarget fields so implicit conversions work cleanly in Razor attributes
+ private readonly BmotionAnimationTarget _hidden = "hidden";
+ private readonly BmotionAnimationTarget _visible_ = "visible";
+
+ private readonly BmotionMotionVariants _containerVariants = BmotionMotionVariants.Create(
+ ("hidden", new BmotionAnimationProps { Opacity = 0 }),
+ ("visible", new BmotionAnimationProps { Opacity = 1 })
+ );
+
+ private readonly BmotionMotionVariants _itemVariants = BmotionMotionVariants.Create(
+ ("hidden", new BmotionAnimationProps { Opacity = 0, X = -30 }),
+ ("visible", new BmotionAnimationProps { Opacity = 1, X = 0 })
+ );
+
+ private const string _variantsCode = """
+ @using Bit.Bmotion
+
+
+
+
+
Variants
+
+
+
+
+ @for (int i = 0; i < 5; i++)
+ {
+ var idx = i;
+
+ Item @(idx + 1)
+
+ }
+
+
+
+
+
_visible = !_visible)">
+ @(_visible ? "Hide" : "Show") list
+
+
+
+ @code {
+ private bool _visible = true;
+
+ private readonly BmotionAnimationTarget _hidden = "hidden";
+ private readonly BmotionAnimationTarget _visible_ = "visible";
+
+ private readonly BmotionMotionVariants _containerVariants = BmotionMotionVariants.Create(
+ ("hidden", new BmotionAnimationProps { Opacity = 0 }),
+ ("visible", new BmotionAnimationProps { Opacity = 1 })
+ );
+
+ private readonly BmotionMotionVariants _itemVariants = BmotionMotionVariants.Create(
+ ("hidden", new BmotionAnimationProps { Opacity = 0, X = -30 }),
+ ("visible", new BmotionAnimationProps { Opacity = 1, X = 0 })
+ );
+ }
+ """;
+
+ private const string _orchestrationCode = """
+ @using Bit.Bmotion
+
+
+
+
+
Variant Orchestration
+
+ Use StaggerChildren and DelayChildren in the container's
+ transition to choreograph child animations.
+
+
+
+
+ @for (int i = 0; i < 4; i++)
+ {
+
+ }
+
+
+
+
_visible2 = !_visible2)">
+ @(_visible2 ? "Hide" : "Show") boxes
+
+
+
+ @code {
+ private bool _visible2 = true;
+
+ private readonly BmotionAnimationTarget _hidden = "hidden";
+ private readonly BmotionAnimationTarget _visible_ = "visible";
+
+ private readonly BmotionMotionVariants _containerVariants = BmotionMotionVariants.Create(
+ ("hidden", new BmotionAnimationProps { Opacity = 0 }),
+ ("visible", new BmotionAnimationProps { Opacity = 1 })
+ );
+
+ private readonly BmotionMotionVariants _itemVariants = BmotionMotionVariants.Create(
+ ("hidden", new BmotionAnimationProps { Opacity = 0, X = -30 }),
+ ("visible", new BmotionAnimationProps { Opacity = 1, X = 0 })
+ );
+ }
+ """;
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Program.cs b/src/Bmotion/Bit.Bmotion.Demos/Program.cs
new file mode 100644
index 0000000000..b888075a0e
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Program.cs
@@ -0,0 +1,15 @@
+using Bit.Bmotion;
+using Bit.Bmotion.Demos;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+builder.RootComponents.Add("#app");
+builder.RootComponents.Add("head::after");
+
+builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+
+// Register Bit.Bmotion services
+builder.Services.AddBitBmotionServices();
+
+await builder.Build().RunAsync();
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json b/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json
new file mode 100644
index 0000000000..a3f538194a
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "Bit.Bmotion.Demos": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+ "applicationUrl": "http://localhost:5070;https://localhost:5071",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Shared/CodeSnippet.razor b/src/Bmotion/Bit.Bmotion.Demos/Shared/CodeSnippet.razor
new file mode 100644
index 0000000000..ade3a42505
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Shared/CodeSnippet.razor
@@ -0,0 +1,51 @@
+@inject IJSRuntime JS
+
+@* CodeSnippet renders a toggle button that shows / hides the full source code of a sample,
+ plus a copy-to-clipboard button. *@
+
+
+
+
+ @(_show ? "▾" : "▸")
+ @(_show ? "Hide code" : "Show code")
+
+
+ @if (_show)
+ {
+
+ @(_copied ? "✓ Copied" : "Copy")
+
+ }
+
+
+ @if (_show)
+ {
+
@((MarkupString)SyntaxHighlighter.Highlight(Code.Trim('\n')))
+ }
+
+
+@code {
+ /// The full, copy/paste-runnable source snippet to display for the sample.
+ [Parameter, EditorRequired] public string Code { get; set; } = "";
+
+ private bool _show;
+ private bool _copied;
+
+ private void Toggle()
+ {
+ _show = !_show;
+ _copied = false;
+ }
+
+ private async Task CopyAsync()
+ {
+ try
+ {
+ _copied = await JS.InvokeAsync("bmCopyToClipboard", Code.Trim('\n'));
+ }
+ catch
+ {
+ _copied = false;
+ }
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Shared/SyntaxHighlighter.cs b/src/Bmotion/Bit.Bmotion.Demos/Shared/SyntaxHighlighter.cs
new file mode 100644
index 0000000000..b97caa580d
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/Shared/SyntaxHighlighter.cs
@@ -0,0 +1,420 @@
+using System.Text;
+
+namespace Bit.Bmotion.Demos.Shared;
+
+///
+/// A small, dependency-free Razor/C# syntax highlighter for the demo code snippets.
+/// Produces HTML with <span class="tok-*"> token wrappers (already HTML-encoded)
+/// and wraps the lines that belong to a Bmotion component tag (<Bmotion> ,
+/// <BmotionAnimatePresence> , <BmotionConfig> ) in a hl-line span so they
+/// stand out among the surrounding markup.
+/// It is intentionally pragmatic - it covers the constructs used by the demo snippets rather
+/// than being a fully spec-compliant Razor lexer.
+///
+public static class SyntaxHighlighter
+{
+ /// Highlights Razor/C# source and returns HTML markup safe to render verbatim.
+ public static string Highlight(string code)
+ {
+ try
+ {
+ code = code.Replace("\r\n", "\n").Replace("\r", "\n");
+ return new Lexer(code).Run();
+ }
+ catch { return System.Net.WebUtility.HtmlEncode(code); }
+ }
+
+ private sealed class LineBuf
+ {
+ public readonly StringBuilder Sb = new();
+ public bool Hl;
+ }
+
+ private sealed class Lexer
+ {
+ private readonly string s;
+ private readonly int n;
+ private int i;
+
+ private readonly List lines = new();
+ private LineBuf cur;
+ private bool tagHl; // currently emitting a Bmotion component tag
+
+ public Lexer(string code)
+ {
+ s = code; n = code.Length;
+ cur = new LineBuf();
+ lines.Add(cur);
+ }
+
+ public string Run()
+ {
+ Markup(false);
+
+ var outSb = new StringBuilder();
+ for (int k = 0; k < lines.Count; k++)
+ {
+ var ln = lines[k];
+ // Each line is rendered as its own block-level span. Empty lines get a
+ // zero-width space so they keep their height. Newlines are intentionally
+ // NOT emitted: the block layout provides the line breaks, and a literal
+ // '\n' next to a display:block span would render as an extra blank line.
+ var cls = ln.Hl ? "code-line hl-line" : "code-line";
+ outSb.Append("");
+ if (ln.Sb.Length == 0) outSb.Append("\u200b");
+ else outSb.Append(ln.Sb);
+ outSb.Append(" ");
+ }
+ return outSb.ToString();
+ }
+
+ // ── Vocabularies ──────────────────────────────────────────────────────
+ private static readonly HashSet BmotionTags = new(StringComparer.Ordinal)
+ {
+ "Bmotion", "BmotionAnimatePresence", "BmotionConfig",
+ };
+ private static readonly HashSet Keywords = new()
+ {
+ "abstract","as","async","await","base","bool","break","byte","case","catch","char","class",
+ "const","continue","decimal","default","do","double","else","enum","false","finally","float",
+ "for","foreach","get","if","in","init","int","interface","internal","is","lock","long","new",
+ "namespace","null","object","out","override","params","private","protected","public","readonly",
+ "record","ref","return","sealed","set","short","static","string","struct","switch","this","throw",
+ "true","try","typeof","using","var","virtual","void","while","yield",
+ };
+ private static readonly HashSet SingleLineDirectives = new()
+ {
+ "using","inject","implements","page","namespace","inherits","layout","attribute","model",
+ "rendermode","preservewhitespace","typeparam","addTagHelper",
+ };
+ private static readonly HashSet BodyDirectives = new() { "code", "functions" };
+ private static readonly HashSet BlockKeywords = new()
+ {
+ "if","else","for","foreach","while","switch","do","lock",
+ };
+
+ // ── Output helpers ────────────────────────────────────────────────────
+ private void NewLine() { cur = new LineBuf(); lines.Add(cur); }
+
+ private void RawHtml(string h) { cur.Sb.Append(h); if (tagHl) cur.Hl = true; }
+
+ private void Out(char c)
+ {
+ if (c == '\n') { NewLine(); return; }
+ if (c == '\r') return;
+ switch (c)
+ {
+ case '&': cur.Sb.Append("&"); break;
+ case '<': cur.Sb.Append("<"); break;
+ case '>': cur.Sb.Append(">"); break;
+ default: cur.Sb.Append(c); break;
+ }
+ if (tagHl) cur.Hl = true;
+ }
+
+ private void Plain(char c) => Out(c);
+ private void Plain(string t) { foreach (var c in t) Out(c); }
+
+ private void Span(string t, string cls)
+ {
+ int start = 0;
+ for (int k = 0; k < t.Length; k++)
+ {
+ if (t[k] == '\n')
+ {
+ SpanSeg(t.Substring(start, k - start), cls);
+ NewLine();
+ start = k + 1;
+ }
+ }
+ SpanSeg(t.Substring(start), cls);
+ }
+
+ private void SpanSeg(string seg, string cls)
+ {
+ if (seg.Length == 0) return;
+ RawHtml("");
+ Plain(seg);
+ RawHtml(" ");
+ }
+
+ private bool Match(string m) => i + m.Length <= n && string.CompareOrdinal(s, i, m, 0, m.Length) == 0;
+ private void SkipWs() { while (i < n && char.IsWhiteSpace(s[i])) { Plain(s[i]); i++; } }
+
+ private string ReadIdent()
+ {
+ int st = i;
+ while (i < n && (char.IsLetterOrDigit(s[i]) || s[i] == '_')) i++;
+ return s.Substring(st, i - st);
+ }
+
+ // ── Markup mode ───────────────────────────────────────────────────────
+ private void Markup(bool untilCloseBrace)
+ {
+ while (i < n)
+ {
+ char c = s[i];
+ if (untilCloseBrace && c == '}') { Plain('}'); i++; return; }
+ if (c == '@')
+ {
+ if (i + 1 < n && s[i + 1] == '*') RazorComment();
+ else RazorAt();
+ continue;
+ }
+ if (c == '<')
+ {
+ if (Match("")) i++;
+ if (i < n) i += 3;
+ Span(s.Substring(st, i - st), "tok-com");
+ }
+
+ private void Tag()
+ {
+ RawHtml("");
+ Out(s[i]); i++; // '<'
+ if (i < n && s[i] == '/') { Out('/'); i++; }
+ int st = i;
+ while (i < n && (char.IsLetterOrDigit(s[i]) || s[i] == '-' || s[i] == ':' || s[i] == '_')) { Out(s[i]); i++; }
+ string name = s.Substring(st, i - st);
+ RawHtml(" ");
+
+ bool bm = BmotionTags.Contains(name);
+ if (bm) { tagHl = true; cur.Hl = true; }
+
+ while (i < n)
+ {
+ char c = s[i];
+ if (c == '>') { Span(">", "tok-tag"); i++; if (bm) tagHl = false; return; }
+ if (c == '/' && i + 1 < n && s[i + 1] == '>') { Span("/>", "tok-tag"); i += 2; if (bm) tagHl = false; return; }
+ if (char.IsWhiteSpace(c)) { Plain(c); i++; continue; }
+ if (c == '@')
+ {
+ i++;
+ string w = ReadIdent();
+ Span("@" + w, "tok-attr");
+ continue;
+ }
+ if (char.IsLetter(c) || c == '_')
+ {
+ int as_ = i;
+ while (i < n && (char.IsLetterOrDigit(s[i]) || s[i] == '-' || s[i] == ':' || s[i] == '_')) i++;
+ Span(s.Substring(as_, i - as_), "tok-attr");
+ continue;
+ }
+ if (c == '=') { Plain('='); i++; AttrValue(); continue; }
+ Plain(c); i++;
+ }
+ if (bm) tagHl = false;
+ }
+
+ private void AttrValue()
+ {
+ while (i < n && char.IsWhiteSpace(s[i])) { Plain(s[i]); i++; }
+ if (i >= n) return;
+ char c = s[i];
+ if (c == '"' || c == '\'')
+ {
+ char q = c;
+ Span(q.ToString(), "tok-str"); i++;
+ var buf = new StringBuilder();
+ void Flush() { if (buf.Length > 0) { Span(buf.ToString(), "tok-str"); buf.Clear(); } }
+ while (i < n)
+ {
+ char d = s[i];
+ if (d == q) { Flush(); Span(q.ToString(), "tok-str"); i++; return; }
+ if (d == '@')
+ {
+ Flush();
+ if (i + 1 < n && s[i + 1] == '*') RazorComment();
+ else RazorAt();
+ continue;
+ }
+ buf.Append(d); i++;
+ }
+ Flush();
+ }
+ else
+ {
+ int st = i;
+ while (i < n && !char.IsWhiteSpace(s[i]) && s[i] != '>' && s[i] != '/') i++;
+ if (i > st) Span(s.Substring(st, i - st), "tok-str");
+ }
+ }
+
+ // ── Razor transition ──────────────────────────────────────────────────
+ private void RazorAt()
+ {
+ i++; // consume '@'
+ if (i >= n) { Span("@", "tok-raz"); return; }
+ char c = s[i];
+ if (c == '(' || c == '{') { Span("@", "tok-raz"); CSharpGroup(); return; }
+ if (char.IsLetter(c) || c == '_')
+ {
+ string w = ReadIdent();
+ if (SingleLineDirectives.Contains(w)) { Span("@" + w, "tok-key"); DirectiveLine(); return; }
+ if (BodyDirectives.Contains(w)) { Span("@" + w, "tok-key"); SkipWs(); if (i < n && s[i] == '{') CSharpGroup(); return; }
+ if (BlockKeywords.Contains(w))
+ {
+ Span("@" + w, "tok-key");
+ SkipWs();
+ if (i < n && s[i] == '(') CSharpGroup();
+ SkipWs();
+ if (i < n && s[i] == '{') MarkupGroup();
+ return;
+ }
+ Span("@", "tok-raz"); EmitIdentCS(w); MemberTail(); return;
+ }
+ Span("@", "tok-raz");
+ }
+
+ private void MemberTail()
+ {
+ while (i < n)
+ {
+ char c = s[i];
+ if (c == '.' && i + 1 < n && (char.IsLetter(s[i + 1]) || s[i + 1] == '_'))
+ {
+ Plain('.'); i++; EmitIdentCS(ReadIdent()); continue;
+ }
+ if (c == '(' || c == '[') { CSharpGroup(); continue; }
+ break;
+ }
+ }
+
+ private void DirectiveLine()
+ {
+ while (i < n && s[i] != '\n')
+ {
+ char c = s[i];
+ if (char.IsLetter(c) || c == '_') { EmitIdentCS(ReadIdent()); continue; }
+ if (char.IsDigit(c)) { Number(); continue; }
+ Plain(c); i++;
+ }
+ }
+
+ // ── C# mode ─────────────────────────────────────────────────────────────
+ private void CSharpGroup()
+ {
+ Plain(s[i]); i++; // opening bracket
+ int depth = 1;
+ while (i < n && depth > 0)
+ {
+ char c = s[i];
+ if (c == '"' || c == '\'') { CSharpString(c); continue; }
+ if (c == '$' && i + 1 < n && s[i + 1] == '"') { InterpString(); continue; }
+ if (c == '@' && i + 1 < n && s[i + 1] == '"') { VerbatimString(); continue; }
+ if (c == '/' && i + 1 < n && s[i + 1] == '/') { LineComment(); continue; }
+ if (c == '/' && i + 1 < n && s[i + 1] == '*') { BlockComment(); continue; }
+ if (c == '(' || c == '{' || c == '[') { depth++; Plain(c); i++; continue; }
+ if (c == ')' || c == '}' || c == ']') { depth--; Plain(c); i++; continue; }
+ if (char.IsLetter(c) || c == '_') { EmitIdentCS(ReadIdent()); continue; }
+ if (char.IsDigit(c)) { Number(); continue; }
+ Plain(c); i++;
+ }
+ }
+
+ private void EmitIdentCS(string w)
+ {
+ if (w.Length == 0) return;
+ if (Keywords.Contains(w)) Span(w, "tok-key");
+ else if (char.IsUpper(w[0])) Span(w, "tok-type");
+ else Plain(w);
+ }
+
+ private void Number()
+ {
+ int st = i;
+ while (i < n && char.IsDigit(s[i])) i++;
+ if (i < n && s[i] == '.' && i + 1 < n && char.IsDigit(s[i + 1]))
+ {
+ i++;
+ while (i < n && char.IsDigit(s[i])) i++;
+ }
+ if (i < n && "fFdDmMlLuU".IndexOf(s[i]) >= 0) i++;
+ Span(s.Substring(st, i - st), "tok-num");
+ }
+
+ private void CSharpString(char q)
+ {
+ int st = i; i++;
+ while (i < n)
+ {
+ char c = s[i];
+ if (c == '\\' && i + 1 < n) { i += 2; continue; }
+ if (c == q) { i++; break; }
+ i++;
+ }
+ Span(s.Substring(st, i - st), "tok-str");
+ }
+
+ private void InterpString()
+ {
+ int st = i; i += 2; // $"
+ int brace = 0;
+ while (i < n)
+ {
+ char c = s[i];
+ if (c == '\\' && i + 1 < n) { i += 2; continue; }
+ if (c == '{') { if (i + 1 < n && s[i + 1] == '{') { i += 2; continue; } brace++; i++; continue; }
+ if (c == '}') { if (i + 1 < n && s[i + 1] == '}') { i += 2; continue; } if (brace > 0) brace--; i++; continue; }
+ if (c == '"' && brace == 0) { i++; break; }
+ if (c == '"' && brace > 0)
+ {
+ i++;
+ while (i < n) { char d = s[i]; if (d == '\\' && i + 1 < n) { i += 2; continue; } if (d == '"') { i++; break; } i++; }
+ continue;
+ }
+ i++;
+ }
+ Span(s.Substring(st, i - st), "tok-str");
+ }
+
+ private void VerbatimString()
+ {
+ int st = i; i += 2; // @"
+ while (i < n)
+ {
+ char c = s[i];
+ if (c == '"') { if (i + 1 < n && s[i + 1] == '"') { i += 2; continue; } i++; break; }
+ i++;
+ }
+ Span(s.Substring(st, i - st), "tok-str");
+ }
+
+ private void LineComment()
+ {
+ int st = i;
+ while (i < n && s[i] != '\n') i++;
+ Span(s.Substring(st, i - st), "tok-com");
+ }
+
+ private void BlockComment()
+ {
+ int st = i; i += 2;
+ while (i < n && !(s[i] == '*' && i + 1 < n && s[i + 1] == '/')) i++;
+ if (i < n) i += 2;
+ Span(s.Substring(st, i - st), "tok-com");
+ }
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor b/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor
new file mode 100644
index 0000000000..7c000e9596
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor
@@ -0,0 +1,12 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Components.WebAssembly.Http
+@using Microsoft.JSInterop
+@using Bit.Bmotion.Demos
+@using Bit.Bmotion.Demos.Layout
+@using Bit.Bmotion.Demos.Shared
+@using Bit.Bmotion
diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css
new file mode 100644
index 0000000000..a87a1467b2
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css
@@ -0,0 +1,82 @@
+html, body {
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+h1:focus {
+ outline: none;
+}
+
+h1:focus-visible {
+ outline: 2px solid #0071c1;
+ outline-offset: 2px;
+}
+
+a, .btn-link {
+ color: #0071c1;
+}
+
+#blazor-error-ui {
+ background: lightyellow;
+ bottom: 0;
+ box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+ display: none;
+ left: 0;
+ padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+ position: fixed;
+ width: 100%;
+ z-index: 1000;
+}
+
+ #blazor-error-ui .dismiss {
+ cursor: pointer;
+ position: absolute;
+ right: 0.75rem;
+ top: 0.5rem;
+ }
+
+.blazor-error-boundary {
+ background: #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
+
+.loading-progress {
+ position: relative;
+ display: block;
+ width: 8rem;
+ height: 8rem;
+ margin: 20vh auto 1rem auto;
+}
+
+ .loading-progress circle {
+ fill: none;
+ stroke: #e0e0e0;
+ stroke-width: 0.6rem;
+ transform-origin: 50% 50%;
+ transform: rotate(-90deg);
+ }
+
+ .loading-progress circle:last-child {
+ stroke: #1b6ec2;
+ stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
+ transition: stroke-dasharray 0.05s ease-in-out;
+ }
+
+.loading-progress-text {
+ position: absolute;
+ text-align: center;
+ font-weight: bold;
+ inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
+}
+
+ .loading-progress-text:after {
+ content: var(--blazor-load-percentage-text, "Loading");
+ }
+
+code {
+ color: #c02d76;
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css
new file mode 100644
index 0000000000..fc35ed5b3a
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css
@@ -0,0 +1,284 @@
+/* Bit.Bmotion Demos - demo styles */
+:root {
+ --bm-primary: #6c47ff;
+ --bm-secondary: #ff4785;
+ --bm-dark: #0a0a0f;
+ --bm-surface: #141420;
+ --bm-card: #1e1e2e;
+ --bm-text: #e8e8f0;
+ --bm-muted: #888;
+ --bm-radius: 12px;
+}
+
+body {
+ background: var(--bm-dark);
+ color: var(--bm-text);
+ font-family: 'Segoe UI', system-ui, sans-serif;
+}
+
+/* ── Layout ────────────────────────────────────────────────────────────────── */
+
+.bm-nav {
+ background: var(--bm-surface);
+ border-bottom: 1px solid rgba(255,255,255,.08);
+ padding: .75rem 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+.bm-nav .logo {
+ font-weight: 800;
+ font-size: 1.2rem;
+ background: linear-gradient(135deg, var(--bm-primary), var(--bm-secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+.bm-nav a {
+ color: var(--bm-muted);
+ text-decoration: none;
+ font-size: .9rem;
+ transition: color .2s;
+}
+.bm-nav a:hover, .bm-nav a.active { color: var(--bm-text); }
+.bm-nav a:focus-visible {
+ color: var(--bm-text);
+ outline: 2px solid var(--bm-primary);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.bm-page {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
+}
+
+/* ── Demo cards ────────────────────────────────────────────────────────────── */
+
+.demo-section {
+ margin-bottom: 3rem;
+}
+.demo-section h2 {
+ font-size: 1.4rem;
+ font-weight: 700;
+ margin-bottom: .5rem;
+}
+.demo-section p {
+ color: var(--bm-muted);
+ margin-bottom: 1.5rem;
+ font-size: .95rem;
+ line-height: 1.6;
+}
+.demo-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+ align-items: center;
+}
+.demo-card {
+ background: var(--bm-card);
+ border: 1px solid rgba(255,255,255,.07);
+ border-radius: var(--bm-radius);
+ padding: 2rem;
+ min-height: 160px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-width: 220px;
+}
+
+/* ── Motion boxes ──────────────────────────────────────────────────────────── */
+
+.box {
+ width: 80px;
+ height: 80px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, var(--bm-primary), var(--bm-secondary));
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-weight: 700;
+ user-select: none;
+}
+.box-sm { width: 50px; height: 50px; border-radius: 8px; }
+
+/* ── Buttons ───────────────────────────────────────────────────────────────── */
+
+.btn-bm {
+ background: var(--bm-primary);
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ padding: .6rem 1.4rem;
+ font-size: .9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity .2s;
+}
+.btn-bm:hover { opacity: .85; }
+.btn-bm:focus-visible {
+ opacity: .85;
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+}
+
+/* ── Code snippets ─────────────────────────────────────────────────────────── */
+
+.code-block {
+ background: #0d0d1a;
+ border: 1px solid rgba(255,255,255,.1);
+ border-radius: 8px;
+ padding: 1rem 1.25rem;
+ font-family: 'Cascadia Code', 'Fira Code', monospace;
+ font-size: .82rem;
+ color: #c8c8e0;
+ overflow-x: auto;
+ margin-top: 1rem;
+ white-space: pre;
+}
+
+/* ── Code snippet toggle ───────────────────────────────────────────────────── */
+
+.code-snippet {
+ margin-top: 1rem;
+}
+.code-snippet-bar {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+}
+.code-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: .4rem;
+ background: transparent;
+ border: 1px solid rgba(255,255,255,.15);
+ border-radius: 6px;
+ color: var(--bm-muted);
+ font-size: .8rem;
+ font-weight: 600;
+ padding: .4rem .8rem;
+ cursor: pointer;
+ transition: color .2s, border-color .2s, background .2s;
+}
+.code-toggle:hover {
+ color: #fff;
+ border-color: var(--bm-primary);
+ background: rgba(108,71,255,.12);
+}
+.code-toggle:focus-visible {
+ color: #fff;
+ border-color: var(--bm-primary);
+ background: rgba(108,71,255,.12);
+ outline: 2px solid var(--bm-primary);
+ outline-offset: 2px;
+}
+.code-toggle-icon {
+ font-size: .7rem;
+ line-height: 1;
+}
+.code-copy {
+ display: inline-flex;
+ align-items: center;
+ gap: .35rem;
+ background: transparent;
+ border: 1px solid rgba(255,255,255,.15);
+ border-radius: 6px;
+ color: var(--bm-muted);
+ font-size: .8rem;
+ font-weight: 600;
+ padding: .4rem .8rem;
+ cursor: pointer;
+ transition: color .2s, border-color .2s, background .2s;
+}
+.code-copy:hover {
+ color: #fff;
+ border-color: var(--bm-primary);
+ background: rgba(108,71,255,.12);
+}
+.code-copy:focus-visible {
+ color: #fff;
+ border-color: var(--bm-primary);
+ background: rgba(108,71,255,.12);
+ outline: 2px solid var(--bm-primary);
+ outline-offset: 2px;
+}
+.code-snippet .code-block {
+ margin-top: .75rem;
+}
+.code-snippet .code-block code {
+ display: block;
+ color: inherit;
+ background: none;
+ font-family: inherit;
+ font-size: inherit;
+}
+
+/* ── Syntax highlighting tokens ────────────────────────────────────────────── */
+
+.code-block .tok-com { color: #6a9955; font-style: italic; }
+.code-block .tok-tag { color: #569cd6; }
+.code-block .tok-attr { color: #9cdcfe; }
+.code-block .tok-str { color: #ce9178; }
+.code-block .tok-key { color: #569cd6; }
+.code-block .tok-type { color: #4ec9b0; }
+.code-block .tok-num { color: #b5cea8; }
+.code-block .tok-raz { color: #c586c0; }
+
+/* Each source line is its own block so highlighted and plain lines share uniform spacing. */
+.code-block .code-line {
+ display: block;
+}
+
+/* Lines where a Bmotion component is used, so they stand out from the markup. */
+.code-block .hl-line {
+ display: block;
+ background: rgba(108,71,255,.16);
+ box-shadow: inset 3px 0 0 var(--bm-primary);
+ margin: 0 -1.25rem;
+ padding: 0 1.25rem;
+}
+
+/* ── Page hero ─────────────────────────────────────────────────────────────── */
+
+.page-hero {
+ text-align: center;
+ padding: 4rem 0 3rem;
+}
+.page-hero h1 {
+ font-size: clamp(2rem, 6vw, 3.5rem);
+ font-weight: 900;
+ line-height: 1.1;
+ margin-bottom: 1rem;
+ background: linear-gradient(135deg, #fff 30%, var(--bm-primary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+.page-hero p {
+ color: var(--bm-muted);
+ font-size: 1.15rem;
+ max-width: 600px;
+ margin: 0 auto 2rem;
+ line-height: 1.6;
+}
+.badge-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .5rem;
+ justify-content: center;
+}
+.badge {
+ background: rgba(108,71,255,.2);
+ border: 1px solid rgba(108,71,255,.4);
+ color: #a88cff;
+ padding: .3rem .8rem;
+ border-radius: 999px;
+ font-size: .8rem;
+ font-weight: 600;
+}
diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html
new file mode 100644
index 0000000000..2d4de2cf61
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ Bit.Bmotion.Demos
+
+
+
+
+
+
+
+
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
+
+
+
+
+
diff --git a/src/Bmotion/Bit.Bmotion.slnx b/src/Bmotion/Bit.Bmotion.slnx
new file mode 100644
index 0000000000..1e17825ab5
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion.slnx
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj
new file mode 100644
index 0000000000..ff2b6cc034
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj
@@ -0,0 +1,40 @@
+
+
+
+
+
+ net10.0;net9.0;net8.0
+ Bit.Bmotion
+ enable
+ true
+
+ $(NoWarn);IL2091
+
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bmotion/Bit.Bmotion/BitBmotion.cs b/src/Bmotion/Bit.Bmotion/BitBmotion.cs
new file mode 100644
index 0000000000..faa3f394cb
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/BitBmotion.cs
@@ -0,0 +1,66 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Bmotion;
+
+///
+/// Extension methods to register Bit.Bmotion services in the DI container.
+///
+///
+///
+/// Platform support: Bit.Bmotion runs its animation loop over synchronous JS interop
+/// and is therefore supported on Blazor WebAssembly only . It does not work on Blazor
+/// Server, and it is inert during server-side prerendering (animations start once the WASM runtime
+/// is interactive). Attempting to start the animation loop on a non-WebAssembly host throws
+/// .
+///
+///
+public static class BitBmotion
+{
+ ///
+ /// Registers all Bit.Bmotion services.
+ /// Call this in Program.cs before builder.Build() :
+ /// builder.Services.AddBitBmotionServices();
+ ///
+ /// Blazor WebAssembly only - see the remarks on for details.
+ ///
+ ///
+ public static IServiceCollection AddBitBmotionServices(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ // Keep the JSON-marshaled interop DTOs trim/AOT-safe. These types cross the JS↔.NET
+ // boundary via reflection-based System.Text.Json (InvokeAsync, [JSInvokable] params),
+ // so the trimmer can't see their members as used and would otherwise strip them - leading
+ // to empty/failed deserialization in published WebAssembly builds.
+ PreserveInteropContracts();
+
+ // Slim browser-API interop bridge - one instance per DI scope
+ services.AddScoped();
+
+ // C# animation engine - drives all animation math in WebAssembly
+ services.AddScoped();
+
+ // Higher-level services
+ // BmotionScrollTracker is owned and disposed by the consuming component (like
+ // Framer Motion's per-component useScroll), so it must be transient.
+ // A scoped (app-lifetime in WASM) instance would be disposed by the first
+ // component to unmount, leaving its DotNetObjectReference disposed and
+ // causing ObjectDisposedException when another component re-observes.
+ services.AddTransient();
+ services.AddTransient();
+ services.AddScoped();
+
+ return services;
+ }
+
+ // Roots the public properties + parameterless constructors of every type that is (de)serialized
+ // across JS interop so the trimmer preserves them. The [DynamicDependency] attributes take
+ // effect because this method is reachable from AddBitBmotionServices (an app entry point).
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionBoundingRect))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionScrollInfo))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionXY))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionViewportOptions))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionDragConstraints))]
+ private static void PreserveInteropContracts() { }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs
new file mode 100644
index 0000000000..8f716c59c7
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs
@@ -0,0 +1,807 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.JSInterop;
+
+namespace Bit.Bmotion;
+///
+/// The primary animation component - a drop-in replacement for any HTML element.
+/// Animation math runs in the C# ; JS is used only
+/// for DOM style mutation, pointer/focus events, viewport observation and FLIP.
+///
+public sealed class Bmotion : ComponentBase, IAsyncDisposable
+{
+ // ── Injected services ──────────────────────────────────────────────────────
+ [Inject] private BmotionAnimationEngine Engine { get; set; } = null!;
+ [Inject] private BmotionInterop Interop { get; set; } = null!;
+
+ // ── Cascaded contexts ──────────────────────────────────────────────────────
+ [CascadingParameter] private BmotionPresenceContext? PresenceCtx { get; set; }
+ [CascadingParameter] private BmotionVariantContext? VariantCtx { get; set; }
+ [CascadingParameter] private BmotionConfigContext? ConfigCtx { get; set; }
+
+ // ── Core rendering parameters ────────────────────────────────────────────
+ [Parameter] public string Tag { get; set; } = "div";
+ [Parameter] public string? Class { get; set; }
+ [Parameter] public string? Style { get; set; }
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary? AdditionalAttributes { get; set; }
+
+ // ── Animation targets ──────────────────────────────────────────────────────
+ [Parameter] public BmotionAnimationTarget? Initial { get; set; }
+ [Parameter] public BmotionAnimationTarget? Animate { get; set; }
+ [Parameter] public BmotionAnimationTarget? Exit { get; set; }
+
+ // ── Gesture states ─────────────────────────────────────────────────────────
+ [Parameter] public BmotionAnimationTarget? WhileHover { get; set; }
+ [Parameter] public BmotionAnimationTarget? WhileTap { get; set; }
+ [Parameter] public BmotionAnimationTarget? WhileFocus { get; set; }
+ [Parameter] public BmotionAnimationTarget? WhileDrag { get; set; }
+ [Parameter] public BmotionAnimationTarget? WhileInView { get; set; }
+
+ ///
+ /// If true , fires only once and never deactivates.
+ /// Shorthand for Viewport = new BmotionViewportOptions { Once = true } .
+ ///
+ [Parameter] public bool Once { get; set; }
+
+ ///
+ /// Advanced viewport options for (margin, amount, once).
+ /// When set, is ignored in favour of Viewport.Once .
+ ///
+ [Parameter] public BmotionViewportOptions? Viewport { get; set; }
+
+ // ── Transition ─────────────────────────────────────────────────────────────
+ [Parameter] public BmotionTransitionConfig? Transition { get; set; }
+
+ // ── Variants ─────────────────────────────────────────────────────────────
+ [Parameter] public BmotionMotionVariants? Variants { get; set; }
+
+ // ── Drag ─────────────────────────────────────────────────────────────────
+ [Parameter] public bool Drag { get; set; }
+ [Parameter] public BmotionDragOptions? DragOptions { get; set; }
+
+ // ── Layout ─────────────────────────────────────────────────────────────────
+ [Parameter] public bool Layout { get; set; }
+
+ // ── Events ─────────────────────────────────────────────────────────────────
+ [Parameter] public EventCallback OnHoverStart { get; set; }
+ [Parameter] public EventCallback OnHoverEnd { get; set; }
+ [Parameter] public EventCallback OnTapStart { get; set; }
+ [Parameter] public EventCallback OnTap { get; set; }
+ [Parameter] public EventCallback OnTapCancel { get; set; }
+ [Parameter] public EventCallback OnFocusStart { get; set; }
+ [Parameter] public EventCallback OnFocusEnd { get; set; }
+ [Parameter] public EventCallback OnPanStart { get; set; }
+ [Parameter] public EventCallback OnPan { get; set; }
+ [Parameter] public EventCallback OnPanEnd { get; set; }
+ [Parameter] public EventCallback OnDragStart { get; set; }
+ [Parameter] public EventCallback OnDrag { get; set; }
+ [Parameter] public EventCallback OnDragEnd { get; set; }
+ [Parameter] public EventCallback OnAnimationStart { get; set; }
+ [Parameter] public EventCallback OnAnimationComplete { get; set; }
+ [Parameter] public EventCallback OnViewportEnter { get; set; }
+ [Parameter] public EventCallback OnViewportLeave { get; set; }
+
+ // ── Internal state ─────────────────────────────────────────────────────────
+ private readonly string _id = $"bm-{Guid.NewGuid():N}";
+ private ElementReference _ref;
+ private DotNetObjectReference? _dotnet;
+ private bool _initialized;
+ private bool _isExiting;
+ private BmotionAnimationTarget? _prevAnimate;
+ private BmotionVariantContext? _ownVariantCtx;
+ private string? _prevInheritedVariant;
+ private int _variantChildIndex = -1;
+ private BmotionBoundingRect? _layoutSnapshot;
+ // The style string most recently emitted into the render tree, and the one we've reconciled
+ // with the engine. When these diverge after init, Blazor has rewritten the element's inline
+ // style, so we re-flush the engine's live values on top to avoid resetting animated props.
+ private string _pendingStyle = string.Empty;
+ private string _committedStyle = string.Empty;
+ // Signatures of the gesture-event flags and viewport options currently wired up in JS. Compared
+ // each update so listeners/observers are re-attached only when the effective configuration
+ // changes (gestures are otherwise wired once and would ignore later parameter changes).
+ private string _eventFlagsSig = string.Empty;
+ private string? _viewportSig;
+ // Tracks whether WhileInView was set on the previous reconcile, so its removal can clear the
+ // active in-view gesture layer even when the (option-only) viewport signature is unchanged.
+ private bool _whileInViewSet;
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Rendering
+ // ════════════════════════════════════════════════════════════════════════════
+
+ /// The element tag to render, normalized to "div" when null or blank so
+ /// always receives a valid tag name.
+ private string EffectiveTag => string.IsNullOrWhiteSpace(Tag) ? "div" : Tag;
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ // Sequence numbers are fixed literals (one per logical slot) rather than a running counter.
+ // Blazor's diffing assumes stable sequence numbers; computing them dynamically alongside
+ // conditional attributes shifts the numbers between renders and degrades diffing.
+ builder.OpenElement(0, EffectiveTag);
+ builder.AddAttribute(1, "id", _id);
+
+ if (AdditionalAttributes != null)
+ builder.AddMultipleAttributes(2,
+ AdditionalAttributes.Where(kvp => !string.Equals(kvp.Key, "id", StringComparison.OrdinalIgnoreCase)));
+
+ // Auto-inject pathLength="1" so normalized [0,1] dasharray coordinates work correctly
+ if (NeedsPathLengthAttr())
+ builder.AddAttribute(3, "pathLength", "1");
+
+ if (!string.IsNullOrEmpty(Class))
+ builder.AddAttribute(4, "class", Class);
+
+ var motionStyle = BuildInitialStyle();
+ var combinedStyle = string.IsNullOrEmpty(Style) ? motionStyle : motionStyle + Style;
+ _pendingStyle = combinedStyle ?? string.Empty;
+ if (!string.IsNullOrEmpty(combinedStyle))
+ builder.AddAttribute(5, "style", combinedStyle);
+
+ builder.AddElementReferenceCapture(6, r => _ref = r);
+
+ if (Variants != null)
+ {
+ // Fall back to an inherited active variant from an ancestor so nested variant trees
+ // propagate the active label when this component doesn't set its own Animate.Variant.
+ var active = Animate?.IsVariant == true ? Animate.Variant : VariantCtx?.ActiveVariant;
+ // Mirror the ActiveVariant fallback: descendants inherit the initial variant label from
+ // an ancestor when this node defines Variants without its own local Initial variant.
+ var initial = Initial?.IsVariant == true ? Initial.Variant : VariantCtx?.InitialVariant;
+ var stagger = Transition?.StaggerChildren ?? 0;
+ var delayChildren = Transition?.DelayChildren ?? 0;
+
+ // A cascaded context whose fields are mutated in place does NOT reliably notify
+ // descendants (CascadingValue change-detection is reference-based). So when any cascaded
+ // value actually changes, publish a NEW context instance - the changed reference forces
+ // CascadingValue to re-notify children. The child-index counter is carried over so any
+ // child registering after the swap still gets a stable stagger position.
+ if (_ownVariantCtx is null ||
+ _ownVariantCtx.ActiveVariant != active ||
+ _ownVariantCtx.InitialVariant != initial ||
+ !ReferenceEquals(_ownVariantCtx.Variants, Variants) ||
+ _ownVariantCtx.StaggerChildren != stagger ||
+ _ownVariantCtx.DelayChildren != delayChildren)
+ {
+ var previous = _ownVariantCtx;
+ _ownVariantCtx = new BmotionVariantContext
+ {
+ ActiveVariant = active,
+ InitialVariant = initial,
+ Variants = Variants,
+ StaggerChildren = stagger,
+ DelayChildren = delayChildren,
+ };
+ if (previous != null) _ownVariantCtx.SeedChildIndex(previous.NextChildIndex);
+ }
+
+ builder.OpenComponent>(7);
+ builder.AddComponentParameter(8, "Value", _ownVariantCtx);
+ builder.AddComponentParameter(9, "ChildContent", ChildContent);
+ builder.CloseComponent();
+ }
+ else
+ {
+ builder.AddContent(10, ChildContent);
+ }
+ builder.CloseElement();
+ }
+
+ private string BuildInitialStyle()
+ {
+ // The initial inline style exists only to avoid a flash of unstyled content before interop
+ // initialises; it never changes after the first paint (the engine owns live styles from
+ // then on), so compute it once and reuse the cached string on subsequent renders.
+ if (_initialStyleCache != null) return _initialStyleCache;
+ var props = ResolveProps(Initial);
+ if (props == null && Animate == null && VariantCtx?.InitialVariant is string initVariant)
+ props = Variants?.Get(initVariant) ?? VariantCtx.Variants?.Get(initVariant);
+ return _initialStyleCache = props?.ToCssStyleString() ?? string.Empty;
+ }
+ private string? _initialStyleCache;
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Lifecycle
+ // ════════════════════════════════════════════════════════════════════════════
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ _dotnet = DotNetObjectReference.Create(this);
+ await InitialiseAsync();
+ _initialized = true;
+ // The initial inline style is what the engine seeded the DOM with; mark it reconciled.
+ _committedStyle = _pendingStyle;
+ }
+ else if (_initialized)
+ {
+ await HandleParameterUpdateAsync();
+
+ // FLIP: play layout animation after DOM settles
+ if (_layoutSnapshot != null)
+ {
+ var snap = _layoutSnapshot;
+ _layoutSnapshot = null;
+ await PlayFlipAsync(snap);
+ }
+
+ // If a re-render rewrote the inline style attribute (e.g. the consumer changed Style),
+ // Blazor will have wiped the engine's live transform/opacity/etc. Re-flush the current
+ // engine values on top so animated state isn't visibly reset.
+ if (_pendingStyle != _committedStyle)
+ {
+ _committedStyle = _pendingStyle;
+ var live = Engine.GetCurrentStyles(_id);
+ if (live is { Count: > 0 })
+ await Interop.ApplyStylesAsync(_id, live);
+ }
+ }
+ }
+
+ protected override async Task OnParametersSetAsync()
+ {
+ if (PresenceCtx is { IsExiting: true } && !_isExiting)
+ {
+ _isExiting = true;
+ if (_initialized) await PlayExitAsync();
+ }
+ else if (_isExiting && PresenceCtx is not { IsExiting: true })
+ {
+ // The presence re-entered before/after this element finished exiting. Clear the
+ // exiting flag (otherwise the component stays frozen forever) and force the enter
+ // animation to replay by invalidating the cached previous target.
+ _isExiting = false;
+ _prevAnimate = null;
+ _prevInheritedVariant = null;
+ PresenceCtx?.Register(this); // re-register in case the context was reset
+ }
+
+ // FLIP: snapshot BEFORE re-render
+ if (_initialized && Layout && !_isExiting)
+ _layoutSnapshot = await Interop.GetBoundingRectAsync(_id);
+ }
+
+ private async Task InitialiseAsync()
+ {
+ // Reduced-motion is opt-in: only probe the OS preference when this element is
+ // inside a . Elements without a config always animate normally.
+ if (ConfigCtx is not null)
+ await Engine.EnsureReducedMotionDetectedAsync();
+
+ // Register with C# engine (applies initial values synchronously)
+ var initProps = ResolveProps(Initial);
+ Engine.RegisterElement(_id, initProps?.ToJsDictionary());
+
+ // Mark element in the DOM for JS bridge
+ await Interop.RegisterElementAsync(_id);
+
+ PresenceCtx?.Register(this);
+
+ // Attach gesture listeners + viewport observation through the same reconciliation path used
+ // on later updates, so enabling/disabling a gesture after first render is handled uniformly.
+ await ReconcileEventListenersAsync();
+ await ReconcileViewportAsync();
+
+ // Start enter animation
+ if (Animate != null)
+ {
+ var animateProps = ResolveProps(Animate);
+ if (animateProps != null)
+ {
+ await OnAnimationStart.InvokeAsync();
+ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(),
+ () => OnAnimationComplete.InvokeAsync(), setAsBase: true);
+ }
+ }
+ else if (VariantCtx != null && (Variants != null || VariantCtx.Variants != null))
+ {
+ // Claim a stable stagger position on first render even when no variant is active yet,
+ // so a parent that switches its variant later (the common hidden→visible toggle) still
+ // staggers its children in render order instead of collapsing every delay to zero.
+ _variantChildIndex = VariantCtx.RegisterChild();
+ if (VariantCtx.ActiveVariant is string inheritedVariant)
+ {
+ _prevInheritedVariant = inheritedVariant;
+ var props = Variants?.Get(inheritedVariant) ?? VariantCtx.Variants?.Get(inheritedVariant);
+ if (props != null)
+ await Engine.AnimateToAsync(_id, props.ToJsDictionary(),
+ BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex)),
+ setAsBase: true);
+ }
+ }
+
+ _prevAnimate = Animate;
+ }
+
+ private async Task HandleParameterUpdateAsync()
+ {
+ if (_isExiting) return;
+
+ // Recovery: if the engine evicted this element after a driver fault (see
+ // BmotionAnimationEngine.ComputeFrame), it silently stopped animating. Re-register and
+ // re-seed it here so a subsequent parameter change brings it back to life.
+ if (!Engine.IsRegistered(_id))
+ {
+ var seed = ResolveProps(Initial);
+ Engine.RegisterElement(_id, seed?.ToJsDictionary());
+ _prevAnimate = null; // force the animate below to replay
+ _prevInheritedVariant = null; // and the variant path too
+ }
+
+ // Gesture listeners and viewport observation are wired once at init; re-wire them when the
+ // set of needed events / viewport options changes so gestures enabled (or disabled) after
+ // the first render actually take effect.
+ await ReconcileEventListenersAsync();
+ await ReconcileViewportAsync();
+
+ if (!BmotionAnimationTarget.AreEquivalent(_prevAnimate, Animate))
+ {
+ var animateProps = ResolveProps(Animate);
+ if (animateProps != null)
+ {
+ await OnAnimationStart.InvokeAsync();
+ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(),
+ () => OnAnimationComplete.InvokeAsync(), setAsBase: true);
+ }
+ _prevAnimate = Animate;
+ }
+ // Not an "else": when Animate transitions to null the block above still runs (the targets
+ // differ) but applies nothing, so the inherited-variant fallback must be free to run in the
+ // same update cycle rather than being deferred to a later rerender.
+ if (Animate == null && (Variants != null || VariantCtx?.Variants != null))
+ {
+ var newVariant = VariantCtx?.ActiveVariant;
+ if (newVariant != _prevInheritedVariant)
+ {
+ _prevInheritedVariant = newVariant;
+ if (newVariant != null)
+ {
+ var props = Variants?.Get(newVariant) ?? VariantCtx?.Variants?.Get(newVariant);
+ if (props != null)
+ {
+ double delay = _variantChildIndex >= 0 ? VariantCtx!.GetChildDelay(_variantChildIndex) : 0;
+ await Engine.AnimateToAsync(_id, props.ToJsDictionary(),
+ BuildEffectiveTransitionWithDelay(delay), setAsBase: true);
+ }
+ }
+ }
+ }
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Exit & FLIP
+ // ════════════════════════════════════════════════════════════════════════════
+
+ internal async Task PlayExitAsync()
+ {
+ var exitProps = ResolveProps(Exit);
+ if (exitProps != null)
+ await Engine.AnimateToAwaitAsync(_id, exitProps.ToJsDictionary(), BuildEffectiveTransition());
+ PresenceCtx?.NotifyExitComplete(this);
+ }
+
+ private async Task PlayFlipAsync(BmotionBoundingRect snap)
+ {
+ var cur = await Interop.GetBoundingRectAsync(_id);
+ if (cur == null) return;
+
+ double dx = snap.Left - cur.Left;
+ double dy = snap.Top - cur.Top;
+ double sx = cur.Width > 0 ? snap.Width / cur.Width : 1;
+ double sy = cur.Height > 0 ? snap.Height / cur.Height : 1;
+
+ if (Math.Abs(dx) < 0.5 && Math.Abs(dy) < 0.5 && Math.Abs(sx - 1) < 0.005 && Math.Abs(sy - 1) < 0.005)
+ return;
+
+ var t = BuildEffectiveTransition();
+ double dur = t?.Type == BmotionTransitionType.Spring ? 600 : (t?.Duration ?? 0.5) * 1000;
+ string easing = t?.Type == BmotionTransitionType.Spring
+ ? "cubic-bezier(0.14,1,0.34,1)"
+ : BmotionEasingFunctions.ToCssString(t);
+ string? finalT = Engine.GetCurrentTransformString(_id);
+
+ // Pause the engine's per-frame transform writes for the duration of the FLIP so the rAF
+ // loop and the WAAPI layout animation don't both write `transform` and tear each other.
+ Engine.SuspendTransformWrites(_id, dur);
+
+ await Interop.PlayWaapiFlipAsync(_id, dx, dy, sx, sy, dur, easing, finalT);
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Programmatic API
+ // ════════════════════════════════════════════════════════════════════════════
+
+ public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null)
+ {
+ transition ??= BuildEffectiveTransition();
+ await Engine.AnimateToAsync(_id, props.ToJsDictionary(), transition);
+ }
+
+ public void Set(BmotionAnimationProps props) => Engine.SetInstant(_id, props.ToJsDictionary());
+
+ public async ValueTask SetAsync(BmotionAnimationProps props)
+ {
+ Engine.SetInstant(_id, props.ToJsDictionary());
+ // Flush synchronous style update to DOM as individual declarations (never via cssText,
+ // which would replace the element's entire inline style).
+ var styles = props.ToCssStyleDictionary();
+ if (styles.Count > 0)
+ await Interop.ApplyStylesAsync(_id, styles);
+ }
+
+ public void Stop(params string[] properties) => Engine.Stop(_id, properties.Length > 0 ? properties : null);
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // JS → C# callbacks (called from slim JS bridge)
+ // ════════════════════════════════════════════════════════════════════════════
+
+ // ── Hover ──────────────────────────────────────────────────────────────────
+ [JSInvokable]
+ public async Task OnPointerEnter()
+ {
+ var props = ResolveProps(WhileHover);
+ if (props != null)
+ await Engine.ActivateGestureLayerAsync(_id, "hover", props.ToJsDictionary(), BuildEffectiveTransition());
+ await OnHoverStart.InvokeAsync();
+ }
+
+ [JSInvokable]
+ public async Task OnPointerLeave()
+ {
+ // Deactivate unconditionally: if WhileHover was cleared while the hover layer was still
+ // active, a null guard here would strand the layer's styles. DeactivateGestureLayerAsync
+ // is a no-op when no matching layer is active.
+ await Engine.DeactivateGestureLayerAsync(_id, "hover");
+ await OnHoverEnd.InvokeAsync();
+ }
+
+ // ── Tap ──────────────────────────────────────────────────────────────────
+ [JSInvokable]
+ public async Task OnPointerDown()
+ {
+ var props = ResolveProps(WhileTap);
+ if (props != null)
+ await Engine.ActivateGestureLayerAsync(_id, "tap", props.ToJsDictionary(), BuildEffectiveTransition());
+ await OnTapStart.InvokeAsync();
+ }
+
+ [JSInvokable]
+ public async Task OnPointerUp(bool isInsideElement)
+ {
+ await Engine.DeactivateGestureLayerAsync(_id, "tap");
+ if (isInsideElement) await OnTap.InvokeAsync();
+ else await OnTapCancel.InvokeAsync(); // released outside the element ⇒ tap cancelled
+ }
+
+ [JSInvokable]
+ public async Task OnPointerCancel()
+ {
+ await Engine.DeactivateGestureLayerAsync(_id, "tap");
+ await OnTapCancel.InvokeAsync();
+ }
+
+ // ── Focus ──────────────────────────────────────────────────────────────────
+ [JSInvokable]
+ public async Task OnFocusIn()
+ {
+ var props = ResolveProps(WhileFocus);
+ if (props != null)
+ await Engine.ActivateGestureLayerAsync(_id, "focus", props.ToJsDictionary(), BuildEffectiveTransition());
+ await OnFocusStart.InvokeAsync();
+ }
+
+ [JSInvokable]
+ public async Task OnFocusOut()
+ {
+ await Engine.DeactivateGestureLayerAsync(_id, "focus");
+ await OnFocusEnd.InvokeAsync();
+ }
+
+ // ── Drag ──────────────────────────────────────────────────────────────────
+ [JSInvokable]
+ public async Task OnPointerDown_Drag()
+ {
+ var props = ResolveProps(WhileDrag);
+ if (props != null)
+ await Engine.ActivateGestureLayerAsync(_id, "drag", props.ToJsDictionary(), BuildEffectiveTransition());
+ await OnDragStart.InvokeAsync();
+ }
+
+ /// Called synchronously from JS for drag position updates (Blazor WASM only).
+ [JSInvokable] public void SetDragPosition(double x, double y) => Engine.SetDragPosition(_id, x, y);
+
+ /// Called synchronously from JS to get current XY for drag start offset (Blazor WASM only).
+ [JSInvokable]
+ public BmotionXY GetCurrentXY()
+ {
+ var (x, y) = Engine.GetCurrentXY(_id);
+ return new BmotionXY(x, y);
+ }
+
+ [JSInvokable] public async Task OnDragMove() => await OnDrag.InvokeAsync();
+
+ [JSInvokable]
+ public async Task OnPointerUp_Drag(double velX, double velY)
+ {
+ await Engine.DeactivateGestureLayerAsync(_id, "drag");
+
+ var dragOpt = DragOptions ?? new BmotionDragOptions();
+
+ if (dragOpt.SnapToOrigin)
+ {
+ var snapT = dragOpt.SnapTransition ?? new BmotionTransitionConfig
+ { Type = BmotionTransitionType.Spring, Stiffness = 400, Damping = 35 };
+ await Engine.AnimateToAsync(_id,
+ new Dictionary { ["x"] = 0.0, ["y"] = 0.0 }, snapT);
+ }
+ else
+ {
+ await Engine.EndDragAsync(
+ _id, velX, velY, dragOpt.Momentum, dragOpt.Constraints,
+ dragOpt.Axis == BmotionDragAxis.Both ? null : dragOpt.Axis.ToString().ToLowerInvariant(),
+ dragOpt.SnapTransition);
+ }
+
+ await OnDragEnd.InvokeAsync();
+ }
+
+ // ── Pan (pointer moves without moving the element) ──────────────────────────
+ [JSInvokable]
+ public async Task OnPanStart_() => await OnPanStart.InvokeAsync();
+
+ [JSInvokable]
+ public async Task OnPanMove(double pointX, double pointY,
+ double deltaX, double deltaY, double offsetX, double offsetY,
+ double velocityX, double velocityY)
+ {
+ if (OnPan.HasDelegate)
+ {
+ await OnPan.InvokeAsync(new BmotionPanInfo
+ {
+ Point = new BmotionPointInfo { X = pointX, Y = pointY },
+ Delta = new BmotionPointInfo { X = deltaX, Y = deltaY },
+ Offset = new BmotionPointInfo { X = offsetX, Y = offsetY },
+ Velocity = new BmotionPointInfo { X = velocityX, Y = velocityY },
+ });
+ }
+ }
+
+ [JSInvokable]
+ public async Task OnPanEnd_() => await OnPanEnd.InvokeAsync();
+
+ // ── Viewport ──────────────────────────────────────────────────────────────
+ [JSInvokable]
+ public async Task OnIntersect(bool isIntersecting)
+ {
+ if (isIntersecting)
+ {
+ var props = ResolveProps(WhileInView);
+ if (props != null)
+ await Engine.ActivateGestureLayerAsync(_id, "inview", props.ToJsDictionary(), BuildEffectiveTransition());
+ await OnViewportEnter.InvokeAsync();
+ }
+ else
+ {
+ if (!(Viewport?.Once ?? Once))
+ await Engine.DeactivateGestureLayerAsync(_id, "inview");
+ await OnViewportLeave.InvokeAsync();
+ }
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Helpers
+ // ════════════════════════════════════════════════════════════════════════════
+
+ private static readonly HashSet _pathDrawableTags = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "path", "circle", "ellipse", "line", "polyline", "polygon", "rect",
+ };
+
+ private bool NeedsPathLengthAttr() =>
+ _pathDrawableTags.Contains(EffectiveTag) &&
+ (AdditionalAttributes == null ||
+ !AdditionalAttributes.Keys.Any(k => string.Equals(k, "pathLength", StringComparison.OrdinalIgnoreCase))) &&
+ (HasPathLength(Initial) || HasPathLength(Animate) || HasPathLength(Exit) ||
+ HasPathLength(WhileHover) || HasPathLength(WhileTap) || HasPathLength(WhileFocus) ||
+ HasPathLength(WhileInView) || HasPathLength(WhileDrag));
+
+ // Resolve the effective props (direct or variant-referenced) so pathLength is detected
+ // whether the target carries Props directly or points at a variant label.
+ private bool HasPathLength(BmotionAnimationTarget? t) =>
+ ResolveProps(t)?.PathLength != null;
+
+ private BmotionAnimationProps? ResolveProps(BmotionAnimationTarget? target)
+ {
+ if (target == null || target.IsDisabled) return null;
+ if (target.HasProps) return target.Props;
+ if (target.IsVariant)
+ {
+ var name = target.Variant!;
+ return Variants?.Get(name) ?? VariantCtx?.Variants?.Get(name);
+ }
+ return null;
+ }
+
+ ///
+ /// Resolves whether motion should be reduced for this element.
+ ///
+ /// Reduced motion is opt-in : an element only reduces motion when it is inside a
+ /// . Within one, an explicit
+ /// value (true/false) always wins; when it is
+ /// null the OS prefers-reduced-motion preference is respected. Elements with no
+ /// surrounding config always animate, so the OS preference never silently disables animations
+ /// an app didn't opt into.
+ ///
+ ///
+ private bool ShouldReduceMotion()
+ {
+ if (ConfigCtx is null) return false;
+ return ConfigCtx.ReduceMotion ?? Engine.OsPrefersReducedMotion;
+ }
+
+ /// An instant (zero-duration) transition used when motion is reduced.
+ private static BmotionTransitionConfig InstantTransition()
+ => new() { Type = BmotionTransitionType.Tween, Duration = 0, Delay = 0 };
+
+ private BmotionTransitionConfig? BuildEffectiveTransition()
+ {
+ // Reduced motion: collapse every animation to an instant state change.
+ if (ShouldReduceMotion()) return InstantTransition();
+
+ var t = Transition ?? ConfigCtx?.DefaultTransition;
+ if (t == null) return null;
+ if (ConfigCtx?.TransitionSpeed is double speed && speed != 1.0)
+ {
+ t = t.Clone();
+ // Scale every time-based field so the whole animation is consistently sped up /
+ // slowed down - not just the tween duration (which left delays and duration-based
+ // springs out of sync with the requested speed).
+ t.Duration *= speed;
+ t.Delay *= speed;
+ t.RepeatDelay *= speed;
+ if (t.VisualDuration.HasValue) t.VisualDuration *= speed;
+ }
+ return t;
+ }
+
+ private BmotionTransitionConfig BuildEffectiveTransitionWithDelay(double extraDelay)
+ {
+ // Reduced motion stays instant - stagger delays are skipped too.
+ if (ShouldReduceMotion()) return InstantTransition();
+
+ var t = BuildEffectiveTransition() ?? new BmotionTransitionConfig();
+ if (extraDelay <= 0) return t;
+ t = t.Clone();
+ t.Delay += extraDelay;
+ return t;
+ }
+
+ private Dictionary BuildEventFlags()
+ {
+ var d = new Dictionary();
+ if (WhileHover != null || OnHoverStart.HasDelegate || OnHoverEnd.HasDelegate) d["hover"] = true;
+ if (WhileTap != null || OnTapStart.HasDelegate || OnTap.HasDelegate || OnTapCancel.HasDelegate) d["tap"] = true;
+ if (WhileFocus != null || OnFocusStart.HasDelegate || OnFocusEnd.HasDelegate) d["focus"] = true;
+ if (OnPanStart.HasDelegate || OnPan.HasDelegate || OnPanEnd.HasDelegate) d["pan"] = true;
+ if (Drag)
+ {
+ d["drag"] = true;
+ var dragOpt = DragOptions ?? new BmotionDragOptions();
+ if (dragOpt.Axis != BmotionDragAxis.Both) d["dragAxis"] = dragOpt.Axis.ToString().ToLowerInvariant();
+ d["dragElastic"] = dragOpt.Elastic;
+ if (dragOpt.Constraints != null) d["dragConstraints"] = dragOpt.Constraints.ToJsObject();
+ if (dragOpt.DirectionLock) d["dragDirectionLock"] = true;
+ }
+ return d;
+ }
+
+ ///
+ /// Re-wires the JS gesture listeners when the effective event set changes. Attaching always
+ /// runs the JS-side cleanup first, so passing an empty set also safely detaches everything.
+ ///
+ private async Task ReconcileEventListenersAsync()
+ {
+ var events = BuildEventFlags();
+ var sig = SignatureOf(events);
+ if (sig == _eventFlagsSig) return;
+ _eventFlagsSig = sig;
+ await Interop.AttachEventListenersAsync(_id, events, _dotnet!);
+ }
+
+ /// Re-observes (or stops observing) the viewport when the effective options change.
+ private async Task ReconcileViewportAsync()
+ {
+ // The viewport signature intentionally ignores WhileInView (it only tracks observation
+ // options). So if WhileInView is cleared while other viewport callbacks keep observation
+ // alive, the signature is unchanged and the early-return below would skip reconciliation,
+ // stranding an already-active in-view layer. Detect that transition explicitly and clear it.
+ bool whileInViewSet = WhileInView != null;
+ if (_whileInViewSet && !whileInViewSet)
+ await Engine.DeactivateGestureLayerAsync(_id, "inview");
+ _whileInViewSet = whileInViewSet;
+
+ var sig = BuildViewportSignature();
+ if (sig == _viewportSig) return;
+ _viewportSig = sig;
+ if (sig == null)
+ {
+ await Interop.UnobserveViewportAsync(_id);
+ // Unobserving stops future intersect callbacks but doesn't undo an already-active
+ // in-view layer, so clear it here to avoid leaving inview styles stuck on the element.
+ await Engine.DeactivateGestureLayerAsync(_id, "inview");
+ return;
+ }
+ if (Viewport != null)
+ await Interop.ObserveViewportWithOptionsAsync(_id, _dotnet!, Viewport);
+ else
+ await Interop.ObserveViewportAsync(_id, _dotnet!, Once);
+ }
+
+ private string? BuildViewportSignature()
+ {
+ bool needed = WhileInView != null || OnViewportEnter.HasDelegate || OnViewportLeave.HasDelegate;
+ if (!needed) return null;
+ return Viewport != null
+ ? $"opt|{Viewport.Once}|{Viewport.Margin}|{Viewport.Amount}"
+ : $"once|{Once}";
+ }
+
+ /// Builds a stable, order-independent string signature for an event-flags dictionary.
+ private static string SignatureOf(Dictionary d)
+ {
+ var sb = new System.Text.StringBuilder();
+ foreach (var key in d.Keys.OrderBy(k => k, StringComparer.Ordinal))
+ {
+ sb.Append(key).Append('=');
+ AppendValue(sb, d[key]);
+ sb.Append(';');
+ }
+ return sb.ToString();
+ }
+
+ private static void AppendValue(System.Text.StringBuilder sb, object? value)
+ {
+ switch (value)
+ {
+ case null:
+ sb.Append("null");
+ break;
+ case double dbl:
+ sb.Append(BmotionCssFormat.Num(dbl));
+ break;
+ case bool b:
+ sb.Append(b ? "1" : "0");
+ break;
+ case IDictionary nested:
+ sb.Append('{');
+ foreach (var key in nested.Keys.OrderBy(k => k, StringComparer.Ordinal))
+ {
+ sb.Append(key).Append(':');
+ AppendValue(sb, nested[key]);
+ sb.Append(',');
+ }
+ sb.Append('}');
+ break;
+ default:
+ sb.Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture));
+ break;
+ }
+ }
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // Dispose
+ // ════════════════════════════════════════════════════════════════════════════
+
+ public async ValueTask DisposeAsync()
+ {
+ PresenceCtx?.Unregister(this);
+ Engine.UnregisterElement(_id);
+ try { await Interop.UnregisterElementAsync(_id); } catch { /* ignore during teardown */ }
+ try { await Interop.UnobserveViewportAsync(_id); } catch { /* ignore during teardown */ }
+ _dotnet?.Dispose();
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor
new file mode 100644
index 0000000000..70f5e83258
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor
@@ -0,0 +1,11 @@
+@namespace Bit.Bmotion
+
+@* BmotionAnimatePresence keeps its children alive while they play exit animations,
+ then stops rendering them once every child reports completion. *@
+
+@if (_shouldRender)
+{
+
+ @ChildContent
+
+}
diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs
new file mode 100644
index 0000000000..6c7a773120
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs
@@ -0,0 +1,124 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Bit.Bmotion;
+///
+/// Wraps content that should animate in and out.
+/// When switches from true to false , children
+/// are kept in the DOM until their Exit animations finish.
+///
+///
+/// Limitation: presence is controlled by a single flag for the
+/// whole subtree (all-or-nothing). This does not provide per-item enter/exit tracking for keyed
+/// lists the way Framer Motion's keyed AnimatePresence does; wrap individual items in their
+/// own if you need independent exit animations per item.
+///
+///
+///
+///
+/// <BmotionAnimatePresence IsPresent="@_visible">
+/// <Bmotion Tag="div" Animate="..." Exit="..." />
+/// </BmotionAnimatePresence>
+///
+///
+///
+public partial class BmotionAnimatePresence : ComponentBase
+{
+ // ── Parameters ────────────────────────────────────────────────────────────
+
+ ///
+ /// Controls whether children are present. Setting to false triggers exit
+ /// animations before removing children from the DOM.
+ ///
+ [Parameter] public bool IsPresent { get; set; } = true;
+
+ ///
+ /// When true, a new set of children waits for the exiting children to finish
+ /// before entering. Mirrors Framer Motion's exitBeforeEnter .
+ ///
+ [Parameter] public bool ExitBeforeEnter { get; set; }
+
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ // ── Internal state ────────────────────────────────────────────────────────
+
+ private readonly BmotionPresenceContext _presenceCtx = new();
+ // Starts false so an initial IsPresent=false renders nothing (children stay unmounted) rather
+ // than mounting them; the OnParametersSet transitions flip it on when content should appear.
+ private bool _shouldRender;
+ // Starts false so an initial IsPresent=false is treated as "nothing was present yet"
+ // rather than a present→absent exit transition.
+ private bool _prevIsPresent;
+ private bool _deferEnter;
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Lifecycle
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ protected override void OnInitialized()
+ {
+ _presenceCtx.AllExitsComplete += OnAllExitsComplete;
+ }
+
+ protected override void OnParametersSet()
+ {
+ if (_prevIsPresent && !IsPresent)
+ {
+ // A fresh leave invalidates any pending deferred enter; clear it so stale
+ // deferred-enter state can't remount the children after this exit completes.
+ _deferEnter = false;
+
+ if (_presenceCtx.ChildCount > 0)
+ {
+ // Children are leaving - signal exiting state so Bmotion components play Exit
+ _presenceCtx.IsExiting = true;
+ _shouldRender = true; // keep rendering until exit completes
+ }
+ else
+ {
+ // No animatable children registered: AllExitsComplete would never fire, so
+ // flagging IsExiting/keeping _shouldRender true would strand the content. Drop it now.
+ _presenceCtx.IsExiting = false;
+ _shouldRender = false;
+ }
+ }
+ else if (!_prevIsPresent && IsPresent)
+ {
+ if (ExitBeforeEnter && _presenceCtx.IsExiting)
+ {
+ // Wait for the exiting children to finish before rendering the new ones.
+ _deferEnter = true;
+ }
+ else
+ {
+ // Children are re-entering
+ _presenceCtx.IsExiting = false;
+ _presenceCtx.Reset();
+ _shouldRender = true;
+ }
+ }
+
+ _prevIsPresent = IsPresent;
+ }
+
+ private void OnAllExitsComplete()
+ {
+ // Ignore stale callbacks that arrive after a re-entry / reset.
+ if (!_presenceCtx.IsExiting) return;
+
+ _presenceCtx.IsExiting = false;
+
+ if (_deferEnter)
+ {
+ // Deferred re-entry: now that exits are done, render the new children.
+ _deferEnter = false;
+ _presenceCtx.Reset();
+ _shouldRender = true;
+ }
+ else
+ {
+ _shouldRender = false;
+ }
+
+ InvokeAsync(StateHasChanged);
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor
new file mode 100644
index 0000000000..d64eae35e6
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor
@@ -0,0 +1,44 @@
+@namespace Bit.Bmotion
+
+@* BmotionConfig provides global animation defaults to the entire subtree. *@
+
+
+ @ChildContent
+
+
+@code {
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ /// Global default transition applied when no per-component transition is set.
+ [Parameter] public BmotionTransitionConfig? Transition { get; set; }
+
+ ///
+ /// Global reduce-motion override.
+ /// null = auto-detect via CSS media query.
+ /// true = always skip animations.
+ /// false = always play animations.
+ ///
+ [Parameter] public bool? ReduceMotion { get; set; }
+
+ ///
+ /// Scale factor applied to all durations in this subtree.
+ /// 0 = instant, 2 = half speed. Default: 1.
+ ///
+ [Parameter] public double TransitionSpeed { get; set; } = 1.0;
+
+ private BmotionConfigContext _ctx = new();
+
+ protected override void OnParametersSet()
+ {
+ // Replace the context with a fresh instance (rather than mutating in place) so the cascading
+ // value's reference changes whenever parameters change, ensuring descendants are notified.
+ // The TransitionSpeed setter still coerces negative / non-finite (NaN/Infinity) speeds to 0,
+ // so a bad binding can't corrupt duration math or crash the render.
+ _ctx = new BmotionConfigContext
+ {
+ DefaultTransition = Transition,
+ ReduceMotion = ReduceMotion,
+ TransitionSpeed = TransitionSpeed,
+ };
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs
new file mode 100644
index 0000000000..acfcf76a49
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs
@@ -0,0 +1,31 @@
+
+namespace Bit.Bmotion;
+///
+/// Cascaded by to set library-wide defaults.
+///
+public class BmotionConfigContext
+{
+ /// Global default transition applied when no individual transition is set.
+ public BmotionTransitionConfig? DefaultTransition { get; set; }
+
+ ///
+ /// When true, all animations are skipped (useful for accessibility / reduced-motion).
+ /// If null the library respects the OS prefers-reduced-motion media query automatically.
+ ///
+ public bool? ReduceMotion { get; set; }
+
+ ///
+ /// Scale factor applied to all animation durations. 0 = instant, 2 = half speed
+ /// (durations are multiplied by this factor). Default: 1.
+ ///
+ /// Negative and non-finite (NaN/Infinity) values are coerced to 0 rather than throwing,
+ /// so a bad binding can never crash a render. This matches 's behaviour.
+ ///
+ ///
+ public double TransitionSpeed
+ {
+ get => _transitionSpeed;
+ set => _transitionSpeed = double.IsFinite(value) && value >= 0 ? value : 0;
+ }
+ private double _transitionSpeed = 1.0;
+}
diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs
new file mode 100644
index 0000000000..a6d41bb89c
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs
@@ -0,0 +1,51 @@
+
+namespace Bit.Bmotion;
+///
+/// Cascaded by to signal exit state to child Bmotion components.
+///
+public class BmotionPresenceContext
+{
+ private readonly List _children = new();
+
+ /// True while the children are playing their exit animation.
+ public bool IsExiting { get; internal set; }
+
+ internal void Register(Bmotion child)
+ {
+ if (!_children.Contains(child)) _children.Add(child);
+ }
+ internal void Unregister(Bmotion child)
+ {
+ _children.Remove(child);
+ _completedChildren.Remove(child);
+ // A child disposed mid-exit removes itself here. Re-evaluate completion now so
+ // AllExitsComplete still fires when either every remaining child has already completed, or
+ // no children remain at all (the last one just unregistered during an active exit).
+ if (IsExiting && _completedChildren.Count >= _children.Count)
+ AllExitsComplete?.Invoke();
+ }
+
+ internal int ChildCount => _children.Count;
+
+ private readonly HashSet _completedChildren = new();
+
+ internal void NotifyExitComplete(Bmotion child)
+ {
+ // Ignore unregistered children and guard against double-counting.
+ if (!_children.Contains(child)) return;
+ if (!_completedChildren.Add(child)) return;
+
+ if (_completedChildren.Count >= _children.Count)
+ AllExitsComplete?.Invoke();
+ }
+
+ ///
+ /// Clears exit-completion bookkeeping for a fresh enter cycle. Registered children are left
+ /// intact — they remove themselves via when disposed, so clearing the
+ /// list here would desynchronise the count for any children that are reused across a toggle.
+ ///
+ internal void Reset() { _completedChildren.Clear(); }
+
+ /// Fired when every registered child has finished its exit animation.
+ internal event Action? AllExitsComplete;
+}
diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs
new file mode 100644
index 0000000000..5153cee279
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs
@@ -0,0 +1,41 @@
+
+namespace Bit.Bmotion;
+///
+/// Cascaded by a parent Bmotion component to propagate the active variant name,
+/// shared variants dictionary, and stagger configuration to descendant Bmotion components.
+///
+public class BmotionVariantContext
+{
+ private int _nextChildIndex;
+
+ /// The currently active variant name selected by the nearest ancestor.
+ public string? ActiveVariant { get; internal set; }
+
+ /// The initial variant name provided by the nearest ancestor.
+ public string? InitialVariant { get; internal set; }
+
+ /// Shared variants dictionary from the nearest ancestor that defined variants.
+ public BmotionMotionVariants? Variants { get; internal set; }
+
+ /// Seconds to stagger each child's animation start.
+ public double StaggerChildren { get; internal set; }
+
+ /// Seconds to delay the first child's animation start.
+ public double DelayChildren { get; internal set; }
+
+ ///
+ /// Called by a child Bmotion component once on first render to obtain a stable
+ /// position in the stagger sequence. Returns the child's index.
+ ///
+ internal int RegisterChild() => _nextChildIndex++;
+
+ /// The next child index that would be handed out. Used to carry the counter across
+ /// context instances so children registering after a variant change keep stable indices.
+ internal int NextChildIndex => _nextChildIndex;
+
+ /// Seeds the child-index counter (used when a fresh context instance replaces a prior one).
+ internal void SeedChildIndex(int value) => _nextChildIndex = value;
+
+ /// Returns the stagger delay in seconds for a child at the given index.
+ public double GetChildDelay(int childIndex) => DelayChildren + childIndex * StaggerChildren;
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs
new file mode 100644
index 0000000000..b203cf2c2c
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs
@@ -0,0 +1,581 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.JSInterop;
+
+namespace Bit.Bmotion;
+///
+/// Central C# animation engine - the JS equivalent of the full BitBmotion.js
+/// animation loop, now running in Blazor WebAssembly.
+///
+/// One instance is shared across the whole component tree (DI scoped).
+/// The slim JS bridge calls synchronously each
+/// requestAnimationFrame tick and receives back a dictionary of
+/// CSS style updates to apply to the DOM.
+///
+///
+///
+/// Threading: the engine is intentionally lock-free. All of its mutable state
+/// (the element map, per-element driver dictionaries, completion batches, dirty flags) is only
+/// safe to touch from a single thread, and every entry point - the rAF
+/// tick, the synchronous JS drag callbacks, and the awaited animate APIs - is expected to run on
+/// the Blazor WebAssembly UI thread. Completion continuations are scheduled on
+/// , which on single-threaded WASM still runs on that same
+/// thread. Do not enable WebAssembly multithreading (<WasmEnableThreads> ) with
+/// this library: the engine has no synchronization and concurrent access would corrupt its state.
+///
+///
+public sealed class BmotionAnimationEngine : IAsyncDisposable
+{
+ private readonly BmotionInterop _interop;
+ private readonly ILogger? _logger;
+ private readonly Dictionary _elements = new();
+ private DotNetObjectReference? _dotnet;
+ private bool _loopRunning;
+ private readonly SemaphoreSlim _loopStartGate = new(1, 1);
+ private bool _reducedMotionDetected;
+ // A single in-flight detection attempt shared by all concurrent callers so the browser probe
+ // and live-change subscription run exactly once (reset to null on failure to allow a retry).
+ private Task? _reducedMotionDetection;
+
+ // Reused across frames so the rAF tick doesn't allocate a fresh outer dictionary every ~16 ms.
+ // Marshaled synchronously to JS before the next ComputeFrame runs (single-threaded Blazor WASM).
+ private readonly Dictionary> _frameResult = new();
+
+ public BmotionAnimationEngine(BmotionInterop interop, ILogger? logger = null)
+ {
+ _interop = interop;
+ _logger = logger;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Reduced-motion (accessibility)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// The OS-level prefers-reduced-motion preference, detected once via the
+ /// browser. false until has run.
+ ///
+ public bool OsPrefersReducedMotion { get; private set; }
+
+ ///
+ /// Detects the user's prefers-reduced-motion setting from the browser the
+ /// first time it is called and caches the result for the lifetime of this engine.
+ ///
+ public ValueTask EnsureReducedMotionDetectedAsync()
+ {
+ if (_reducedMotionDetected) return ValueTask.CompletedTask;
+ // Concurrent first-callers can all pass the check above before any of them completes the
+ // probe. Gate them behind a single shared detection task so the browser probe and the
+ // live-change subscription run once rather than racing duplicate setups.
+ _reducedMotionDetection ??= DetectReducedMotionAsync();
+ return new ValueTask(_reducedMotionDetection);
+ }
+
+ private async Task DetectReducedMotionAsync()
+ {
+ try
+ {
+ OsPrefersReducedMotion = await _interop.PrefersReducedMotionAsync();
+ // Mark detection complete as soon as the initial probe succeeds: the probed value is
+ // valid regardless of whether the live-change subscription below can be set up.
+ _reducedMotionDetected = true;
+ }
+ catch
+ {
+ // Detection is best-effort: if the browser probe fails we default to
+ // animating normally rather than letting it break element initialisation.
+ OsPrefersReducedMotion = false;
+ // Clear the shared task so a later caller can retry the probe.
+ _reducedMotionDetection = null;
+ return;
+ }
+
+ try
+ {
+ // Subscribe to live OS changes so toggling prefers-reduced-motion at runtime is honoured.
+ // Best-effort: a watch-setup failure must not discard the successfully probed value above.
+ _dotnet ??= DotNetObjectReference.Create(this);
+ await _interop.WatchReducedMotionAsync(_dotnet);
+ }
+ catch
+ {
+ // Watch subscription failed; the initial preference stays valid and detection stays complete.
+ }
+ }
+
+ /// JS → C# callback fired when the OS prefers-reduced-motion setting changes.
+ [JSInvokable]
+ public void OnReducedMotionChanged(bool prefersReduced) => OsPrefersReducedMotion = prefersReduced;
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Element lifecycle
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ /// Register an element and optionally seed its initial CSS state.
+ public void RegisterElement(string elementId, Dictionary? initialValues = null)
+ {
+ if (!_elements.TryGetValue(elementId, out var state))
+ {
+ state = new BmotionElementAnimationState();
+ _elements[elementId] = state;
+ }
+ // Reference-counted: the same element may be owned by a and one or more
+ // concurrent AnimateAsync calls at once.
+ state.RefCount++;
+ if (initialValues != null)
+ state.SetInstant(initialValues);
+ }
+
+ /// Release one owner; cancels animations and removes the element only when the last owner releases it.
+ public void UnregisterElement(string elementId)
+ {
+ if (_elements.TryGetValue(elementId, out var state))
+ {
+ if (state.RefCount > 0) state.RefCount--;
+ // Other owners (e.g. an overlapping animation) still hold the element - keep it alive
+ // so their in-flight animations aren't stranded by a premature teardown.
+ if (state.RefCount > 0) return;
+ state.CancelAll();
+ _elements.Remove(elementId);
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Animation control
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Start animating to the given values. Returns immediately (fire-and-forget).
+ ///
+ /// Set to true only for the element's resting target
+ /// (the Animate /variant state a gesture layer should revert to). One-off programmatic
+ /// animations and drag snap-backs leave it false so they don't clobber the gesture base
+ /// (which would strand unrelated animated properties when a gesture later deactivates).
+ ///
+ ///
+ public async ValueTask AnimateToAsync(
+ string elementId,
+ Dictionary values,
+ BmotionTransitionConfig? transition,
+ Func? onComplete = null,
+ bool setAsBase = false)
+ {
+ if (!_elements.TryGetValue(elementId, out var state)) return;
+ if (setAsBase) state.SetBaseAnimation(values, transition);
+ if (onComplete != null)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ state.AnimateTo(values, transition, tcs);
+ await EnsureLoopRunningAsync();
+ // .Unwrap() so the nested onComplete() Task is observed rather than dropped
+ // (keeps the documented fire-and-forget behaviour of this method).
+ // The result flag is true only on natural completion; a superseded/cancelled
+ // animation resolves with false so OnAnimationComplete is NOT raised for it.
+ _ = tcs.Task.ContinueWith(
+ t => t.Result ? onComplete() : Task.CompletedTask,
+ TaskScheduler.Default)
+ .Unwrap();
+ }
+ else
+ {
+ state.AnimateTo(values, transition);
+ await EnsureLoopRunningAsync();
+ }
+ }
+
+ /// Animate to the given values and await animation completion.
+ public async ValueTask AnimateToAwaitAsync(
+ string elementId,
+ Dictionary values,
+ BmotionTransitionConfig? transition,
+ bool setAsBase = false)
+ {
+ if (!_elements.TryGetValue(elementId, out var state)) return;
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (setAsBase) state.SetBaseAnimation(values, transition);
+ state.AnimateTo(values, transition, tcs);
+ await EnsureLoopRunningAsync();
+ await tcs.Task;
+ }
+
+ /// Instantly set values without any animation.
+ public void SetInstant(string elementId, Dictionary values)
+ {
+ if (_elements.TryGetValue(elementId, out var state))
+ {
+ state.SetInstant(values);
+ // Kick the loop for a single frame so the change is flushed to the DOM even when
+ // the element is otherwise at rest (an instant Set has dirty values but no active
+ // animation, so without this it would never be emitted).
+ KickLoop();
+ }
+ }
+
+ ///
+ /// Pauses rAF transform writes for an element for so a WAAPI FLIP
+ /// layout animation can own the transform without the engine fighting it each frame.
+ ///
+ public void SuspendTransformWrites(string elementId, double durationMs)
+ {
+ if (_elements.TryGetValue(elementId, out var state))
+ state.SuspendTransformWrites(durationMs);
+ }
+
+ ///
+ /// Returns a snapshot of the element's current CSS declarations (transform + numeric + string +
+ /// path values), or null when the element is unknown. Used to re-flush live styles after
+ /// a Blazor re-render rewrites the element's style attribute.
+ ///
+ public Dictionary? GetCurrentStyles(string elementId)
+ => _elements.TryGetValue(elementId, out var state) ? state.BuildSnapshotStyles() : null;
+
+ /// Returns true if an element is currently registered with the engine.
+ public bool IsRegistered(string elementId) => _elements.ContainsKey(elementId);
+
+ ///
+ /// Finish all animations on an element immediately, snapping every property to its target
+ /// (end) value, then flush the final frame to the DOM.
+ ///
+ public void Complete(string elementId)
+ {
+ if (_elements.TryGetValue(elementId, out var state))
+ {
+ state.CompleteAll();
+ KickLoop();
+ }
+ }
+
+ /// Stop animations on specific properties (or all when is null/empty).
+ public void Stop(string elementId, string[]? properties)
+ {
+ if (_elements.TryGetValue(elementId, out var state))
+ state.Cancel(properties);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Gesture layer management
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public async ValueTask ActivateGestureLayerAsync(
+ string elementId, string gesture,
+ Dictionary values, BmotionTransitionConfig? transition)
+ {
+ if (!_elements.TryGetValue(elementId, out var state)) return;
+ state.ActivateGestureLayer(gesture, values, transition);
+ await EnsureLoopRunningAsync();
+ }
+
+ public async ValueTask DeactivateGestureLayerAsync(string elementId, string gesture)
+ {
+ if (!_elements.TryGetValue(elementId, out var state)) return;
+ state.DeactivateGestureLayer(gesture);
+ await EnsureLoopRunningAsync();
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Drag position (called synchronously from JS - Blazor WASM only)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Updates the drag position in the element's transform state from a
+ /// synchronous JS pointer-move call. The position will be included in the
+ /// next output.
+ ///
+ public void SetDragPosition(string elementId, double x, double y)
+ {
+ if (_elements.TryGetValue(elementId, out var state))
+ state.SetDragPosition(x, y);
+ }
+
+ /// Returns the current transform x/y for an element (used at drag start).
+ public (double x, double y) GetCurrentXY(string elementId)
+ {
+ return _elements.TryGetValue(elementId, out var state)
+ ? state.GetCurrentXY()
+ : (0, 0);
+ }
+
+ ///
+ /// Completes a drag and optionally starts inertia animations.
+ ///
+ public async ValueTask EndDragAsync(
+ string elementId,
+ double velX, double velY,
+ bool momentum,
+ BmotionDragConstraints? constraints,
+ string? axis,
+ BmotionTransitionConfig? snapTransition)
+ {
+ if (!_elements.TryGetValue(elementId, out var state)) return;
+
+ state.EndDrag();
+
+ var (posX, posY) = state.GetCurrentXY();
+
+ bool inertiaXStarted = false, inertiaYStarted = false;
+ if (momentum)
+ {
+ // velX/velY arrive from JS already scaled to "px per frame" (~16 ms). The * 50 factor
+ // converts that frame-relative figure into the larger projected-distance velocity the
+ // exponential-decay inertia driver expects (tuned so a natural flick throws roughly the
+ // distance Framer Motion produces for the same gesture).
+ const double inertiaVelocityScale = 50.0;
+ if (axis != "y" && Math.Abs(velX) > 0.5)
+ {
+ var inertiaX = new BmotionTransitionConfig
+ {
+ Type = BmotionTransitionType.Inertia,
+ InertiaVelocity = velX * inertiaVelocityScale,
+ InertiaMin = constraints?.Left,
+ InertiaMax = constraints?.Right,
+ };
+ var valuesX = new Dictionary { ["x"] = posX };
+ state.AnimateTo(valuesX, inertiaX);
+ inertiaXStarted = true;
+ }
+
+ if (axis != "x" && Math.Abs(velY) > 0.5)
+ {
+ var inertiaY = new BmotionTransitionConfig
+ {
+ Type = BmotionTransitionType.Inertia,
+ InertiaVelocity = velY * inertiaVelocityScale,
+ InertiaMin = constraints?.Top,
+ InertiaMax = constraints?.Bottom,
+ };
+ var valuesY = new Dictionary { ["y"] = posY };
+ state.AnimateTo(valuesY, inertiaY);
+ inertiaYStarted = true;
+ }
+ }
+
+ // Snap-back runs independently of momentum: when momentum produced no inertia animation
+ // for an axis (velocity below threshold or disabled) the element can still be out of
+ // bounds, so any axis without an active inertia animation is corrected here.
+ if (constraints != null)
+ {
+ // Snap to constraint bounds
+ double cx = posX, cy = posY;
+ bool snap = false;
+ var snapT = snapTransition ?? new BmotionTransitionConfig
+ { Type = BmotionTransitionType.Spring, Stiffness = 400, Damping = 35 };
+
+ if (axis != "y" && !inertiaXStarted)
+ {
+ if (constraints.Left.HasValue && cx < constraints.Left.Value) { cx = constraints.Left.Value; snap = true; }
+ if (constraints.Right.HasValue && cx > constraints.Right.Value) { cx = constraints.Right.Value; snap = true; }
+ }
+ if (axis != "x" && !inertiaYStarted)
+ {
+ if (constraints.Top.HasValue && cy < constraints.Top.Value) { cy = constraints.Top.Value; snap = true; }
+ if (constraints.Bottom.HasValue && cy > constraints.Bottom.Value) { cy = constraints.Bottom.Value; snap = true; }
+ }
+
+ if (snap)
+ {
+ var snapValues = new Dictionary();
+ if (axis != "y" && !inertiaXStarted) snapValues["x"] = cx;
+ if (axis != "x" && !inertiaYStarted) snapValues["y"] = cy;
+ state.AnimateTo(snapValues, snapT);
+ }
+ }
+
+ if (state.HasActiveAnimations)
+ await EnsureLoopRunningAsync();
+ }
+
+ /// Returns the current CSS transform string for the element (used by FLIP).
+ public string? GetCurrentTransformString(string elementId)
+ {
+ if (!_elements.TryGetValue(elementId, out var state)) return null;
+ return BmotionTransformComposer.Build(state.Transforms);
+ }
+
+ /// Returns the for an element, or null.
+ internal BmotionElementAnimationState? GetState(string elementId)
+ => _elements.GetValueOrDefault(elementId);
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // rAF loop - ComputeFrame is called synchronously from JS each tick
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Called synchronously by the JS rAF ticker every ~16 ms (Blazor WASM).
+ /// Returns a dictionary: elementId → { cssPropertyName → cssValue }.
+ /// Returns null when there are no style changes this frame. (The loop keeps running
+ /// until the engine explicitly calls stopRafLoop once no element has active work.)
+ ///
+ [JSInvokable]
+ public Dictionary>? ComputeFrame(double timestamp)
+ {
+ // Clear the reused outer buffer so entries from the previous frame don't leak through.
+ _frameResult.Clear();
+ Dictionary>? result = null;
+ bool anyActive = false;
+ List? faulted = null;
+
+ foreach (var (id, state) in _elements)
+ {
+ try
+ {
+ var updates = state.Tick(timestamp);
+ if (updates is { Count: > 0 })
+ {
+ result ??= _frameResult;
+ result[id] = updates;
+ }
+ if (state.HasActiveAnimations) anyActive = true;
+ }
+ catch
+ {
+ // A single malformed value or driver fault must not take down the whole loop
+ // (a thrown exception would propagate into the synchronous JS rAF tick and
+ // permanently stop animation for every element). Isolate and evict the bad
+ // element instead, then keep ticking the rest.
+ (faulted ??= new List()).Add(id);
+ }
+ }
+
+ if (faulted != null)
+ {
+ foreach (var id in faulted)
+ {
+ if (_elements.TryGetValue(id, out var badState))
+ {
+ try { badState.CancelAll(); } catch { /* best-effort cleanup */ }
+ _elements.Remove(id);
+ // Surface the eviction: the owning component still believes it's registered, so
+ // without this signal a faulted element would silently stop animating forever.
+ // Bmotion re-registers on its next parameter update (see IsRegistered check).
+ _logger?.LogWarning(
+ "Bmotion evicted element '{ElementId}' after its animation tick threw. " +
+ "Animations on it are stopped until it re-registers.", id);
+ }
+ }
+ }
+
+ if (!anyActive)
+ StopLoopInternal();
+
+ return result;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Loop lifecycle
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public async ValueTask EnsureLoopRunningAsync()
+ {
+ if (_loopRunning) return;
+
+ // Concurrent callers (e.g. several elements registering on the same frame) can all pass the
+ // check above before any of them flips _loopRunning, which would start the rAF loop more
+ // than once. Serialize startup behind a gate so only the first caller starts it; the rest
+ // re-check _loopRunning after acquiring the gate and become no-ops.
+ await _loopStartGate.WaitAsync();
+ try
+ {
+ if (_loopRunning) return;
+
+ // Bit.Bmotion's animation loop relies on synchronous JS→.NET interop (the JS rAF ticker
+ // calls ComputeFrame synchronously). That is only available on Blazor WebAssembly; on
+ // Blazor Server / SSR the call would throw an opaque error, so fail fast with a clear one.
+ if (!_interop.IsInProcess)
+ throw new PlatformNotSupportedException(
+ "Bit.Bmotion requires synchronous JS interop and is only supported on Blazor WebAssembly. " +
+ "It cannot run on Blazor Server or during server-side prerendering.");
+
+ _dotnet ??= DotNetObjectReference.Create(this);
+ await _interop.StartRafLoopAsync(_dotnet);
+ // Only flag the loop as running once startup actually succeeded; if the interop call
+ // throws, the flag stays false so a later call can retry instead of silently no-op'ing.
+ _loopRunning = true;
+ }
+ finally
+ {
+ _loopStartGate.Release();
+ }
+ }
+
+ private void StopLoopInternal()
+ {
+ if (!_loopRunning) return;
+ // Clear the flag synchronously so a re-entrant ComputeFrame this frame doesn't schedule a
+ // second stop, but defer the actual JS teardown behind _loopStartGate (the same gate
+ // EnsureLoopRunningAsync uses to start the loop). Serializing stop and start prevents a
+ // delayed stop from racing a restart - tearing down a freshly started loop while
+ // _loopRunning is true would otherwise leave the engine stuck (flagged running, JS stopped).
+ _loopRunning = false;
+ _ = StopRafLoopGatedAsync();
+
+ async Task StopRafLoopGatedAsync()
+ {
+ try
+ {
+ await _loopStartGate.WaitAsync();
+ }
+ catch (ObjectDisposedException)
+ {
+ // DisposeAsync may dispose the gate while this fire-and-forget task is still
+ // pending. Bail out gracefully rather than surfacing an unobserved exception -
+ // teardown already stopped the JS loop explicitly.
+ return;
+ }
+ try
+ {
+ // A restart may have re-flipped _loopRunning to true after we cleared it (and
+ // already (re)started the JS loop). Skip the stale stop so we don't tear it down.
+ if (_loopRunning) return;
+ // Pass our own engine ref so only this engine is removed from the shared JS loop,
+ // leaving any other Blazor-root engines ticking.
+ await _interop.StopRafLoopAsync(_dotnet);
+ }
+ catch { /* mid-session stop is best-effort; teardown is ordered explicitly in DisposeAsync */ }
+ finally
+ {
+ // DisposeAsync may dispose the gate (line below) while this fire-and-forget task is
+ // still unwinding through the await above. Guard Release() so a disposal race can't
+ // surface as an unobserved ObjectDisposedException on this path.
+ try { _loopStartGate.Release(); }
+ catch (ObjectDisposedException) { /* gate disposed during teardown; nothing to release */ }
+ }
+ }
+ }
+
+ ///
+ /// Fire-and-forget loop start that observes (and swallows) any fault. Used by synchronous entry
+ /// points (Set / SetInstant / Complete) so an unsupported-platform throw can't surface as an
+ /// unobserved task exception.
+ ///
+ private void KickLoop()
+ {
+ _ = KickLoopAsync();
+
+ async Task KickLoopAsync()
+ {
+ try { await EnsureLoopRunningAsync(); }
+ catch { /* loop start is best-effort here; awaited entry points surface real errors */ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var (_, state) in _elements)
+ state.CancelAll();
+ _elements.Clear();
+
+ // Await the loop stop before disposing _dotnet so the JS call doesn't marshal a
+ // disposed DotNetObjectReference. StopLoopInternal's fire-and-forget path is fine for
+ // mid-session stops, but during teardown we must order it explicitly.
+ _loopRunning = false;
+ if (_dotnet != null)
+ {
+ try { await _interop.StopRafLoopAsync(_dotnet); } catch { /* ignore during teardown */ }
+ try { await _interop.UnwatchReducedMotionAsync(_dotnet); } catch { /* ignore during teardown */ }
+ _dotnet.Dispose();
+ _dotnet = null;
+ }
+ _loopStartGate.Dispose();
+ // BmotionInterop is owned and disposed by the DI container (it is registered scoped),
+ // so the engine must not dispose it here or it would be disposed twice.
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs
new file mode 100644
index 0000000000..f74f21bb01
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs
@@ -0,0 +1,141 @@
+namespace Bit.Bmotion;
+///
+/// Pure-C# RGBA color parsing and linear interpolation.
+/// Handles #hex, rgb(), rgba(), hsl(), and hsla() formats.
+///
+internal static partial class BmotionColorInterpolator
+{
+ // Source-generated regexes: faster than the static Regex cache and trim-safe (no runtime
+ // IL emit), which matters because this assembly is built with IsTrimmable=true.
+ [System.Text.RegularExpressions.GeneratedRegex(
+ @"^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex RgbRegex();
+
+ [System.Text.RegularExpressions.GeneratedRegex(
+ @"^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)$",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
+ private static partial System.Text.RegularExpressions.Regex HslRegex();
+ ///
+ /// Linearly interpolates between two CSS color strings at progress (0–1).
+ ///
+ /// Interpolation is performed per-channel in the sRGB color space (matching Framer Motion's
+ /// default). This is fast and predictable, but mixing complementary colors can pass through a
+ /// desaturated mid-point (e.g. blue→yellow briefly looks grey). A perceptual space such as
+ /// OKLab would avoid that at extra cost; sRGB is intentional here for parity and performance.
+ ///
+ ///
+ public static string Lerp(string from, string to, double t)
+ {
+ var f = Parse(from);
+ var tt = Parse(to);
+ if (f == null || tt == null) return to;
+ return Lerp(f, tt, t);
+ }
+
+ ///
+ /// Interpolates between two pre-parsed RGBA channel arrays (as produced by ).
+ /// Drivers parse their colors once up-front and call this each tick, avoiding the per-frame
+ /// regex/parse cost of the string overload.
+ ///
+ public static string Lerp(double[] from, double[] to, double t)
+ {
+ int r = (int)Math.Round(Math.Clamp(from[0] + (to[0] - from[0]) * t, 0, 255));
+ int g = (int)Math.Round(Math.Clamp(from[1] + (to[1] - from[1]) * t, 0, 255));
+ int b = (int)Math.Round(Math.Clamp(from[2] + (to[2] - from[2]) * t, 0, 255));
+ double a = Math.Clamp(from[3] + (to[3] - from[3]) * t, 0, 1);
+ return $"rgba({r},{g},{b},{BmotionCssFormat.Num(a, "G4")})";
+ }
+
+ /// Returns true if the CSS string looks like a color value.
+ public static bool LooksLikeColor(string? value)
+ => value != null &&
+ (value.StartsWith('#') ||
+ value.StartsWith("rgb", StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith("hsl", StringComparison.OrdinalIgnoreCase));
+
+ // ── Internal ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Parses a CSS color into its [r, g, b, a] channels (0–255 for RGB, 0–1 for alpha),
+ /// or returns null when the string is not a recognised color. Exposed to drivers so
+ /// they can parse keyframes once instead of on every tick.
+ ///
+ internal static double[]? Parse(string c)
+ {
+ if (string.IsNullOrEmpty(c)) return null;
+
+ if (c.StartsWith('#'))
+ {
+ var h = c[1..];
+ // Expand shorthand #rgb → #rrggbb, #rgba → #rrggbbaa
+ if (h.Length == 3 || h.Length == 4)
+ h = string.Concat(h.Select(ch => $"{ch}{ch}"));
+ if (h.Length is not (6 or 8)) return null;
+ if (!TryHex(h[..2], out int r) ||
+ !TryHex(h[2..4], out int g) ||
+ !TryHex(h[4..6], out int b))
+ return null;
+ double alpha = 1.0;
+ if (h.Length == 8)
+ {
+ if (!TryHex(h[6..8], out int a)) return null;
+ alpha = a / 255.0;
+ }
+ return [r, g, b, alpha];
+ }
+
+ // rgb() / rgba()
+ var m = RgbRegex().Match(c);
+ if (m.Success)
+ {
+ // Use TryParse so malformed numerics like "1..2" fall back to null (and the Lerp
+ // string overload then returns the target) instead of throwing a FormatException.
+ if (!BmotionCssFormat.TryParse(m.Groups[1].Value, out double mr) ||
+ !BmotionCssFormat.TryParse(m.Groups[2].Value, out double mg) ||
+ !BmotionCssFormat.TryParse(m.Groups[3].Value, out double mb))
+ return null;
+ double ma = 1.0;
+ if (m.Groups[4].Success && !BmotionCssFormat.TryParse(m.Groups[4].Value, out ma))
+ return null;
+ return [mr, mg, mb, ma];
+ }
+
+ // hsl() / hsla()
+ var mh = HslRegex().Match(c);
+ if (mh.Success)
+ {
+ if (!BmotionCssFormat.TryParse(mh.Groups[1].Value, out double h2) ||
+ !BmotionCssFormat.TryParse(mh.Groups[2].Value, out double s2raw) ||
+ !BmotionCssFormat.TryParse(mh.Groups[3].Value, out double l2raw))
+ return null;
+ double a2 = 1.0;
+ if (mh.Groups[4].Success && !BmotionCssFormat.TryParse(mh.Groups[4].Value, out a2))
+ return null;
+ var rgb2 = HslToRgb(h2, s2raw / 100.0, l2raw / 100.0);
+ return [rgb2[0], rgb2[1], rgb2[2], a2];
+ }
+
+ return null;
+ }
+
+ private static bool TryHex(string s, out int value)
+ => int.TryParse(s, System.Globalization.NumberStyles.HexNumber,
+ System.Globalization.CultureInfo.InvariantCulture, out value);
+
+ private static double[] HslToRgb(double h, double s, double l)
+ {
+ h = ((h % 360) + 360) % 360; // normalise to 0-360
+ double c = (1 - Math.Abs(2 * l - 1)) * s;
+ double x = c * (1 - Math.Abs((h / 60) % 2 - 1));
+ double m = l - c / 2;
+ double r, g, b;
+ if (h < 60) { r = c; g = x; b = 0; }
+ else if (h < 120) { r = x; g = c; b = 0; }
+ else if (h < 180) { r = 0; g = c; b = x; }
+ else if (h < 240) { r = 0; g = x; b = c; }
+ else if (h < 300) { r = x; g = 0; b = c; }
+ else { r = c; g = 0; b = x; }
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs
new file mode 100644
index 0000000000..bee5014e0b
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs
@@ -0,0 +1,174 @@
+namespace Bit.Bmotion;
+/// Keyframe animation driver for CSS color string properties.
+internal sealed class BmotionColorKeyframesDriver : IBmotionAnimationDriver
+{
+ private readonly string[] _frames;
+ private readonly double _durationMs;
+ private readonly double _delayMs;
+ private readonly double[] _times;
+ private readonly Func[] _eases;
+ private readonly int _repeat;
+ private readonly bool _isInfinite;
+ private readonly BmotionRepeatType _repeatType;
+ private readonly double _repeatDelayMs;
+ private readonly Action _apply;
+
+ private double _startTime = -1;
+ private bool _cancelled;
+ private int _iteration;
+ private bool _reversed;
+ private string[] _curFrames;
+ private readonly double[]?[] _curChannels;
+
+ public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig config, Action apply)
+ {
+ if (frames is null || frames.Length < 2)
+ throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames));
+ if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay)
+ || config.Duration < 0 || config.Delay < 0 || config.RepeatDelay < 0)
+ // NaN/infinite timing values poison _startTime in the progress math, pushing invalid
+ // values through _apply. Negative durations keep t below 1.0 so the animation never
+ // completes. Reject both up front (matches the numeric keyframes driver).
+ throw new ArgumentException(
+ "Duration, Delay and RepeatDelay must be finite, non-negative values.", nameof(config));
+ if (config.Times != null && config.Times.Length != frames.Length)
+ throw new ArgumentException("Times array length must match the number of frames.", nameof(config));
+ if (config.Times != null)
+ {
+ // Times feed the segment math; non-monotonic or out-of-range values produce
+ // negative/zero segment lengths and NaN output, so reject them up front.
+ for (int i = 0; i < config.Times.Length; i++)
+ {
+ // double.NaN/infinity slip past the relational checks below (every comparison with
+ // NaN is false), so reject non-finite entries explicitly before the range/order tests.
+ if (!double.IsFinite(config.Times[i]))
+ throw new ArgumentException("Times values must be finite.", nameof(config));
+ if (config.Times[i] < 0 || config.Times[i] > 1)
+ throw new ArgumentException("Times values must be within the range [0, 1].", nameof(config));
+ if (i > 0 && config.Times[i] < config.Times[i - 1])
+ throw new ArgumentException("Times values must be in monotonically ascending order.", nameof(config));
+ }
+ }
+
+ _frames = (string[])frames.Clone();
+ _curFrames = (string[])frames.Clone();
+ _durationMs = config.Duration * 1000;
+ _delayMs = config.Delay * 1000;
+ _repeat = config.Repeat;
+ _isInfinite = config.IsInfiniteRepeat;
+ _repeatType = config.RepeatType;
+ _repeatDelayMs = config.RepeatDelay * 1000;
+ _apply = apply;
+
+ int n = frames.Length;
+ // Clone the caller's Times so the in-place MirrorTimes mutation never touches their config.
+ _times = config.Times != null
+ ? (double[])config.Times.Clone()
+ : Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray();
+ var globalEase = BmotionEasingFunctions.Get(config);
+ _eases = Enumerable.Repeat(globalEase, n - 1).ToArray();
+
+ // Parse each frame's color once up-front; Tick() then only interpolates pre-parsed
+ // channels instead of running the color regex on every frame (~60 fps).
+ _curChannels = new double[]?[n];
+ for (int i = 0; i < n; i++)
+ _curChannels[i] = BmotionColorInterpolator.Parse(_curFrames[i]);
+ }
+
+ public bool Tick(double timestamp)
+ {
+ // Freeze at the current value on cancel (consistent with the other drivers); callers
+ // remove the driver immediately after Cancel(), so this branch is defensive only.
+ if (_cancelled) return true;
+
+ if (_startTime < 0) _startTime = timestamp + _delayMs;
+ if (timestamp < _startTime) { _apply(_curFrames[0]); return false; }
+
+ double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0;
+
+ int n = _curFrames.Length;
+ int seg = n - 2;
+ for (int i = 0; i < n - 1; i++) { if (t <= _times[i + 1]) { seg = i; break; } }
+ double segLen = _times[seg + 1] - _times[seg];
+ double segT = segLen > 0 ? (t - _times[seg]) / segLen : 1.0;
+ double easedT = _eases[seg](Math.Clamp(segT, 0.0, 1.0));
+ var ca = _curChannels[seg];
+ var cb = _curChannels[seg + 1];
+ // Fall back to the raw target frame string when a color couldn't be parsed
+ // (matches the string Lerp returning 'to' for unparseable input).
+ _apply(ca != null && cb != null
+ ? BmotionColorInterpolator.Lerp(ca, cb, easedT)
+ : _curFrames[seg + 1]);
+
+ if (t >= 1.0)
+ {
+ if (_isInfinite || _iteration < _repeat)
+ {
+ if (!_isInfinite) _iteration++;
+ _startTime = timestamp + _repeatDelayMs;
+ // Mirror ping-pongs: reverse the playback direction every cycle (0→1, 1→0, …).
+ // Reverse plays the frames backwards repeatedly (1→0, 1→0, …): reverse once on the
+ // first repeat, then keep that order so each subsequent cycle replays in reverse
+ // rather than toggling back to forward.
+ if (_repeatType == BmotionRepeatType.Mirror)
+ {
+ Array.Reverse(_curFrames);
+ Array.Reverse(_curChannels);
+ MirrorTimes(_times);
+ }
+ else if (_repeatType == BmotionRepeatType.Reverse && !_reversed)
+ {
+ Array.Reverse(_curFrames);
+ Array.Reverse(_curChannels);
+ MirrorTimes(_times);
+ _reversed = true;
+ }
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void Cancel() => _cancelled = true;
+
+ public void Complete()
+ {
+ // Mirror/Reverse don't always terminate on the last frame, so snap to the correct natural
+ // terminal frame (computed from the original forward-order _frames):
+ // • Mirror ping-pongs each pass (total passes = _repeat + 1). An even count ends back on
+ // the first frame, an odd count on the last.
+ // • Reverse plays forward once then replays reversed for every later pass, so it ends on
+ // the last frame only when there are no repeats, otherwise on the first frame.
+ // Infinite repeats have no natural end, so fall through to the last frame.
+ if (!_isInfinite && _repeatType == BmotionRepeatType.Mirror)
+ {
+ _apply((_repeat + 1) % 2 == 0 ? _frames[0] : _frames[^1]);
+ return;
+ }
+ if (!_isInfinite && _repeatType == BmotionRepeatType.Reverse)
+ {
+ _apply(_repeat == 0 ? _frames[^1] : _frames[0]);
+ return;
+ }
+ _apply(_frames[^1]);
+ }
+
+ ///
+ /// Mirrors a (possibly non-uniform) times array in place so segment durations line up with the
+ /// reversed frame order: newTimes[i] = 1 - times[n-1-i] . Applying it twice restores the
+ /// original, matching how Mirror/Reverse alternate direction each iteration.
+ ///
+ private static void MirrorTimes(double[] times)
+ {
+ int n = times.Length;
+ for (int i = 0; i < n / 2; i++)
+ {
+ double a = 1 - times[n - 1 - i];
+ double b = 1 - times[i];
+ times[i] = a;
+ times[n - 1 - i] = b;
+ }
+ if (n % 2 == 1) times[n / 2] = 1 - times[n / 2];
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs
new file mode 100644
index 0000000000..a78caeca2d
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs
@@ -0,0 +1,116 @@
+
+namespace Bit.Bmotion;
+/// Tween animation driver for CSS color string properties.
+internal sealed class BmotionColorTweenDriver : IBmotionAnimationDriver
+{
+ private readonly string _to;
+ private readonly string _from;
+ private readonly double _durationMs;
+ private readonly double _delayMs;
+ private readonly Func _easeFn;
+ private readonly int _repeat;
+ private readonly bool _isInfinite;
+ private readonly BmotionRepeatType _repeatType;
+ private readonly double _repeatDelayMs;
+ private readonly Action _apply;
+
+ private double _startTime = -1;
+ private bool _cancelled;
+ private int _iteration;
+ private bool _reversed;
+ private string _curFrom;
+ private string _curTo;
+ private double[]? _curFromCh;
+ private double[]? _curToCh;
+
+ public BmotionColorTweenDriver(string from, string to, BmotionTransitionConfig config, Action apply)
+ {
+ if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay)
+ || config.Duration < 0)
+ // NaN/infinite timing values poison _startTime in the progress math, pushing invalid
+ // values through _apply. A negative duration keeps t below 1.0 so the tween never
+ // completes. Reject both up front (a zero duration is allowed: it completes instantly).
+ throw new ArgumentException(
+ "Duration, Delay and RepeatDelay must be finite values and Duration must be non-negative.", nameof(config));
+
+ _curFrom = _from = from;
+ _curTo = _to = to;
+ // Parse once up-front so Tick() doesn't run the color regex ~60 times per second.
+ _curFromCh = BmotionColorInterpolator.Parse(from);
+ _curToCh = BmotionColorInterpolator.Parse(to);
+ _durationMs = config.Duration * 1000;
+ _delayMs = config.Delay * 1000;
+ _easeFn = BmotionEasingFunctions.Get(config);
+ _repeat = config.Repeat;
+ _isInfinite = config.IsInfiniteRepeat;
+ _repeatType = config.RepeatType;
+ _repeatDelayMs = config.RepeatDelay * 1000;
+ _apply = apply;
+ }
+
+ public bool Tick(double timestamp)
+ {
+ if (_cancelled) return true;
+
+ if (_startTime < 0) _startTime = timestamp + _delayMs;
+ if (timestamp < _startTime) { _apply(_curFrom); return false; }
+
+ double elapsed = timestamp - _startTime;
+ double t = _durationMs > 0 ? Math.Min(elapsed / _durationMs, 1.0) : 1.0;
+ double p = _easeFn(t);
+ // Fall back to the raw target string when a color couldn't be parsed (matches the
+ // string Lerp's behaviour of returning 'to' for unparseable input).
+ _apply(_curFromCh != null && _curToCh != null
+ ? BmotionColorInterpolator.Lerp(_curFromCh, _curToCh, p)
+ : _curTo);
+
+ if (t >= 1.0)
+ {
+ if (_isInfinite || _iteration < _repeat)
+ {
+ if (!_isInfinite) _iteration++;
+ _startTime = timestamp + _repeatDelayMs;
+ // Mirror ping-pongs: swap from/to every pass (0→1, 1→0, …).
+ // Reverse plays the colour backwards repeatedly (1→0, 1→0, …): swap once on the
+ // first repeat, then keep that order so each later cycle replays in reverse rather
+ // than toggling back to forward (matches the keyframe drivers).
+ if (_repeatType == BmotionRepeatType.Mirror)
+ {
+ (_curFrom, _curTo) = (_curTo, _curFrom);
+ (_curFromCh, _curToCh) = (_curToCh, _curFromCh);
+ }
+ else if (_repeatType == BmotionRepeatType.Reverse && !_reversed)
+ {
+ (_curFrom, _curTo) = (_curTo, _curFrom);
+ (_curFromCh, _curToCh) = (_curToCh, _curFromCh);
+ _reversed = true;
+ }
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void Cancel() => _cancelled = true;
+
+ public void Complete()
+ {
+ // Mirror ping-pongs each pass, so the natural terminal colour depends on how many passes
+ // run: total passes = _repeat + 1. An even count ends back on _from, an odd count on _to.
+ if (!_isInfinite && _repeatType == BmotionRepeatType.Mirror)
+ {
+ _apply((_repeat + 1) % 2 == 0 ? _from : _to);
+ return;
+ }
+ // Reverse plays forward once (ending on _to) then replays reversed for every later pass
+ // (ending on _from), so it ends on _to only when there are no repeats.
+ if (!_isInfinite && _repeatType == BmotionRepeatType.Reverse)
+ {
+ _apply(_repeat == 0 ? _to : _from);
+ return;
+ }
+ // Loop (and infinite repeats, which have no natural end) terminate on _to.
+ _apply(_to);
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs
new file mode 100644
index 0000000000..651600c337
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs
@@ -0,0 +1,36 @@
+using System.Globalization;
+
+namespace Bit.Bmotion;
+///
+/// Centralised, culture-invariant number↔CSS conversion helpers.
+///
+/// Every numeric value that ends up in a CSS string (transforms, opacity, colours,
+/// dimensions, dash arrays, …) MUST be formatted through here. Using the default
+/// would honour the current culture and emit a comma
+/// decimal separator in locales such as de-DE or fr-FR , producing invalid
+/// CSS like translate(1,5px,0px) . All formatting and parsing here pins
+/// .
+///
+///
+internal static class BmotionCssFormat
+{
+ ///
+ /// Formats a double as an invariant-culture string using compact "G6" formatting
+ /// (up to 6 significant digits). This is lossy, not full round-trip precision, but keeps
+ /// emitted CSS short while staying visually accurate for animation values.
+ ///
+ public static string Num(double value)
+ => value.ToString("G6", CultureInfo.InvariantCulture);
+
+ /// Formats a double as an invariant-culture string using the given numeric format.
+ public static string Num(double value, string format)
+ => value.ToString(format, CultureInfo.InvariantCulture);
+
+ /// Parses a double using invariant culture. Returns false on failure.
+ public static bool TryParse(string? text, out double value)
+ => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
+
+ /// Parses a double using invariant culture, throwing on failure.
+ public static double Parse(string text)
+ => double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture);
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs
new file mode 100644
index 0000000000..4aa6d5f071
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs
@@ -0,0 +1,93 @@
+
+namespace Bit.Bmotion;
+///
+/// Pure-C# easing functions. Ported from the original JS implementation.
+/// Cached delegates avoid re-allocation for common easing types.
+///
+internal static class BmotionEasingFunctions
+{
+ // ── Pre-built delegates for common easings ────────────────────────────────
+ private static readonly Func _easeIn = CubicBezier(0.42, 0, 1, 1);
+ private static readonly Func _easeOut = CubicBezier(0, 0, 0.58, 1);
+ private static readonly Func _easeInOut = CubicBezier(0.42, 0, 0.58, 1);
+ private static readonly Func _backIn = CubicBezier(0.31455, -0.37755, 0.69245, 1.37755);
+ private static readonly Func _backOut = CubicBezier(0.33915, 0, 0.68085, 1.4);
+ private static readonly Func _backInOut = CubicBezier(0.68987, -0.45, 0.32, 1.45);
+
+ /// Returns an easing function for the given transition config.
+ public static Func Get(BmotionTransitionConfig config)
+ {
+ if (config.EaseCubicBezier is { Length: 4 } cb)
+ return CubicBezier(cb[0], cb[1], cb[2], cb[3]);
+
+ return config.Ease switch
+ {
+ BmotionEasing.Linear => t => t,
+ BmotionEasing.EaseIn => _easeIn,
+ BmotionEasing.EaseOut => _easeOut,
+ BmotionEasing.EaseInOut => _easeInOut,
+ BmotionEasing.CircIn => t => 1 - Math.Sqrt(1 - t * t),
+ BmotionEasing.CircOut => t => Math.Sqrt(1 - (t - 1) * (t - 1)),
+ BmotionEasing.CircInOut => t => t < 0.5
+ ? (1 - Math.Sqrt(1 - 4 * t * t)) / 2
+ : (Math.Sqrt(1 - Math.Pow(2 * t - 2, 2)) + 1) / 2,
+ BmotionEasing.BackIn => _backIn,
+ BmotionEasing.BackOut => _backOut,
+ BmotionEasing.BackInOut => _backInOut,
+ BmotionEasing.Anticipate => t => t < 0.5
+ ? _backIn(t * 2) / 2
+ : _easeOut(t * 2 - 1) / 2 + 0.5,
+ _ => _easeOut,
+ };
+ }
+
+ /// Returns a CSS easing string for use with the Web Animations API (FLIP).
+ public static string ToCssString(BmotionTransitionConfig? config)
+ {
+ if (config == null) return "ease";
+ if (config.EaseCubicBezier is { Length: 4 } cb)
+ return $"cubic-bezier({BmotionCssFormat.Num(cb[0])},{BmotionCssFormat.Num(cb[1])},{BmotionCssFormat.Num(cb[2])},{BmotionCssFormat.Num(cb[3])})";
+ return config.Ease switch
+ {
+ BmotionEasing.Linear => "linear",
+ BmotionEasing.EaseIn => "ease-in",
+ BmotionEasing.EaseOut => "ease-out",
+ BmotionEasing.EaseInOut => "ease-in-out",
+ // Circ* have no CSS keyword - map to their closest cubic-bezier approximations.
+ BmotionEasing.CircIn => "cubic-bezier(0.55,0,1,0.45)",
+ BmotionEasing.CircOut => "cubic-bezier(0,0.55,0.45,1)",
+ BmotionEasing.CircInOut => "cubic-bezier(0.85,0,0.15,1)",
+ // Back* map exactly to the cubic-beziers used by Get(...).
+ BmotionEasing.BackIn => "cubic-bezier(0.31455,-0.37755,0.69245,1.37755)",
+ BmotionEasing.BackOut => "cubic-bezier(0.33915,0,0.68085,1.4)",
+ BmotionEasing.BackInOut => "cubic-bezier(0.68987,-0.45,0.32,1.45)",
+ // Anticipate has no CSS equivalent; use the backIn curve as the nearest fallback.
+ BmotionEasing.Anticipate => "cubic-bezier(0.31455,-0.37755,0.69245,1.37755)",
+ _ => "ease",
+ };
+ }
+
+ /// Constructs a cubic-bezier easing function via Newton-Raphson iteration.
+ public static Func CubicBezier(double x1, double y1, double x2, double y2)
+ {
+ return t =>
+ {
+ if (t <= 0) return 0;
+ if (t >= 1) return 1;
+ double u = t;
+ for (int i = 0; i < 10; i++)
+ {
+ double bx = 3 * u * (1 - u) * (1 - u) * x1 + 3 * u * u * (1 - u) * x2 + u * u * u - t;
+ // True derivative dx/du of the cubic-bezier x(u):
+ // 3(1-u)²·x1 + 6(1-u)u·(x2-x1) + 3u²·(1-x2)
+ double dbx = 3 * (1 - u) * (1 - u) * x1
+ + 6 * (1 - u) * u * (x2 - x1)
+ + 3 * u * u * (1 - x2);
+ if (Math.Abs(dbx) < 1e-8) break;
+ u -= bx / dbx;
+ u = Math.Max(0, Math.Min(1, u));
+ }
+ return 3 * u * (1 - u) * (1 - u) * y1 + 3 * u * u * (1 - u) * y2 + u * u * u;
+ };
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs
new file mode 100644
index 0000000000..7ac8822062
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs
@@ -0,0 +1,696 @@
+
+namespace Bit.Bmotion;
+///
+/// Per-element animation state - the C# equivalent of the JS ElementState class.
+/// Holds current transform / numeric / color values, active animation drivers,
+/// and gesture-layer bookkeeping. Called by
+/// every rAF tick.
+///
+internal sealed class BmotionElementAnimationState
+{
+ // ── Live CSS values ───────────────────────────────────────────────────────
+
+ /// Current values of transform components (x, y, scale, rotate, …).
+ // Case-insensitive so keys accepted by BmotionTransformComposer.IsTransformProp (which compares
+ // OrdinalIgnoreCase) match the canonical lowercase keys the composer reads when emitting.
+ internal readonly Dictionary Transforms = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Current values of numeric non-transform properties (opacity, pathLength, …).
+ internal readonly Dictionary NumericValues = new();
+
+ /// Current values of color / string properties (backgroundColor, color, …).
+ internal readonly Dictionary StringValues = new();
+
+ ///
+ /// Number of live owners holding this element (a wrapping <Bmotion>, controllers, and
+ /// in-flight AnimateAsync calls). The engine only tears the element down when this hits zero.
+ ///
+ internal int RefCount;
+
+ // ── Active animations ─────────────────────────────────────────────────────
+ private readonly Dictionary _activeAnims = new();
+
+ // ── Gesture layer stack ────────────────────────────────────────────────────
+ private static readonly string[] GesturePriority = ["drag", "focus", "tap", "hover", "inview"];
+ private readonly Dictionary _gestureLayers = new();
+ private Dictionary? _baseValues;
+ private BmotionTransitionConfig? _baseTransition;
+
+ // ── Animation completion tracking ─────────────────────────────────────────
+ // Each AnimateTo call that carries a TaskCompletionSource registers a "batch": the set of
+ // property keys that call is animating. The batch resolves true only when every one of its
+ // keys finishes naturally; it resolves false if any of its keys is superseded or cancelled.
+ // Tracking per-batch (rather than a single per-element source) means overlapping animations on
+ // different properties of the same element no longer resolve each other prematurely.
+ private sealed class CompletionBatch
+ {
+ public required TaskCompletionSource Source { get; init; }
+ public required HashSet Keys { get; init; }
+ public bool Interrupted { get; set; }
+ }
+ private readonly List _batches = new();
+
+ // ── Drag state ────────────────────────────────────────────────────────────
+ private bool _isDragging;
+
+ // ── Dirty flags for CSS build ─────────────────────────────────────────────
+ private bool _transformDirty;
+ private readonly HashSet _dirtyProps = new();
+
+ // Transform writes are paused while a WAAPI FLIP layout animation owns the element's
+ // transform, so the rAF engine doesn't fight (and visually tear) the layout animation.
+ // The window is measured against Tick timestamps so no extra timer/interop is needed.
+ private double _transformSuspendMs;
+ private double _transformSuspendStart = -1;
+
+ // Reused across frames to avoid allocating a fresh CSS-update dictionary every rAF tick.
+ // Safe because the synchronous JS interop marshals the returned dictionary before the next
+ // Tick runs (single-threaded Blazor WASM), so the buffer is never read after it is cleared.
+ private readonly Dictionary _updateBuffer = new();
+
+ public bool HasActiveAnimations => _activeAnims.Count > 0 || _isDragging;
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Tick - called every rAF frame
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public Dictionary? Tick(double timestamp)
+ {
+ // Nothing to do only when there are no drivers, no drag, and no pending
+ // instant (SetInstant) changes still waiting to be emitted.
+ if (_activeAnims.Count == 0 && !_isDragging && !_transformDirty && _dirtyProps.Count == 0)
+ return null;
+
+ if (_isDragging) _transformDirty = true; // drag always refreshes transform
+
+ // Advance all drivers. Iterate over a snapshot because driver.Tick can invoke a user
+ // OnUpdate callback that re-enters and mutates _activeAnims (e.g. starts/cancels an
+ // animation on this same element), which would otherwise corrupt the enumeration.
+ List? completed = null;
+ foreach (var (key, driver) in _activeAnims.ToArray())
+ {
+ // The driver may have been removed - or replaced with a different driver - by a
+ // re-entrant callback earlier in this loop. Skip unless the live driver for this key
+ // is still the exact one captured in the snapshot, so a stale driver can't tick and
+ // later evict the replacement at the removal step below.
+ if (!_activeAnims.TryGetValue(key, out var current) || !ReferenceEquals(current, driver)) continue;
+ if (driver.Tick(timestamp))
+ (completed ??= new List()).Add(key);
+ }
+
+ if (completed != null)
+ foreach (var key in completed)
+ {
+ _activeAnims.Remove(key);
+ NotePropFinished(key, interrupted: false); // natural completion
+ }
+
+ if (!_transformDirty && _dirtyProps.Count == 0) return null;
+
+ // ── Build CSS style update dict (reused buffer) ────────────────────────
+ var updates = _updateBuffer;
+ updates.Clear();
+
+ // While a FLIP layout animation owns the transform, hold transform writes back so the two
+ // animators don't fight; the dirty flag is preserved so the latest transform is flushed
+ // the moment the suspension window ends.
+ bool transformSuspended = IsTransformSuspended(timestamp);
+ if (_transformDirty && !transformSuspended)
+ updates["transform"] = BmotionTransformComposer.Build(Transforms);
+
+ foreach (var prop in _dirtyProps)
+ {
+ if (prop is "pathLength" or "pathSpacing")
+ {
+ // Compose strokeDasharray from the normalized pathLength + pathSpacing pair.
+ double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1);
+ double spacing = NumericValues.GetValueOrDefault("pathSpacing", 1.0);
+ double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0);
+ updates["strokeDasharray"] = BmotionCssFormat.Num(len) + " " + BmotionCssFormat.Num(spacing);
+ // Offset combines the "draw from end" baseline (1 - len) with any explicit pathOffset.
+ updates["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset);
+ }
+ else if (prop == "pathOffset")
+ {
+ double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1);
+ double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0);
+ updates["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset);
+ }
+ else if (prop.StartsWith("--"))
+ {
+ if (NumericValues.TryGetValue(prop, out double nv))
+ updates[prop] = BmotionCssFormat.Num(nv);
+ else if (StringValues.TryGetValue(prop, out string? sv))
+ updates[prop] = sv;
+ }
+ else if (NumericValues.TryGetValue(prop, out double numVal))
+ {
+ updates[prop] = BmotionCssFormat.Num(numVal);
+ }
+ else if (StringValues.TryGetValue(prop, out string? strVal))
+ {
+ updates[prop] = strVal;
+ }
+ }
+
+ // Reset dirty flags now that this frame's changes have been emitted. The transform flag is
+ // kept set while suspended so the pending transform flushes once the FLIP window ends.
+ if (!transformSuspended) _transformDirty = false;
+ _dirtyProps.Clear();
+
+ return updates.Count > 0 ? updates : null;
+ }
+
+ // ── Transform suspension (used by FLIP layout animations) ─────────────────
+
+ /// Pause rAF transform writes for (measured from the next tick).
+ internal void SuspendTransformWrites(double durationMs)
+ {
+ if (durationMs <= 0) return;
+ _transformSuspendMs = durationMs;
+ _transformSuspendStart = -1; // armed on the next tick
+ }
+
+ private bool IsTransformSuspended(double timestamp)
+ {
+ if (_transformSuspendMs <= 0) return false;
+ if (_transformSuspendStart < 0) _transformSuspendStart = timestamp;
+ if (timestamp - _transformSuspendStart < _transformSuspendMs) return true;
+ // Window elapsed - clear so transforms resume.
+ _transformSuspendMs = 0;
+ _transformSuspendStart = -1;
+ return false;
+ }
+
+ ///
+ /// Builds a full snapshot of the element's current CSS (transform + numeric + string + path
+ /// values), regardless of dirty flags. Used to re-flush live styles to the DOM after a Blazor
+ /// re-render rewrites the element's style attribute.
+ ///
+ internal Dictionary? BuildSnapshotStyles()
+ {
+ var d = new Dictionary();
+
+ if (Transforms.Count > 0)
+ {
+ var tr = BmotionTransformComposer.Build(Transforms);
+ if (!string.IsNullOrEmpty(tr)) d["transform"] = tr;
+ }
+
+ bool hasPath = NumericValues.ContainsKey("pathLength")
+ || NumericValues.ContainsKey("pathSpacing")
+ || NumericValues.ContainsKey("pathOffset");
+ if (hasPath)
+ {
+ double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1);
+ double spacing = NumericValues.GetValueOrDefault("pathSpacing", 1.0);
+ double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0);
+ d["strokeDasharray"] = BmotionCssFormat.Num(len) + " " + BmotionCssFormat.Num(spacing);
+ d["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset);
+ }
+
+ foreach (var (prop, value) in NumericValues)
+ {
+ if (prop is "pathLength" or "pathSpacing" or "pathOffset") continue;
+ d[prop] = BmotionCssFormat.Num(value);
+ }
+ foreach (var (prop, value) in StringValues)
+ d[prop] = value;
+
+ return d.Count > 0 ? d : null;
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Animation control
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public void AnimateTo(
+ Dictionary values,
+ BmotionTransitionConfig? transition,
+ TaskCompletionSource? completionSource = null)
+ {
+ // Cheap scan for any non-null target (no allocation).
+ bool any = false;
+ foreach (var v in values.Values)
+ if (v != null) { any = true; break; }
+ if (!any) { completionSource?.TrySetResult(true); return; }
+
+ // Track which keys actually get an active driver so the completion batch only waits on
+ // animations that can finish (keys that snap instantly resolve immediately).
+ HashSet? driverKeys = completionSource != null ? new HashSet() : null;
+ int activeBefore;
+
+ foreach (var (key, value) in values)
+ {
+ if (value == null) continue;
+ var perKey = transition?.Properties?.GetValueOrDefault(key) ?? transition ?? new BmotionTransitionConfig();
+ // Superseding an in-flight driver for this key interrupts any completion batch that
+ // owned it (resolves that batch with false once its remaining keys settle).
+ CancelProp(key);
+ activeBefore = _activeAnims.Count;
+
+ if (TryGetDoubleArray(value, out double[]? doubleFrames))
+ {
+ // Keyframe drivers require at least two frames (they build n-1 segments and
+ // divide by n-1 when distributing times). Degenerate arrays would otherwise
+ // throw and (via ComputeFrame) stall the whole loop, so handle them here:
+ // 0 frames -> nothing to do; 1 frame -> snap to that single value.
+ if (doubleFrames!.Length >= 2)
+ CreateNumericKeyframesDriver(key, doubleFrames, perKey);
+ else if (doubleFrames.Length == 1)
+ CreateNumericDriver(key, doubleFrames[0], perKey);
+ }
+ else if (IsColorProp(key) && TryGetStringArray(value, out string[]? strFrames))
+ {
+ if (strFrames!.Length >= 2)
+ CreateColorKeyframesDriver(key, strFrames, perKey);
+ else if (strFrames.Length == 1)
+ CreateColorDriver(key, strFrames[0], perKey);
+ }
+ else if (IsColorProp(key) && value is string colorStr)
+ CreateColorDriver(key, colorStr, perKey);
+ else if (value is string dimStr)
+ CreateCssDimensionDriver(key, dimStr, perKey);
+ else if (TryGetStringArray(value, out string[]? otherFrames) && otherFrames!.Length > 0)
+ {
+ // Non-colour string keyframes (e.g. dimension arrays) have no interpolating driver;
+ // snap to the final frame so the value still lands on its destination.
+ StringValues[key] = otherFrames[^1];
+ NumericValues.Remove(key); // keep numeric/string stores mutually exclusive
+ _dirtyProps.Add(key);
+ }
+ else if (TryConvertToDouble(value, out double numeric))
+ CreateNumericDriver(key, numeric, perKey);
+ // else: an unconvertible value (e.g. an arbitrary object in a user Keyframes dict) is
+ // skipped rather than throwing - a bad value can't take down the init / event path.
+
+ // Record the key if this iteration created a live driver for the completion batch.
+ if (driverKeys != null && _activeAnims.Count > activeBefore && _activeAnims.ContainsKey(key))
+ driverKeys.Add(key);
+ }
+
+ // Register the completion batch (if any): it resolves once every key that got a driver
+ // finishes. If nothing animates (all snapped instantly), resolve immediately.
+ if (completionSource != null)
+ {
+ if (driverKeys is { Count: > 0 })
+ _batches.Add(new CompletionBatch { Source = completionSource, Keys = driverKeys });
+ else
+ completionSource.TrySetResult(true);
+ }
+ }
+
+ public void SetInstant(Dictionary values)
+ {
+ foreach (var (key, value) in values)
+ {
+ if (value == null) continue;
+ // Cancel any in-flight driver for this property so the instant value is authoritative
+ // and isn't overwritten on the next tick by an ongoing animation.
+ CancelProp(key);
+ if (BmotionTransformComposer.IsTransformProp(key))
+ {
+ if (TryConvertToDouble(value, out double tv))
+ {
+ Transforms[key] = tv;
+ _transformDirty = true;
+ }
+ }
+ else if (IsColorProp(key) && value is string colorStr)
+ {
+ StringValues[key] = colorStr;
+ NumericValues.Remove(key); // keep numeric/string stores mutually exclusive
+ _dirtyProps.Add(key);
+ }
+ else if (value is string dimStr)
+ {
+ StringValues[key] = dimStr;
+ NumericValues.Remove(key); // keep numeric/string stores mutually exclusive
+ _dirtyProps.Add(key);
+ }
+ else if (TryConvertToDouble(value, out double nv))
+ {
+ NumericValues[key] = nv;
+ StringValues.Remove(key); // keep numeric/string stores mutually exclusive
+ _dirtyProps.Add(key);
+ }
+ }
+ }
+
+ public void Cancel(string[]? properties)
+ {
+ if (properties == null || properties.Length == 0)
+ CancelAll();
+ else
+ {
+ // CancelProp interrupts any completion batch that owns the property, so callers of
+ // AnimateToAwaitAsync resolve (with false) instead of hanging forever.
+ foreach (var p in properties)
+ CancelProp(p);
+ }
+ }
+
+ internal void CancelAll()
+ {
+ foreach (var driver in _activeAnims.Values)
+ driver.Cancel();
+ _activeAnims.Clear();
+ ResolveAllBatches(false); // cancelled, not completed
+ }
+
+ ///
+ /// Finish all running animations immediately, snapping every property to its target
+ /// (end) value. Unlike (which freezes in place), this applies
+ /// the final frame so the element settles on the destination state.
+ ///
+ internal void CompleteAll()
+ {
+ // Snapshot the drivers before iterating: driver.Complete() applies the final value, which
+ // can invoke a user OnUpdate callback that re-enters and mutates _activeAnims (e.g. starts
+ // a new animation on this element). Iterating the live Values collection would then throw.
+ foreach (var driver in _activeAnims.Values.ToArray())
+ driver.Complete();
+ _activeAnims.Clear();
+ ResolveAllBatches(true); // snapped to end values = completed
+ }
+
+ internal void CancelProp(string key)
+ {
+ if (_activeAnims.TryGetValue(key, out var driver))
+ {
+ driver.Cancel();
+ _activeAnims.Remove(key);
+ NotePropFinished(key, interrupted: true); // cancelled / superseded
+ }
+ }
+
+ // ── Completion-batch bookkeeping ──────────────────────────────────────────
+
+ ///
+ /// Records that is no longer animating. Removes it from any pending
+ /// completion batch; a batch resolves once all its keys are gone (true only if none of them
+ /// were interrupted - i.e. every key finished naturally).
+ ///
+ private void NotePropFinished(string key, bool interrupted)
+ {
+ for (int i = _batches.Count - 1; i >= 0; i--)
+ {
+ var b = _batches[i];
+ if (!b.Keys.Remove(key)) continue;
+ if (interrupted) b.Interrupted = true;
+ if (b.Keys.Count == 0)
+ {
+ b.Source.TrySetResult(!b.Interrupted);
+ _batches.RemoveAt(i);
+ }
+ }
+ }
+
+ private void ResolveAllBatches(bool result)
+ {
+ foreach (var b in _batches)
+ b.Source.TrySetResult(result);
+ _batches.Clear();
+ }
+
+ private static bool TryConvertToDouble(object value, out double result)
+ {
+ try
+ {
+ result = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
+ return true;
+ }
+ catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException)
+ {
+ result = 0;
+ return false;
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Gesture layer management
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public void SetBaseAnimation(Dictionary values, BmotionTransitionConfig? transition)
+ {
+ _baseValues = values;
+ _baseTransition = transition;
+ }
+
+ public void ActivateGestureLayer(string gesture, Dictionary values, BmotionTransitionConfig? transition)
+ {
+ _gestureLayers[gesture] = new GestureLayer(values, transition);
+
+ // Respect gesture priority: don't let a lower-priority gesture animate over a
+ // higher-priority one that is already active (mirrors DeactivateGestureLayer).
+ int newPriority = Array.IndexOf(GesturePriority, gesture);
+ if (newPriority >= 0)
+ {
+ foreach (var other in _gestureLayers.Keys)
+ {
+ if (other == gesture) continue;
+ int otherPriority = Array.IndexOf(GesturePriority, other);
+ if (otherPriority >= 0 && otherPriority < newPriority) return; // higher-priority layer wins
+ }
+ }
+
+ AnimateTo(values, transition);
+ }
+
+ public void DeactivateGestureLayer(string gesture)
+ {
+ if (!_gestureLayers.Remove(gesture, out var removed))
+ return;
+
+ // Build the target the element should revert to: the base animation overlaid with
+ // every still-active gesture layer (lowest priority first so higher priority wins).
+ var target = new Dictionary();
+ BmotionTransitionConfig? transition = _baseTransition;
+
+ if (_baseValues != null)
+ foreach (var kv in _baseValues)
+ target[kv.Key] = kv.Value;
+
+ for (int i = GesturePriority.Length - 1; i >= 0; i--)
+ {
+ if (_gestureLayers.TryGetValue(GesturePriority[i], out var layer))
+ {
+ foreach (var kv in layer.Values)
+ target[kv.Key] = kv.Value;
+ transition = layer.Transition; // highest-priority remaining layer wins the transition
+ }
+ }
+
+ // Any property the removed layer set but no remaining layer/base defines must animate
+ // back to its identity value, otherwise it would stay stuck at the gesture value.
+ foreach (var key in removed.Values.Keys)
+ {
+ if (target.ContainsKey(key)) continue;
+ if (BmotionTransformComposer.IsTransformProp(key))
+ target[key] = DefaultTransformValue(key);
+ else if (!IsColorProp(key)) // colours have no safe identity to revert to
+ target[key] = DefaultNumericValue(key);
+ }
+
+ if (target.Count > 0)
+ AnimateTo(target, transition);
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Drag position (updated synchronously from JS pointer events)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public void SetDragPosition(double x, double y)
+ {
+ Transforms["x"] = x;
+ Transforms["y"] = y;
+ _isDragging = true;
+ _transformDirty = true;
+ }
+
+ public void EndDrag() => _isDragging = false;
+
+ public (double x, double y) GetCurrentXY()
+ => (Transforms.GetValueOrDefault("x"), Transforms.GetValueOrDefault("y"));
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Driver factory helpers
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ private void CreateNumericDriver(string key, double toValue, BmotionTransitionConfig config)
+ {
+ bool isTransform = BmotionTransformComposer.IsTransformProp(key);
+ double from = isTransform
+ ? Transforms.GetValueOrDefault(key, DefaultTransformValue(key))
+ : NumericValues.GetValueOrDefault(key, DefaultNumericValue(key));
+
+ Action apply = isTransform
+ ? v => ApplyTransform(key, v)
+ : v => ApplyNumeric(key, v);
+
+ // Wire the optional per-frame OnUpdate callback (single-value numeric animations).
+ if (config.OnUpdate is { } onUpdate)
+ {
+ var inner = apply;
+ apply = v => { inner(v); onUpdate(v); };
+ }
+
+ IBmotionAnimationDriver driver = config.Type switch
+ {
+ BmotionTransitionType.Spring => new BmotionSpringDriver(from, toValue, config, apply),
+ BmotionTransitionType.Inertia => new BmotionInertiaDriver(from, config, apply),
+ _ => new BmotionTweenDriver(from, toValue, config, apply),
+ };
+
+ _activeAnims[key] = driver;
+ }
+
+ private void CreateColorDriver(string key, string toValue, BmotionTransitionConfig config)
+ {
+ string from = StringValues.GetValueOrDefault(key, "rgba(0,0,0,0)");
+ _activeAnims[key] = new BmotionColorTweenDriver(from, toValue, config, v => ApplyString(key, v));
+ }
+
+ private void CreateNumericKeyframesDriver(string key, double[] frames, BmotionTransitionConfig config)
+ {
+ bool isTransform = BmotionTransformComposer.IsTransformProp(key);
+ Action apply = isTransform
+ ? v => ApplyTransform(key, v)
+ : v => ApplyNumeric(key, v);
+ _activeAnims[key] = new BmotionNumericKeyframesDriver(frames, config, apply);
+ }
+
+ private void CreateColorKeyframesDriver(string key, string[] frames, BmotionTransitionConfig config)
+ {
+ _activeAnims[key] = new BmotionColorKeyframesDriver(frames, config, v => ApplyString(key, v));
+ }
+
+ // ── Value apply callbacks (mark dirty) ────────────────────────────────────
+
+ private void ApplyTransform(string key, double value)
+ {
+ Transforms[key] = value;
+ _transformDirty = true;
+ }
+
+ private void ApplyNumeric(string key, double value)
+ {
+ NumericValues[key] = value;
+ // Keep the numeric/string stores mutually exclusive: Tick emits a prop from NumericValues
+ // first, so a stale string entry for the same key would otherwise be masked (and vice versa).
+ StringValues.Remove(key);
+ _dirtyProps.Add(key);
+ }
+
+ private void ApplyString(string key, string value)
+ {
+ StringValues[key] = value;
+ // Mutually exclusive with NumericValues (see ApplyNumeric): a stale numeric entry for this
+ // key would mask the string update during Tick emission.
+ NumericValues.Remove(key);
+ _dirtyProps.Add(key);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static readonly HashSet _colorProps = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "backgroundColor", "color", "borderColor", "outlineColor", "fill", "stroke",
+ "caretColor", "columnRuleColor", "textDecorationColor",
+ };
+
+ private static bool IsColorProp(string key)
+ => _colorProps.Contains(key) || key.EndsWith("color", StringComparison.OrdinalIgnoreCase);
+
+ private static double DefaultTransformValue(string key) =>
+ key is "scale" or "scaleX" or "scaleY" ? 1.0 : 0.0;
+
+ private static double DefaultNumericValue(string key) =>
+ key is "opacity" or "pathLength" ? 1.0 : 0.0;
+
+ private static bool TryGetDoubleArray(object? value, out double[]? result)
+ {
+ result = null;
+ if (value is double[] da) { result = da; return true; }
+ if (value is IEnumerable de) { result = de.ToArray(); return true; }
+ if (value is object[] oa && oa.Length > 0 && oa[0] is double or float or int or long)
+ {
+ // Convert each element defensively: a mixed array like [0, "bad"] must not throw.
+ var arr = new double[oa.Length];
+ for (int i = 0; i < oa.Length; i++)
+ {
+ if (oa[i] is null) return false;
+ try { arr[i] = Convert.ToDouble(oa[i], System.Globalization.CultureInfo.InvariantCulture); }
+ catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException)
+ { return false; }
+ }
+ result = arr;
+ return true;
+ }
+ // Any other numeric sequence (int[], float[], List, …). Strings are excluded so
+ // colour keyframes still fall through to TryGetStringArray.
+ if (value is System.Collections.IEnumerable seq && value is not string)
+ {
+ var list = new List();
+ foreach (var item in seq)
+ {
+ if (item is string || item is null) return false;
+ try { list.Add(Convert.ToDouble(item, System.Globalization.CultureInfo.InvariantCulture)); }
+ catch { return false; }
+ }
+ if (list.Count > 0) { result = list.ToArray(); return true; }
+ }
+ return false;
+ }
+
+ private void CreateCssDimensionDriver(string key, string toValue, BmotionTransitionConfig config)
+ {
+ // If both from and to are the same unit, interpolate numerically.
+ // Otherwise just snap to the new value immediately.
+ string fromRaw = StringValues.GetValueOrDefault(key, "");
+ if (TryParseCssDimension(toValue, out double toNum, out string toUnit) &&
+ TryParseCssDimension(fromRaw, out double fromNum, out string fromUnit) &&
+ string.Equals(fromUnit, toUnit, StringComparison.OrdinalIgnoreCase))
+ {
+ _activeAnims[key] = new BmotionTweenDriver(fromNum, toNum, config,
+ v => ApplyString(key, BmotionCssFormat.Num(v) + toUnit));
+ }
+ else
+ {
+ // Snap and mark dirty - no interpolation possible across different units.
+ StringValues[key] = toValue;
+ NumericValues.Remove(key); // keep numeric/string stores mutually exclusive
+ _dirtyProps.Add(key);
+ }
+ }
+
+ private static bool TryParseCssDimension(string value, out double number, out string unit)
+ {
+ if (string.IsNullOrEmpty(value)) { number = 0; unit = ""; return false; }
+ // Find the split between leading numeric part and trailing unit.
+ int i = 0;
+ if (i < value.Length && (value[i] == '-' || value[i] == '+')) i++;
+ while (i < value.Length && (char.IsDigit(value[i]) || value[i] == '.')) i++;
+ if (i == 0 || (i == 1 && (value[0] == '-' || value[0] == '+')))
+ { number = 0; unit = ""; return false; }
+ unit = value[i..];
+ return double.TryParse(value[..i], System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out number);
+ }
+
+ private static bool TryGetStringArray(object? value, out string[]? result)
+ {
+ result = null;
+ if (value is string) return false; // a single string is not a keyframe array
+ if (value is string[] sa) { result = sa; return true; }
+ if (value is IEnumerable se) { result = se.ToArray(); return true; }
+ if (value is object[] oa && oa.Length > 0 && oa.All(x => x is string))
+ {
+ result = oa.Cast().ToArray();
+ return true;
+ }
+ return false;
+ }
+
+ private sealed record GestureLayer(Dictionary Values, BmotionTransitionConfig? Transition);
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs
new file mode 100644
index 0000000000..e2d6fa8010
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs
@@ -0,0 +1,70 @@
+
+namespace Bit.Bmotion;
+///
+/// Exponential-decay inertia driver. Decelerates from an initial velocity toward
+/// an optional projected target, with optional bounds clamping.
+///
+internal sealed class BmotionInertiaDriver : IBmotionAnimationDriver
+{
+ private readonly double _start;
+ private readonly double _projected;
+ private readonly double _delta;
+ private readonly double _timeConstantSec;
+ private readonly double _restDelta;
+ private readonly double _delayMs;
+ private readonly Action _apply;
+
+ private double _elapsed;
+ private double _lastTs = -1;
+ private double _startTs = -1;
+ private bool _cancelled;
+
+ public BmotionInertiaDriver(double from, BmotionTransitionConfig config, Action apply)
+ {
+ _start = from;
+ _timeConstantSec = config.TimeConstant > 0 ? config.TimeConstant / 1000.0 : 1e-6;
+ // Rest delta must be strictly positive, otherwise the completion test
+ // (|projected - pos| < restDelta) can never pass and the driver runs forever.
+ _restDelta = config.InertiaRestDelta > 0 ? config.InertiaRestDelta : 0.01;
+ _delayMs = config.Delay * 1000;
+ _apply = apply;
+
+ double power = config.Power;
+ double velocity = config.InertiaVelocity;
+
+ double projected = from + power * velocity;
+ if (config.InertiaMax.HasValue) projected = Math.Min(projected, config.InertiaMax.Value);
+ if (config.InertiaMin.HasValue) projected = Math.Max(projected, config.InertiaMin.Value);
+
+ _projected = projected;
+ _delta = projected - from;
+ }
+
+ public bool Tick(double timestamp)
+ {
+ if (_cancelled) return true;
+
+ if (_startTs < 0) _startTs = timestamp;
+ if (timestamp - _startTs < _delayMs) { _apply(_start); return false; }
+
+ if (_lastTs < 0) _lastTs = timestamp;
+
+ _elapsed += Math.Min((timestamp - _lastTs) / 1000.0, 0.064);
+ _lastTs = timestamp;
+
+ double tau = _timeConstantSec > 0 ? _timeConstantSec : 1e-6;
+ double pos = _start + _delta * (1 - Math.Exp(-_elapsed / tau));
+ _apply(pos);
+
+ if (Math.Abs(_projected - pos) < _restDelta)
+ {
+ _apply(_projected);
+ return true;
+ }
+ return false;
+ }
+
+ public void Cancel() => _cancelled = true;
+
+ public void Complete() => _apply(_projected);
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs
new file mode 100644
index 0000000000..029599a7d3
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs
@@ -0,0 +1,172 @@
+namespace Bit.Bmotion;
+/// Keyframe animation driver for numeric (double) properties.
+internal sealed class BmotionNumericKeyframesDriver : IBmotionAnimationDriver
+{
+ private readonly double[] _frames;
+ private readonly double _durationMs;
+ private readonly double _delayMs;
+ private readonly double[] _times;
+ private readonly Func[] _eases;
+ private readonly int _repeat;
+ private readonly bool _isInfinite;
+ private readonly BmotionRepeatType _repeatType;
+ private readonly double _repeatDelayMs;
+ private readonly Action _apply;
+
+ private double _startTime = -1;
+ private bool _cancelled;
+ private int _iteration;
+ private bool _reversed;
+ private double[] _curFrames;
+
+ public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig config, Action apply)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ ArgumentNullException.ThrowIfNull(apply);
+ if (frames is null || frames.Length < 2)
+ throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames));
+ if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay))
+ // NaN/infinite timing values poison _startTime in the progress math (e.g. a NaN delay
+ // makes _startTime NaN), pushing invalid values through _apply. Reject them up front.
+ throw new ArgumentException(
+ "Duration, Delay and RepeatDelay must be finite values.", nameof(config));
+ if (config.Times != null && config.Times.Length != frames.Length)
+ throw new ArgumentException("Times array length must match the number of frames.", nameof(config));
+ if (config.Times != null)
+ {
+ // Times feed the Interpolate segment math; non-monotonic or out-of-range values produce
+ // negative/zero segment lengths and NaN output, so reject them up front.
+ for (int i = 0; i < config.Times.Length; i++)
+ {
+ // double.NaN/infinity slip past the relational checks below (every comparison with
+ // NaN is false), so reject non-finite entries explicitly before the range/order tests.
+ if (!double.IsFinite(config.Times[i]))
+ throw new ArgumentException("Times values must be finite.", nameof(config));
+ if (config.Times[i] < 0 || config.Times[i] > 1)
+ throw new ArgumentException("Times values must be within the range [0, 1].", nameof(config));
+ if (i > 0 && config.Times[i] < config.Times[i - 1])
+ throw new ArgumentException("Times values must be in monotonically ascending order.", nameof(config));
+ }
+ }
+
+ _frames = (double[])frames.Clone();
+ _curFrames = (double[])frames.Clone();
+ _durationMs = config.Duration * 1000;
+ _delayMs = config.Delay * 1000;
+ _repeat = config.Repeat;
+ _isInfinite = config.IsInfiniteRepeat;
+ _repeatType = config.RepeatType;
+ _repeatDelayMs = config.RepeatDelay * 1000;
+ _apply = apply;
+
+ int n = frames.Length;
+ // Clone the caller's Times so the in-place MirrorTimes mutation never touches their config.
+ _times = config.Times != null
+ ? (double[])config.Times.Clone()
+ : Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray();
+
+ // Per-segment easing array. Per-segment easing isn't exposed on the transition config yet,
+ // so every segment currently shares the single configured easing function; the array shape
+ // is kept so adding per-segment curves later doesn't change the interpolation code path.
+ _eases = new Func[n - 1];
+ var globalEase = BmotionEasingFunctions.Get(config);
+ for (int i = 0; i < n - 1; i++)
+ _eases[i] = globalEase;
+ }
+
+ public bool Tick(double timestamp)
+ {
+ // Freeze at the current value on cancel (consistent with the other drivers); callers
+ // remove the driver immediately after Cancel(), so this branch is defensive only.
+ if (_cancelled) return true;
+
+ if (_startTime < 0) _startTime = timestamp + _delayMs;
+ if (timestamp < _startTime) { _apply(_curFrames[0]); return false; }
+
+ double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0;
+ _apply(Interpolate(_curFrames, _times, _eases, t));
+
+ if (t >= 1.0)
+ {
+ if (_isInfinite || _iteration < _repeat)
+ {
+ if (!_isInfinite) _iteration++;
+ _startTime = timestamp + _repeatDelayMs;
+ // Mirror ping-pongs: reverse the playback direction every cycle (0→1, 1→0, …).
+ // Reverse plays the frames backwards repeatedly (1→0, 1→0, …): reverse once on the
+ // first repeat, then keep that order so each subsequent cycle replays in reverse
+ // rather than toggling back to forward.
+ if (_repeatType == BmotionRepeatType.Mirror)
+ {
+ Array.Reverse(_curFrames);
+ MirrorTimes(_times);
+ }
+ else if (_repeatType == BmotionRepeatType.Reverse && !_reversed)
+ {
+ Array.Reverse(_curFrames);
+ MirrorTimes(_times);
+ _reversed = true;
+ }
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void Cancel() => _cancelled = true;
+
+ public void Complete()
+ {
+ // Mirror/Reverse don't always terminate on the last frame, so snap to the correct natural
+ // terminal frame (computed from the original forward-order _frames):
+ // • Mirror ping-pongs each pass (total passes = _repeat + 1). An even count ends back on
+ // the first frame, an odd count on the last.
+ // • Reverse plays forward once then replays reversed for every later pass, so it ends on
+ // the last frame only when there are no repeats, otherwise on the first frame.
+ // Infinite repeats have no natural end, so fall through to the last frame.
+ if (!_isInfinite && _repeatType == BmotionRepeatType.Mirror)
+ {
+ _apply((_repeat + 1) % 2 == 0 ? _frames[0] : _frames[^1]);
+ return;
+ }
+ if (!_isInfinite && _repeatType == BmotionRepeatType.Reverse)
+ {
+ _apply(_repeat == 0 ? _frames[^1] : _frames[0]);
+ return;
+ }
+ _apply(_frames[^1]);
+ }
+
+ ///
+ /// Mirrors a (possibly non-uniform) times array in place so segment durations line up with the
+ /// reversed frame order: newTimes[i] = 1 - times[n-1-i] . Applying it twice restores the
+ /// original, matching how Mirror/Reverse alternate direction each iteration.
+ ///
+ private static void MirrorTimes(double[] times)
+ {
+ int n = times.Length;
+ for (int i = 0; i < n / 2; i++)
+ {
+ double a = 1 - times[n - 1 - i];
+ double b = 1 - times[i];
+ times[i] = a;
+ times[n - 1 - i] = b;
+ }
+ if (n % 2 == 1) times[n / 2] = 1 - times[n / 2];
+ }
+
+ private static double Interpolate(double[] frames, double[] times, Func[] eases, double t)
+ {
+ int n = frames.Length;
+ int seg = n - 2;
+ for (int i = 0; i < n - 1; i++)
+ {
+ if (t <= times[i + 1]) { seg = i; break; }
+ }
+ double segLen = times[seg + 1] - times[seg];
+ double segT = segLen > 0 ? (t - times[seg]) / segLen : 1.0;
+ double easedT = eases[seg](Math.Clamp(segT, 0.0, 1.0));
+ return frames[seg] + (frames[seg + 1] - frames[seg]) * easedT;
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs
new file mode 100644
index 0000000000..71f099857d
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs
@@ -0,0 +1,127 @@
+
+namespace Bit.Bmotion;
+///
+/// Semi-implicit Euler spring physics driver for numeric properties.
+/// Automatically subdivides each frame to maintain numerical stability for
+/// high-stiffness / high-damping configurations.
+///
+internal sealed class BmotionSpringDriver : IBmotionAnimationDriver
+{
+ private double _target;
+ private double _from;
+ private readonly double _k; // stiffness
+ private readonly double _d; // damping
+ private readonly double _m; // mass
+ private readonly double _initialVel;
+ private readonly double _restSpeed;
+ private readonly double _restDelta;
+ private readonly double _repeatDelayMs;
+ private readonly double _maxSubDt;
+ private readonly int _repeat;
+ private readonly bool _isInfinite;
+ private readonly BmotionRepeatType _repeatType;
+ private readonly Action _apply;
+
+ private double _pos;
+ private double _vel;
+ private double _currentDelayMs;
+ private double _lastTs = -1;
+ private double _startTs = -1;
+ private int _iteration;
+ private bool _cancelled;
+
+ public BmotionSpringDriver(double from, double to, BmotionTransitionConfig config, Action apply)
+ {
+ _pos = _from = from;
+ _target = to;
+
+ // Resolve stiffness/damping: if Bounce+VisualDuration (or Bounce+Duration) are set,
+ // derive them from those intuitive parameters (Framer Motion-compatible).
+ double k = config.Stiffness;
+ double d = config.Damping;
+ if (config.Bounce.HasValue)
+ {
+ double vd = config.VisualDuration ?? config.Duration;
+ (k, d) = BmotionTransitionConfig.SpringFromBounce(vd, config.Bounce.Value, config.Mass);
+ }
+
+ _k = k;
+ _d = d;
+ // Mass divides the acceleration each sub-step; a value <= 0 would yield NaN/Infinity and
+ // trap the spring (the rest test would never pass). Fall back to the default mass of 1.
+ _m = config.Mass > 0 ? config.Mass : 1.0;
+ _vel = _initialVel = config.Velocity;
+ // Rest thresholds are scaled by the animation's magnitude so large-range springs (e.g.
+ // x: 0→1000) settle in proportion to their distance instead of chasing an absolute 0.01px/
+ // 0.01px-per-sec target for many extra frames. Small ranges keep the absolute thresholds.
+ double range = Math.Abs(to - from);
+ double restScale = range > 1.0 ? range : 1.0;
+ // Clamp to a small positive floor: a non-positive RestSpeed/RestDelta would make the
+ // completion gate (Abs(vel) < restSpeed && Abs(pos-target) < restDelta) unsatisfiable,
+ // leaving the spring ticking forever.
+ const double minRest = 1e-4;
+ _restSpeed = Math.Max(config.RestSpeed * restScale, minRest);
+ _restDelta = Math.Max(config.RestDelta * restScale, minRest);
+ _currentDelayMs = config.Delay * 1000;
+ _repeatDelayMs = config.RepeatDelay * 1000;
+ _repeat = config.Repeat;
+ _isInfinite = config.IsInfiniteRepeat;
+ _repeatType = config.RepeatType;
+ _apply = apply;
+
+ // Compute a maximum sub-step size that keeps semi-implicit Euler stable
+ _maxSubDt = Math.Max(0.001, Math.Min(
+ _d > 0 ? 1.8 / _d : 1.0,
+ _k > 0 ? 0.9 / Math.Sqrt(_k) : 1.0));
+ }
+
+ public bool Tick(double timestamp)
+ {
+ if (_cancelled) return true;
+
+ if (_startTs < 0) _startTs = timestamp;
+ if (timestamp - _startTs < _currentDelayMs) { _apply(_pos); return false; }
+
+ if (_lastTs < 0) _lastTs = timestamp;
+
+ double dt = Math.Min((timestamp - _lastTs) / 1000.0, 0.064);
+ _lastTs = timestamp;
+
+ int subSteps = Math.Max(1, (int)Math.Ceiling(dt / _maxSubDt));
+ double subDt = dt / subSteps;
+ for (int i = 0; i < subSteps; i++)
+ {
+ double springF = -_k * (_pos - _target);
+ double dampF = -_d * _vel;
+ _vel += (springF + dampF) / _m * subDt;
+ _pos += _vel * subDt;
+ }
+
+ _apply(_pos);
+
+ if (Math.Abs(_vel) < _restSpeed && Math.Abs(_pos - _target) < _restDelta)
+ {
+ _apply(_target);
+
+ if (_isInfinite || _iteration < _repeat)
+ {
+ if (!_isInfinite) _iteration++;
+ // Mirror/Reverse ping-pong back to the start; Loop replays from the origin.
+ if (_repeatType is BmotionRepeatType.Mirror or BmotionRepeatType.Reverse)
+ (_from, _target) = (_target, _from);
+ _pos = _from;
+ _vel = _initialVel;
+ _lastTs = -1;
+ _startTs = timestamp; // re-arm the delay window for this repeat
+ _currentDelayMs = _repeatDelayMs;
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void Cancel() => _cancelled = true;
+
+ public void Complete() => _apply(_target);
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionTransformComposer.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionTransformComposer.cs
new file mode 100644
index 0000000000..efe9af7ce2
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionTransformComposer.cs
@@ -0,0 +1,60 @@
+namespace Bit.Bmotion;
+///
+/// Builds a CSS transform string from a dictionary of individual transform components.
+/// Mirrors the JS buildTransformString function.
+///
+internal static class BmotionTransformComposer
+{
+ private static readonly HashSet _transformProps = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "x", "y", "z",
+ "rotateX", "rotateY", "rotateZ", "rotate",
+ "scaleX", "scaleY", "scale",
+ "skewX", "skewY",
+ "perspective",
+ };
+
+ public static bool IsTransformProp(string key) => _transformProps.Contains(key);
+
+ ///
+ /// Composes a CSS transform value string from a transform-components dictionary.
+ /// Returns an empty string when all values are at their identity.
+ ///
+ public static string Build(Dictionary t)
+ {
+ if (t.Count == 0) return string.Empty;
+
+ var parts = new List(8);
+
+ if (t.TryGetValue("perspective", out double persp) && persp != 0)
+ parts.Add($"perspective({BmotionCssFormat.Num(persp)}px)");
+
+ double x = t.GetValueOrDefault("x");
+ double y = t.GetValueOrDefault("y");
+ double z = t.GetValueOrDefault("z");
+ if (x != 0 || y != 0 || z != 0)
+ parts.Add(z != 0
+ ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)"
+ : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)");
+
+ if (t.TryGetValue("scale", out double scale) && scale != 1)
+ parts.Add($"scale({BmotionCssFormat.Num(scale)})");
+ else
+ {
+ if (t.TryGetValue("scaleX", out double sx) && sx != 1) parts.Add($"scaleX({BmotionCssFormat.Num(sx)})");
+ if (t.TryGetValue("scaleY", out double sy) && sy != 1) parts.Add($"scaleY({BmotionCssFormat.Num(sy)})");
+ }
+
+ // rotateZ / rotate aliases: prefer a non-zero rotateZ, otherwise fall back to rotate so a
+ // zero rotateZ doesn't mask a meaningful rotate value.
+ double rz = t.TryGetValue("rotateZ", out double rz2) && rz2 != 0 ? rz2 : t.GetValueOrDefault("rotate");
+ if (rz != 0) parts.Add($"rotate({BmotionCssFormat.Num(rz)}deg)");
+ if (t.TryGetValue("rotateX", out double rx) && rx != 0) parts.Add($"rotateX({BmotionCssFormat.Num(rx)}deg)");
+ if (t.TryGetValue("rotateY", out double ry) && ry != 0) parts.Add($"rotateY({BmotionCssFormat.Num(ry)}deg)");
+
+ if (t.TryGetValue("skewX", out double skx) && skx != 0) parts.Add($"skewX({BmotionCssFormat.Num(skx)}deg)");
+ if (t.TryGetValue("skewY", out double sky) && sky != 0) parts.Add($"skewY({BmotionCssFormat.Num(sky)}deg)");
+
+ return string.Join(" ", parts);
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs
new file mode 100644
index 0000000000..a0346b9908
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs
@@ -0,0 +1,67 @@
+
+namespace Bit.Bmotion;
+/// Tween (duration-based) animation driver for numeric properties.
+internal sealed class BmotionTweenDriver : IBmotionAnimationDriver
+{
+ private readonly double _to;
+ private readonly double _durationMs;
+ private readonly double _delayMs;
+ private readonly Func _easeFn;
+ private readonly int _repeat;
+ private readonly bool _isInfinite;
+ private readonly BmotionRepeatType _repeatType;
+ private readonly double _repeatDelayMs;
+ private readonly Action _apply;
+
+ private double _startTime = -1;
+ private bool _cancelled;
+ private int _iteration;
+ private double _curFrom;
+ private double _curTo;
+
+ public BmotionTweenDriver(double from, double to, BmotionTransitionConfig config, Action apply)
+ {
+ _curFrom = from;
+ _curTo = _to = to;
+ _durationMs = config.Duration * 1000;
+ _delayMs = config.Delay * 1000;
+ _easeFn = BmotionEasingFunctions.Get(config);
+ _repeat = config.Repeat;
+ _isInfinite = config.IsInfiniteRepeat;
+ _repeatType = config.RepeatType;
+ _repeatDelayMs = config.RepeatDelay * 1000;
+ _apply = apply;
+ }
+
+ public bool Tick(double timestamp)
+ {
+ if (_cancelled) return true;
+
+ if (_startTime < 0) _startTime = timestamp + _delayMs;
+ if (timestamp < _startTime) { _apply(_curFrom); return false; }
+
+ double elapsed = timestamp - _startTime;
+ double t = _durationMs > 0 ? Math.Min(elapsed / _durationMs, 1.0) : 1.0;
+ double p = _easeFn(t);
+ double value = _curFrom + (_curTo - _curFrom) * p;
+ _apply(value);
+
+ if (t >= 1.0)
+ {
+ if (_isInfinite || _iteration < _repeat)
+ {
+ if (!_isInfinite) _iteration++;
+ _startTime = timestamp + _repeatDelayMs;
+ if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse)
+ (_curFrom, _curTo) = (_curTo, _curFrom);
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void Cancel() => _cancelled = true;
+
+ public void Complete() => _apply(_to);
+}
diff --git a/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs
new file mode 100644
index 0000000000..8fab5d113e
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs
@@ -0,0 +1,22 @@
+namespace Bit.Bmotion;
+///
+/// Single animation driver interface.
+/// Each driver owns the callback that applies the animated value to
+/// state dictionaries.
+/// Returns true from when the animation is complete.
+///
+internal interface IBmotionAnimationDriver
+{
+ ///
+ /// Advance the animation to (milliseconds, matching performance.now() ).
+ /// Calls the apply-callback with the current value.
+ /// Returns true when the animation has finished and may be removed.
+ ///
+ bool Tick(double timestamp);
+
+ /// Cancel the animation, freezing the value at its current (intermediate) position.
+ void Cancel();
+
+ /// Finish immediately by applying the animation's final target value.
+ void Complete();
+}
diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs
new file mode 100644
index 0000000000..05da7f517b
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs
@@ -0,0 +1,12 @@
+namespace Bit.Bmotion;
+
+/// DOM bounding rect returned by getBoundingRect in JS.
+public sealed class BmotionBoundingRect
+{
+ public double X { get; set; }
+ public double Y { get; set; }
+ public double Width { get; set; }
+ public double Height { get; set; }
+ public double Top { get; set; }
+ public double Left { get; set; }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs
new file mode 100644
index 0000000000..965099e6e6
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs
@@ -0,0 +1,165 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
+
+namespace Bit.Bmotion;
+///
+/// Slim C# wrapper around the browser-API bridge in BitBmotion.js .
+/// Only calls browser-native APIs; all animation logic lives in the C# engine.
+///
+public sealed class BmotionInterop : IAsyncDisposable
+{
+ private readonly Lazy> _moduleTask;
+
+ public BmotionInterop(IJSRuntime js)
+ {
+ IsInProcess = js is IJSInProcessRuntime;
+ _moduleTask = new Lazy>(
+ () => js.InvokeAsync(
+ "import", "./_content/Bit.Bmotion/bit-bmotion.js").AsTask());
+ }
+
+ ///
+ /// true when the JS runtime supports synchronous interop (Blazor WebAssembly).
+ /// Bit.Bmotion's animation loop and drag handlers rely on synchronous JS↔.NET calls, so the
+ /// library only functions on WebAssembly. This is checked before the rAF loop starts.
+ ///
+ public bool IsInProcess { get; }
+
+ private async ValueTask Module() => await _moduleTask.Value;
+
+ // ── rAF loop ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Start the JS rAF loop. The loop calls dotnetRef.invokeMethod('ComputeFrame', timestamp)
+ /// synchronously each tick (Blazor WASM) and applies the returned style updates.
+ ///
+ public async ValueTask StartRafLoopAsync(DotNetObjectReference dotnetRef) where T : class
+ => await (await Module()).InvokeVoidAsync("startRafLoop", dotnetRef);
+
+ /// Stop the JS rAF loop for the given engine reference (or all engines when null).
+ public async ValueTask StopRafLoopAsync(DotNetObjectReference? dotnetRef = null) where T : class
+ {
+ if (!_moduleTask.IsValueCreated) return;
+ await (await Module()).InvokeVoidAsync("stopRafLoop", dotnetRef);
+ }
+
+ // ── Reduced motion (accessibility) ────────────────────────────────────────
+
+ ///
+ /// Returns whether the user's OS/browser has prefers-reduced-motion: reduce set.
+ ///
+ public async ValueTask PrefersReducedMotionAsync()
+ => await (await Module()).InvokeAsync("prefersReducedMotion");
+
+ ///
+ /// Subscribes to live changes of the prefers-reduced-motion media query. JS calls
+ /// OnReducedMotionChanged(bool) on the engine ref whenever the OS preference changes.
+ ///
+ public async ValueTask WatchReducedMotionAsync(DotNetObjectReference dotnetRef) where T : class
+ => await (await Module()).InvokeVoidAsync("watchReducedMotion", dotnetRef);
+
+ /// Unsubscribes the engine ref from prefers-reduced-motion change notifications.
+ public async ValueTask UnwatchReducedMotionAsync(DotNetObjectReference dotnetRef) where T : class
+ {
+ if (!_moduleTask.IsValueCreated) return;
+ await (await Module()).InvokeVoidAsync("unwatchReducedMotion", dotnetRef);
+ }
+
+ // ── Style application ─────────────────────────────────────────────────────
+
+ /// Instantly apply a CSS styles object to a DOM element (for set() calls).
+ public async ValueTask ApplyStylesAsync(string elementId, object styles)
+ => await (await Module()).InvokeVoidAsync("applyStyles", elementId, styles);
+
+ // ── Element registration ──────────────────────────────────────────────────
+
+ public async ValueTask RegisterElementAsync(string elementId)
+ => await (await Module()).InvokeVoidAsync("registerElement", elementId);
+
+ public async ValueTask UnregisterElementAsync(string elementId)
+ {
+ if (!_moduleTask.IsValueCreated) return;
+ await (await Module()).InvokeVoidAsync("unregisterElement", elementId);
+ }
+
+ // ── Gesture event listeners ───────────────────────────────────────────────
+
+ ///
+ /// Attach pointer / focus / drag event listeners to an element.
+ /// JS forwards raw browser events to the DotNet ref via async callbacks.
+ ///
+ public async ValueTask AttachEventListenersAsync(
+ string elementId, object events, DotNetObjectReference dotnetRef) where T : class
+ => await (await Module()).InvokeVoidAsync("attachEventListeners", elementId, events, dotnetRef);
+
+ // ── Viewport observation ──────────────────────────────────────────────────
+
+ public async ValueTask ObserveViewportAsync(
+ string elementId, DotNetObjectReference dotnetRef, bool once) where T : class
+ => await (await Module()).InvokeVoidAsync("observeViewport", elementId, dotnetRef,
+ new Dictionary { ["once"] = once, ["margin"] = "0px", ["threshold"] = 0.0 });
+
+ public async ValueTask ObserveViewportWithOptionsAsync(
+ string elementId, DotNetObjectReference dotnetRef, BmotionViewportOptions options) where T : class
+ => await (await Module()).InvokeVoidAsync("observeViewport", elementId, dotnetRef, options.ToJsObject());
+
+ public async ValueTask UnobserveViewportAsync(string elementId)
+ {
+ if (!_moduleTask.IsValueCreated) return;
+ await (await Module()).InvokeVoidAsync("unobserveViewport", elementId);
+ }
+
+ // ── FLIP layout ───────────────────────────────────────────────────────────
+
+ /// Returns the element's current bounding rect (for C# FLIP delta computation).
+ public async ValueTask GetBoundingRectAsync(string elementId)
+ => await (await Module()).InvokeAsync("getBoundingRect", elementId);
+
+ /// Run a FLIP animation via the Web Animations API.
+ public async ValueTask PlayWaapiFlipAsync(
+ string elementId, double dx, double dy, double sx, double sy,
+ double durationMs, string easingStr, string? finalTransform)
+ => await (await Module()).InvokeVoidAsync(
+ "playWaapiFlip", elementId, dx, dy, sx, sy, durationMs, easingStr, finalTransform);
+
+ // ── Scroll ────────────────────────────────────────────────────────────────
+
+ public async ValueTask ObserveScrollAsync(
+ string? containerId, DotNetObjectReference dotnetRef) where T : class
+ => await (await Module()).InvokeAsync("observeScroll", containerId, dotnetRef);
+
+ public async ValueTask UnobserveScrollAsync(string key)
+ {
+ if (!_moduleTask.IsValueCreated) return;
+ await (await Module()).InvokeVoidAsync("unobserveScroll", key);
+ }
+
+ // ── Programmatic animate() API ─────────────────────────────────────────────
+
+ ///
+ /// Resolves all DOM elements matching , assigns stable IDs
+ /// if needed, and returns those IDs so the can address them.
+ ///
+ public async ValueTask ResolveOrRegisterBySelectorAsync(string selector)
+ => await (await Module()).InvokeAsync("resolveOrRegisterBySelector", selector);
+
+ ///
+ /// Resolves the DOM element for , assigns a stable ID
+ /// if needed, and returns that ID.
+ ///
+ public async ValueTask ResolveOrRegisterByRefAsync(ElementReference elementReference)
+ => await (await Module()).InvokeAsync("resolveOrRegisterByRef", elementReference);
+
+ // ── Dispose ───────────────────────────────────────────────────────────────
+
+ public async ValueTask DisposeAsync()
+ {
+ if (!_moduleTask.IsValueCreated) return;
+ var module = await Module();
+ // Stop the rAF loop before disposing the module so no callbacks remain scheduled against a
+ // disposed module reference (passing null stops all engines). This keeps the JS side from
+ // invoking ComputeFrame on a torn-down reference during teardown.
+ await module.InvokeVoidAsync("stopRafLoop", new object?[] { null });
+ await module.DisposeAsync();
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs
new file mode 100644
index 0000000000..f58ac861ec
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs
@@ -0,0 +1,17 @@
+namespace Bit.Bmotion;
+
+///
+/// A simple x/y pair marshalled to JS at drag start (serialised as { x, y } ).
+/// Used instead of a boxed anonymous type so the shape is explicit and trim/AOT friendly.
+///
+public readonly struct BmotionXY
+{
+ public BmotionXY(double x, double y)
+ {
+ X = x;
+ Y = y;
+ }
+
+ public double X { get; init; }
+ public double Y { get; init; }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs
new file mode 100644
index 0000000000..f96dcca00c
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs
@@ -0,0 +1,342 @@
+namespace Bit.Bmotion;
+
+///
+/// Describes a set of animatable CSS / transform properties - the "what" of an animation.
+/// Assign to Initial, Animate, Exit, WhileHover, WhileTap, etc.
+///
+///
+/// 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
+/// to them risks CSS injection into the element's style .
+///
+public class BmotionAnimationProps
+{
+ // ── Transform properties ──────────────────────────────────────────────────
+ public double? X { get; set; }
+ public double? Y { get; set; }
+ public double? Z { get; set; }
+
+ public double? Scale { get; set; }
+ public double? ScaleX { get; set; }
+ public double? ScaleY { get; set; }
+
+ public double? Rotate { get; set; }
+ public double? RotateX { get; set; }
+ public double? RotateY { get; set; }
+ public double? RotateZ { get; set; }
+
+ public double? SkewX { get; set; }
+ public double? SkewY { get; set; }
+
+ public double? Perspective { get; set; }
+
+ // ── Visual properties ─────────────────────────────────────────────────────
+ public double? Opacity { get; set; }
+
+ // Accept CSS color strings: #rgb, #rrggbbaa, rgb(), hsl(), named colors
+ public string? BackgroundColor { get; set; }
+ public string? Color { get; set; }
+ public string? BorderColor { get; set; }
+ public string? OutlineColor { get; set; }
+ public string? Fill { get; set; }
+ public string? Stroke { get; set; }
+
+ // Box model (accept px values or CSS strings like "50%" or "2rem")
+ public string? Width { get; set; }
+ public string? Height { get; set; }
+ public string? BorderRadius { get; set; }
+ public string? BoxShadow { get; set; }
+
+ // ── SVG path drawing ──────────────────────────────────────────────────────
+ ///
+ /// 0 = invisible, 1 = fully drawn. Drives strokeDashoffset.
+ ///
+ /// The generated stroke-dasharray /stroke-dashoffset values are normalised to a
+ /// unit path length, so the target SVG element must declare pathLength="1" for the
+ /// drawing to render correctly.
+ ///
+ ///
+ public double? PathLength { get; set; }
+ /// Offset along the path (0–1).
+ public double? PathOffset { get; set; }
+ /// Spacing between dash/gap pairs (0–1).
+ public double? PathSpacing { get; set; }
+
+ // ── CSS custom properties (e.g. "--my-var") ───────────────────────────────
+ /// Animate arbitrary CSS custom properties. Keys must start with "--".
+ public Dictionary? CssVars { get; set; }
+
+ // ── Keyframe arrays ───────────────────────────────────────────────────────
+ ///
+ /// Per-property keyframe arrays for multi-step animations.
+ /// Keys are the same as the simple property names ("x", "y", "scale", "opacity",
+ /// "backgroundColor", etc.). Values are double[] or string[] .
+ /// When a key is present here it takes precedence over the single-value property.
+ ///
+ ///
+ /// new BmotionAnimationProps
+ /// {
+ /// Keyframes = new()
+ /// {
+ /// ["scale"] = new double[] { 1, 1.4, 0.8, 1 },
+ /// ["backgroundColor"] = new string[] { "#6c47ff", "#ff4785", "#6c47ff" }
+ /// }
+ /// }
+ ///
+ ///
+ ///
+ public Dictionary? Keyframes { get; set; }
+
+ ///
+ /// Serialise to a plain JS-friendly dictionary that the interop layer understands.
+ ///
+ internal Dictionary ToJsDictionary()
+ {
+ var d = new Dictionary();
+
+ if (X.HasValue) d["x"] = X.Value;
+ if (Y.HasValue) d["y"] = Y.Value;
+ if (Z.HasValue) d["z"] = Z.Value;
+ if (Scale.HasValue) d["scale"] = Scale.Value;
+ if (ScaleX.HasValue) d["scaleX"] = ScaleX.Value;
+ if (ScaleY.HasValue) d["scaleY"] = ScaleY.Value;
+ if (Rotate.HasValue) d["rotate"] = Rotate.Value;
+ if (RotateX.HasValue) d["rotateX"] = RotateX.Value;
+ if (RotateY.HasValue) d["rotateY"] = RotateY.Value;
+ if (RotateZ.HasValue) d["rotateZ"] = RotateZ.Value;
+ if (SkewX.HasValue) d["skewX"] = SkewX.Value;
+ if (SkewY.HasValue) d["skewY"] = SkewY.Value;
+ if (Perspective.HasValue) d["perspective"] = Perspective.Value;
+ if (Opacity.HasValue) d["opacity"] = Opacity.Value;
+ if (BackgroundColor != null) d["backgroundColor"] = BackgroundColor;
+ if (Color != null) d["color"] = Color;
+ if (BorderColor != null) d["borderColor"] = BorderColor;
+ if (OutlineColor != null) d["outlineColor"] = OutlineColor;
+ if (Fill != null) d["fill"] = Fill;
+ if (Stroke != null) d["stroke"] = Stroke;
+ if (Width != null) d["width"] = Width;
+ if (Height != null) d["height"] = Height;
+ if (BorderRadius != null) d["borderRadius"] = BorderRadius;
+ if (BoxShadow != null) d["boxShadow"] = BoxShadow;
+ if (PathLength.HasValue) d["pathLength"] = PathLength.Value;
+ if (PathOffset.HasValue) d["pathOffset"] = PathOffset.Value;
+ if (PathSpacing.HasValue) d["pathSpacing"] = PathSpacing.Value;
+
+ if (CssVars != null)
+ foreach (var kv in CssVars)
+ {
+ if (!kv.Key.StartsWith("--")) continue; // contract: CSS custom property keys start with "--"
+ d[kv.Key] = kv.Value;
+ }
+
+ // Keyframe arrays override single values
+ if (Keyframes != null)
+ foreach (var kv in Keyframes)
+ d[kv.Key] = kv.Value;
+
+ return d;
+ }
+
+ ///
+ /// Render these props as an inline CSS style string - used server-side to avoid a
+ /// flash of un-styled content before the JS interop layer initialises.
+ ///
+ internal string ToCssStyleString()
+ {
+ var sb = new System.Text.StringBuilder();
+
+ var transforms = new List();
+ if (X.HasValue || Y.HasValue || Z.HasValue)
+ {
+ double x = X ?? 0, y = Y ?? 0, z = Z ?? 0;
+ if (z != 0)
+ transforms.Add($"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)");
+ else
+ transforms.Add($"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)");
+ }
+ if (Scale.HasValue && Scale.Value != 1)
+ {
+ transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})");
+ }
+ else
+ {
+ if (ScaleX.HasValue && ScaleX.Value != 1) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})");
+ if (ScaleY.HasValue && ScaleY.Value != 1) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})");
+ }
+ // Prefer a non-zero rotateZ, otherwise fall back to rotate, so an explicit RotateZ = 0
+ // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer).
+ double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0);
+ if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)");
+ if (RotateX.HasValue && RotateX.Value != 0) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)");
+ if (RotateY.HasValue && RotateY.Value != 0) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)");
+ if (SkewX.HasValue && SkewX.Value != 0) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)");
+ if (SkewY.HasValue && SkewY.Value != 0) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)");
+ if (Perspective.HasValue) transforms.Insert(0, $"perspective({BmotionCssFormat.Num(Perspective.Value)}px)");
+
+ if (transforms.Count > 0) sb.Append($"transform:{string.Join(" ", transforms)};");
+
+ if (Opacity.HasValue) sb.Append($"opacity:{BmotionCssFormat.Num(Opacity.Value)};");
+ if (BackgroundColor != null) sb.Append($"background-color:{BackgroundColor};");
+ if (Color != null) sb.Append($"color:{Color};");
+ if (BorderColor != null) sb.Append($"border-color:{BorderColor};");
+ if (OutlineColor != null) sb.Append($"outline-color:{OutlineColor};");
+ if (Fill != null) sb.Append($"fill:{Fill};");
+ if (Stroke != null) sb.Append($"stroke:{Stroke};");
+ if (Width != null) sb.Append($"width:{Width};");
+ if (Height != null) sb.Append($"height:{Height};");
+ if (BorderRadius != null) sb.Append($"border-radius:{BorderRadius};");
+ if (BoxShadow != null) sb.Append($"box-shadow:{BoxShadow};");
+ if (PathLength.HasValue)
+ {
+ double clamped = Math.Max(0, Math.Min(1, PathLength.Value));
+ double spacing = Math.Max(0, Math.Min(1, PathSpacing ?? 1.0));
+ double offset = Math.Max(0, Math.Min(1, PathOffset ?? 0.0));
+ sb.Append($"stroke-dasharray:{BmotionCssFormat.Num(clamped)} {BmotionCssFormat.Num(spacing)};");
+ sb.Append($"stroke-dashoffset:{BmotionCssFormat.Num(1 - clamped - offset)};");
+ }
+
+ if (CssVars != null)
+ foreach (var kv in CssVars)
+ {
+ if (!kv.Key.StartsWith("--")) continue; // contract: CSS custom property keys start with "--"
+ sb.Append($"{kv.Key}:{kv.Value};");
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Render these props as a dictionary of individual CSS declarations
+ /// (camelCase keys suitable for element.style[prop] = value ).
+ /// Used by instant set() calls so we update only the specified
+ /// declarations instead of replacing the element's entire inline style
+ /// (which assigning cssText would do).
+ ///
+ internal Dictionary ToCssStyleDictionary()
+ {
+ var d = new Dictionary();
+
+ var transforms = new List();
+ if (Perspective.HasValue) transforms.Add($"perspective({BmotionCssFormat.Num(Perspective.Value)}px)");
+ if (X.HasValue || Y.HasValue || Z.HasValue)
+ {
+ double x = X ?? 0, y = Y ?? 0, z = Z ?? 0;
+ transforms.Add(z != 0
+ ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)"
+ : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)");
+ }
+ if (Scale.HasValue && Scale.Value != 1)
+ {
+ transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})");
+ }
+ else
+ {
+ if (ScaleX.HasValue && ScaleX.Value != 1) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})");
+ if (ScaleY.HasValue && ScaleY.Value != 1) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})");
+ }
+ // Prefer a non-zero rotateZ, otherwise fall back to rotate, so an explicit RotateZ = 0
+ // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer).
+ double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0);
+ if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)");
+ if (RotateX.HasValue && RotateX.Value != 0) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)");
+ if (RotateY.HasValue && RotateY.Value != 0) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)");
+ if (SkewX.HasValue && SkewX.Value != 0) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)");
+ if (SkewY.HasValue && SkewY.Value != 0) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)");
+ if (transforms.Count > 0) d["transform"] = string.Join(" ", transforms);
+
+ if (Opacity.HasValue) d["opacity"] = BmotionCssFormat.Num(Opacity.Value);
+ if (BackgroundColor != null) d["backgroundColor"] = BackgroundColor;
+ if (Color != null) d["color"] = Color;
+ if (BorderColor != null) d["borderColor"] = BorderColor;
+ if (OutlineColor != null) d["outlineColor"] = OutlineColor;
+ if (Fill != null) d["fill"] = Fill;
+ if (Stroke != null) d["stroke"] = Stroke;
+ if (Width != null) d["width"] = Width;
+ if (Height != null) d["height"] = Height;
+ if (BorderRadius != null) d["borderRadius"] = BorderRadius;
+ if (BoxShadow != null) d["boxShadow"] = BoxShadow;
+ if (PathLength.HasValue)
+ {
+ double clamped = Math.Max(0, Math.Min(1, PathLength.Value));
+ double spacing = Math.Max(0, Math.Min(1, PathSpacing ?? 1.0));
+ double offset = Math.Max(0, Math.Min(1, PathOffset ?? 0.0));
+ d["strokeDasharray"] = $"{BmotionCssFormat.Num(clamped)} {BmotionCssFormat.Num(spacing)}";
+ d["strokeDashoffset"] = BmotionCssFormat.Num(1 - clamped - offset);
+ }
+
+ if (CssVars != null)
+ foreach (var kv in CssVars)
+ {
+ if (!kv.Key.StartsWith("--")) continue; // contract: CSS custom property keys start with "--"
+ d[kv.Key] = kv.Value;
+ }
+
+ return d;
+ }
+
+ ///
+ /// Structural value comparison used by to decide whether an
+ /// Animate target actually changed between renders. This avoids re-triggering an
+ /// animation (and OnAnimationStart ) on every unrelated re-render when a consumer writes
+ /// the idiomatic Animate="@(new BmotionAnimationProps { ... })" (a fresh reference each render).
+ ///
+ internal bool ValueEquals(BmotionAnimationProps? o)
+ {
+ if (o is null) return false;
+ if (ReferenceEquals(this, o)) return true;
+
+ bool scalars =
+ X == o.X && Y == o.Y && Z == o.Z &&
+ Scale == o.Scale && ScaleX == o.ScaleX && ScaleY == o.ScaleY &&
+ Rotate == o.Rotate && RotateX == o.RotateX && RotateY == o.RotateY && RotateZ == o.RotateZ &&
+ SkewX == o.SkewX && SkewY == o.SkewY && Perspective == o.Perspective &&
+ Opacity == o.Opacity &&
+ BackgroundColor == o.BackgroundColor && Color == o.Color && BorderColor == o.BorderColor &&
+ OutlineColor == o.OutlineColor && Fill == o.Fill && Stroke == o.Stroke &&
+ Width == o.Width && Height == o.Height && BorderRadius == o.BorderRadius && BoxShadow == o.BoxShadow &&
+ PathLength == o.PathLength && PathOffset == o.PathOffset && PathSpacing == o.PathSpacing;
+
+ return scalars && DictEquals(CssVars, o.CssVars) && KeyframeDictEquals(Keyframes, o.Keyframes);
+ }
+
+ private static bool DictEquals(Dictionary? a, Dictionary? b)
+ {
+ if (ReferenceEquals(a, b)) return true;
+ if (a is null || b is null || a.Count != b.Count) return false;
+ foreach (var kv in a)
+ if (!b.TryGetValue(kv.Key, out var v) || v != kv.Value) return false;
+ return true;
+ }
+
+ private static bool KeyframeDictEquals(Dictionary? a, Dictionary? b)
+ {
+ if (ReferenceEquals(a, b)) return true;
+ if (a is null || b is null || a.Count != b.Count) return false;
+ foreach (var kv in a)
+ {
+ if (!b.TryGetValue(kv.Key, out var v)) return false;
+ if (!SequenceEquals(kv.Value, v)) return false;
+ }
+ return true;
+ }
+
+ private static bool SequenceEquals(object? a, object? b)
+ {
+ if (Equals(a, b)) return true;
+ if (a is System.Collections.IEnumerable ea && a is not string &&
+ b is System.Collections.IEnumerable eb && b is not string)
+ {
+ var ia = ea.GetEnumerator();
+ var ib = eb.GetEnumerator();
+ while (true)
+ {
+ bool na = ia.MoveNext(), nb = ib.MoveNext();
+ if (na != nb) return false;
+ if (!na) return true;
+ if (!Equals(ia.Current, ib.Current)) return false;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs
new file mode 100644
index 0000000000..13eab40d4b
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs
@@ -0,0 +1,49 @@
+namespace Bit.Bmotion;
+///
+/// Union type for animation target parameters (Initial, Animate, Exit, WhileHover, …).
+/// Can be implicitly constructed from , a variant name string,
+/// or false to disable the target entirely.
+///
+public sealed class BmotionAnimationTarget
+{
+ /// Direct set of animation properties.
+ public BmotionAnimationProps? Props { get; private init; }
+
+ /// Name of a variant defined in the nearest Bmotion ancestor's Variants dictionary.
+ public string? Variant { get; private init; }
+
+ /// When true this target is explicitly disabled (e.g. Initial="false" ).
+ public bool IsDisabled { get; private init; }
+
+ public bool HasProps => Props != null;
+ public bool IsVariant => Variant != null;
+
+ ///
+ /// Value-based equivalence between two targets, used for change detection.
+ /// Two prop targets are equivalent when their values match;
+ /// two variant targets when they name the same variant.
+ ///
+ internal static bool AreEquivalent(BmotionAnimationTarget? a, BmotionAnimationTarget? b)
+ {
+ if (ReferenceEquals(a, b)) return true;
+ if (a is null || b is null) return false;
+ if (a.IsDisabled || b.IsDisabled) return a.IsDisabled == b.IsDisabled;
+ if (a.IsVariant || b.IsVariant)
+ return string.Equals(a.Variant, b.Variant, StringComparison.OrdinalIgnoreCase);
+ if (a.Props is null || b.Props is null) return a.Props is null && b.Props is null;
+ return a.Props.ValueEquals(b.Props);
+ }
+
+ // ── Implicit conversions ──────────────────────────────────────────────────
+ // Null inputs convert to a null target (not a target wrapping null) so downstream code can
+ // distinguish "no target set" from "target set to empty props" - e.g. the variant-fallback
+ // check in Bmotion only fires when Animate is genuinely null.
+ public static implicit operator BmotionAnimationTarget?(BmotionAnimationProps? props)
+ => props is null ? null : new() { Props = props };
+
+ public static implicit operator BmotionAnimationTarget?(string? variant)
+ => variant is null ? null : new() { Variant = variant };
+
+ public static implicit operator BmotionAnimationTarget(bool value)
+ => value ? new() { Props = new BmotionAnimationProps() } : new() { IsDisabled = true };
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs
new file mode 100644
index 0000000000..149d172ce3
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs
@@ -0,0 +1,3 @@
+namespace Bit.Bmotion;
+
+public enum BmotionDragAxis { Both, X, Y }
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs
new file mode 100644
index 0000000000..2b6680dbc5
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs
@@ -0,0 +1,28 @@
+namespace Bit.Bmotion;
+
+public class BmotionDragConstraints
+{
+ public double? Left { get; set; }
+ public double? Right { get; set; }
+ public double? Top { get; set; }
+ public double? Bottom { get; set; }
+
+ public static BmotionDragConstraints Horizontal(double left, double right)
+ => new() { Left = left, Right = right };
+
+ public static BmotionDragConstraints Vertical(double top, double bottom)
+ => new() { Top = top, Bottom = bottom };
+
+ public static BmotionDragConstraints Box(double left, double right, double top, double bottom)
+ => new() { Left = left, Right = right, Top = top, Bottom = bottom };
+
+ internal object ToJsObject()
+ {
+ var d = new Dictionary();
+ if (Left.HasValue) d["left"] = Left.Value;
+ if (Right.HasValue) d["right"] = Right.Value;
+ if (Top.HasValue) d["top"] = Top.Value;
+ if (Bottom.HasValue) d["bottom"] = Bottom.Value;
+ return d;
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs
new file mode 100644
index 0000000000..fcd497e8f9
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs
@@ -0,0 +1,50 @@
+namespace Bit.Bmotion;
+///
+/// Options for the drag gesture on a Bmotion element.
+///
+public class BmotionDragOptions
+{
+ /// Restrict drag to a single axis. Defaults to .
+ public BmotionDragAxis Axis { get; set; } = BmotionDragAxis.Both;
+
+ ///
+ /// Constraint bounds (in px relative to the element's resting position).
+ /// Null = unconstrained.
+ ///
+ public BmotionDragConstraints? Constraints { get; set; }
+
+ ///
+ /// Elasticity when the drag exceeds constraints (0 = rigid, 1 = fully elastic).
+ /// Default: 0.35. Values are clamped to the [0, 1] range.
+ ///
+ public double Elastic
+ {
+ get => _elastic;
+ set => _elastic = Math.Clamp(value, 0, 1);
+ }
+ private double _elastic = 0.35;
+
+ ///
+ /// Whether to apply momentum / inertia after releasing. Default: true.
+ ///
+ public bool Momentum { get; set; } = true;
+
+ ///
+ /// Transition applied when snapping back to constraints after release.
+ /// Defaults to a spring.
+ ///
+ public BmotionTransitionConfig? SnapTransition { get; set; }
+
+ ///
+ /// If true, the draggable element will spring back to its center (origin) when released.
+ /// Default: false.
+ ///
+ public bool SnapToOrigin { get; set; }
+
+ ///
+ /// Locks drag to the dominant movement axis once detected.
+ /// For example, moving mostly horizontally will lock drag to x only.
+ /// Default: false.
+ ///
+ public bool DirectionLock { get; set; }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs
new file mode 100644
index 0000000000..232e75e022
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs
@@ -0,0 +1,10 @@
+namespace Bit.Bmotion;
+
+public enum BmotionEasing
+{
+ Linear,
+ EaseIn, EaseOut, EaseInOut,
+ CircIn, CircOut, CircInOut,
+ BackIn, BackOut, BackInOut,
+ Anticipate
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs
new file mode 100644
index 0000000000..a7944444ac
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs
@@ -0,0 +1,38 @@
+namespace Bit.Bmotion;
+///
+/// A named set of animation states (variants) that can be referenced by name on
+/// any Bmotion component. Children automatically inherit the active variant name
+/// unless they define their own.
+///
+public class BmotionMotionVariants
+{
+ private readonly Dictionary _variants = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Adds a variant by name. If a variant with the same name already exists
+ /// (case-insensitive) it is replaced .
+ ///
+ public BmotionMotionVariants Add(string name, BmotionAnimationProps props)
+ {
+ ArgumentNullException.ThrowIfNull(props);
+ _variants[name] = props;
+ return this;
+ }
+
+ public BmotionAnimationProps? Get(string name)
+ => _variants.TryGetValue(name, out var v) ? v : null;
+
+ public bool Contains(string name) => _variants.ContainsKey(name);
+
+ public BmotionAnimationProps? this[string name] => Get(name);
+
+ // ── Builder shorthand ─────────────────────────────────────────────────────
+ public static BmotionMotionVariants Create(params (string name, BmotionAnimationProps props)[] entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ var mv = new BmotionMotionVariants();
+ foreach (var (name, props) in entries)
+ mv.Add(name, props);
+ return mv;
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs
new file mode 100644
index 0000000000..d037d0be0f
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs
@@ -0,0 +1,20 @@
+namespace Bit.Bmotion;
+
+///
+/// Information about a pan gesture provided to OnPan callbacks.
+/// Matches the Framer Motion pan event info shape.
+///
+public class BmotionPanInfo
+{
+ /// Current pointer position relative to the document.
+ public required BmotionPointInfo Point { get; init; }
+
+ /// Distance moved since the last event.
+ public required BmotionPointInfo Delta { get; init; }
+
+ /// Total distance moved since the pan gesture started.
+ public required BmotionPointInfo Offset { get; init; }
+
+ /// Current velocity of the pointer (pixels per second).
+ public required BmotionPointInfo Velocity { get; init; }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs
new file mode 100644
index 0000000000..aee0a99014
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs
@@ -0,0 +1,8 @@
+namespace Bit.Bmotion;
+
+/// A 2-D point with and components.
+public class BmotionPointInfo
+{
+ public double X { get; set; }
+ public double Y { get; set; }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs
new file mode 100644
index 0000000000..edbb15638b
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs
@@ -0,0 +1,12 @@
+namespace Bit.Bmotion;
+
+/// How a repeating animation behaves on each subsequent iteration.
+public enum BmotionRepeatType
+{
+ /// Restart from the beginning each iteration (0 → 1, 0 → 1, …).
+ Loop,
+ /// Ping-pong: alternate direction each iteration (0 → 1, 1 → 0, …).
+ Mirror,
+ /// Jump back to the start and play in reverse each iteration (1 → 0, 1 → 0, …).
+ Reverse,
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs
new file mode 100644
index 0000000000..4d3dff38c1
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs
@@ -0,0 +1,29 @@
+namespace Bit.Bmotion;
+
+/// Data returned by the on each scroll event.
+public class BmotionScrollInfo
+{
+ /// Horizontal scroll offset in pixels.
+ public double ScrollX { get; init; }
+
+ /// Vertical scroll offset in pixels.
+ public double ScrollY { get; init; }
+
+ /// Horizontal scroll progress 0–1.
+ public double ProgressX { get; init; }
+
+ /// Vertical scroll progress 0–1.
+ public double ProgressY { get; init; }
+
+ /// Total scrollable content width in pixels (including the part outside the viewport).
+ public double ScrollWidth { get; init; }
+
+ /// Total scrollable content height in pixels (including the part outside the viewport).
+ public double ScrollHeight { get; init; }
+
+ /// Visible viewport width in pixels (the currently displayed area).
+ public double ClientWidth { get; init; }
+
+ /// Visible viewport height in pixels (the currently displayed area).
+ public double ClientHeight { get; init; }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs
new file mode 100644
index 0000000000..9d947d860e
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs
@@ -0,0 +1,258 @@
+namespace Bit.Bmotion;
+/// Controls how a value transitions from one state to another.
+public class BmotionTransitionConfig
+{
+ // ── Type ─────────────────────────────────────────────────────────────────
+ /// Animation driver: Tween, Spring, or Inertia. Default: Tween.
+ public BmotionTransitionType Type { get; set; } = BmotionTransitionType.Tween;
+
+ // ── Tween ─────────────────────────────────────────────────────────────────
+ /// Duration in seconds. Default: 0.3.
+ public double Duration { get; set; } = 0.3;
+
+ /// Delay before animation starts, in seconds. Default: 0.
+ public double Delay { get; set; } = 0;
+
+ /// Named easing preset. See . Default: EaseOut.
+ public BmotionEasing Ease { get; set; } = BmotionEasing.EaseOut;
+
+ ///
+ /// Custom cubic-bezier as [x1, y1, x2, y2]. Overrides when set.
+ /// Must be either null or an array of exactly 4 finite values.
+ ///
+ public double[]? EaseCubicBezier
+ {
+ get => _easeCubicBezier is null ? null : (double[])_easeCubicBezier.Clone();
+ set
+ {
+ var validated = ValidateCubicBezier(value);
+ _easeCubicBezier = validated is null ? null : (double[])validated.Clone();
+ }
+ }
+ private double[]? _easeCubicBezier;
+
+ private static double[]? ValidateCubicBezier(double[]? value)
+ {
+ if (value is null) return null;
+ if (value.Length != 4 || !value.All(double.IsFinite))
+ throw new ArgumentException(
+ "EaseCubicBezier must be null or an array of exactly 4 finite values [x1, y1, x2, y2].",
+ nameof(value));
+ // The control-point X coordinates must stay within [0, 1] so the bezier's x(t) curve is
+ // monotonic. Outside that range x(t) can fold back on itself, and the Newton-Raphson solver
+ // in BmotionEasingFunctions can then converge to the wrong root (a visibly broken easing).
+ if (value[0] is < 0 or > 1 || value[2] is < 0 or > 1)
+ throw new ArgumentException(
+ "EaseCubicBezier X coordinates (x1, x2) must be within [0, 1]; only Y may overshoot.",
+ nameof(value));
+ return value;
+ }
+
+ // ── Repeat ────────────────────────────────────────────────────────────────
+ ///
+ /// Number of times to repeat. Setting (preferred) or the legacy
+ /// sentinel int.MaxValue makes the animation repeat forever.
+ ///
+ public int Repeat { get; set; } = 0;
+
+ ///
+ /// When true the animation repeats forever, regardless of .
+ /// Prefer this over the legacy Repeat = int.MaxValue sentinel.
+ ///
+ public bool RepeatInfinite { get; set; }
+
+ /// True when this transition repeats forever (via flag or legacy sentinel).
+ internal bool IsInfiniteRepeat => RepeatInfinite || Repeat == int.MaxValue;
+
+ /// How to repeat: Loop, Mirror (ping-pong), or Reverse.
+ public BmotionRepeatType RepeatType { get; set; } = BmotionRepeatType.Loop;
+
+ /// Delay between repetitions, in seconds.
+ public double RepeatDelay { get; set; } = 0;
+
+ // ── Keyframes ─────────────────────────────────────────────────────────────
+ ///
+ /// Progress offsets (0–1) for each keyframe value. Length must match value array.
+ /// If omitted the frames are evenly distributed.
+ ///
+ public double[]? Times { get; set; }
+
+ // ── Spring ────────────────────────────────────────────────────────────────
+ /// Spring stiffness (N/m). Higher = snappier. Default: 100.
+ public double Stiffness { get; set; } = 100;
+
+ /// Damping coefficient. Higher = less oscillation. Default: 10.
+ public double Damping { get; set; } = 10;
+
+ /// Virtual mass. Higher = slower acceleration. Default: 1.
+ public double Mass { get; set; } = 1;
+
+ /// Initial velocity for the spring (units/s). Default: 0.
+ public double Velocity { get; set; } = 0;
+
+ /// Minimum speed (units/s) considered at rest. Default: 0.01.
+ public double RestSpeed { get; set; } = 0.01;
+
+ /// Minimum distance from target considered at rest. Default: 0.01.
+ public double RestDelta { get; set; } = 0.01;
+
+ ///
+ /// Bounciness of a duration-based spring (0 = critically damped, 1 = very bouncy).
+ /// When set together with or ,
+ /// stiffness and damping are derived automatically (overriding their values).
+ ///
+ public double? Bounce { get; set; }
+
+ ///
+ /// The visual time (in seconds) the spring will take to appear to reach its target.
+ /// Works together with for intuitive spring configuration.
+ /// Overrides when computing spring parameters.
+ ///
+ public double? VisualDuration { get; set; }
+
+ // ── Inertia ───────────────────────────────────────────────────────────────
+ /// Velocity at the start of deceleration. Default: 0.
+ public double InertiaVelocity { get; set; } = 0;
+
+ /// Exponential decay time constant in ms. Default: 700.
+ public double TimeConstant { get; set; } = 700;
+
+ /// Multiplier for the projected distance. Default: 0.8.
+ public double Power { get; set; } = 0.8;
+
+ /// Minimum distance from target that counts as at rest. Default: 0.5.
+ public double InertiaRestDelta { get; set; } = 0.5;
+
+ /// Optional lower bound for the inertia target.
+ public double? InertiaMin { get; set; }
+
+ /// Optional upper bound for the inertia target.
+ public double? InertiaMax { get; set; }
+
+ // ── Orchestration (for Variants) ──────────────────────────────────────────
+ ///
+ /// Seconds to stagger each child's animation start. Works in Variant transitions.
+ ///
+ public double? StaggerChildren { get; set; }
+
+ /// Seconds to delay the first child's animation start.
+ public double? DelayChildren { get; set; }
+
+ // ── Per-property overrides ────────────────────────────────────────────────
+ ///
+ /// Override transition for specific properties, e.g.
+ /// Properties = new { ["opacity"] = new BmotionTransitionConfig { Duration = 0.1 } }
+ ///
+ public Dictionary? Properties { get; set; }
+
+ ///
+ /// Called on every animation frame with the latest interpolated value.
+ /// Supported for single-value numeric animations.
+ ///
+ public Action? OnUpdate { get; set; }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a deep copy of this configuration. Used internally when the library
+ /// needs to derive a variant of a transition (e.g. applying a global
+ /// scale or a stagger delay)
+ /// without mutating or partially losing the original's fields.
+ ///
+ public BmotionTransitionConfig Clone() => new()
+ {
+ Type = Type,
+ Duration = Duration,
+ Delay = Delay,
+ Ease = Ease,
+ // Read the backing field directly: the EaseCubicBezier getter already returns a defensive
+ // clone, so cloning it again here would allocate a redundant second copy.
+ EaseCubicBezier = _easeCubicBezier is null ? null : (double[])_easeCubicBezier.Clone(),
+ Repeat = Repeat,
+ RepeatInfinite = RepeatInfinite,
+ RepeatType = RepeatType,
+ RepeatDelay = RepeatDelay,
+ Times = Times is null ? null : (double[])Times.Clone(),
+ Stiffness = Stiffness,
+ Damping = Damping,
+ Mass = Mass,
+ Velocity = Velocity,
+ RestSpeed = RestSpeed,
+ RestDelta = RestDelta,
+ Bounce = Bounce,
+ VisualDuration = VisualDuration,
+ InertiaVelocity = InertiaVelocity,
+ TimeConstant = TimeConstant,
+ Power = Power,
+ InertiaRestDelta = InertiaRestDelta,
+ InertiaMin = InertiaMin,
+ InertiaMax = InertiaMax,
+ StaggerChildren = StaggerChildren,
+ DelayChildren = DelayChildren,
+ Properties = CloneProperties(Properties),
+ OnUpdate = OnUpdate,
+ };
+
+ private static Dictionary? CloneProperties(
+ Dictionary? source)
+ {
+ if (source is null) return null;
+ // Preserve the source's key comparer so a custom (e.g. case-insensitive) lookup semantic
+ // survives the clone instead of silently reverting to the default ordinal comparer.
+ var copy = new Dictionary(source.Count, source.Comparer);
+ foreach (var kv in source)
+ {
+ // The dictionary's value type is non-nullable, so a null per-property override violates
+ // the contract and would surface as a NullReferenceException downstream. Reject it here
+ // with a clear message instead of propagating the null via the null-forgiving operator.
+ if (kv.Value is null)
+ throw new ArgumentException(
+ $"Per-property transition override for '{kv.Key}' must not be null.", nameof(source));
+ copy[kv.Key] = kv.Value.Clone();
+ }
+ return copy;
+ }
+
+ // ── Factory helpers ───────────────────────────────────────────────────────
+ public static BmotionTransitionConfig Spring(double stiffness = 100, double damping = 10, double mass = 1)
+ => new() { Type = BmotionTransitionType.Spring, Stiffness = stiffness, Damping = damping, Mass = mass };
+
+ ///
+ /// Duration-based spring using intuitive (0 = no bounce, 1 = very bouncy)
+ /// and parameters. Stiffness and damping are derived automatically.
+ ///
+ public static BmotionTransitionConfig BounceSpring(double duration = 0.5, double bounce = 0.25, double mass = 1)
+ {
+ var (stiffness, damping) = SpringFromBounce(duration, bounce, mass);
+ return new()
+ {
+ Type = BmotionTransitionType.Spring,
+ Duration = duration,
+ Bounce = bounce,
+ VisualDuration = duration,
+ Stiffness = stiffness,
+ Damping = damping,
+ Mass = mass,
+ };
+ }
+
+ public static BmotionTransitionConfig Tween(double duration = 0.3, BmotionEasing ease = BmotionEasing.EaseOut)
+ => new() { Type = BmotionTransitionType.Tween, Duration = duration, Ease = ease };
+
+ public static BmotionTransitionConfig Inertia(double velocity = 0, double timeConstant = 700)
+ => new() { Type = BmotionTransitionType.Inertia, InertiaVelocity = velocity, TimeConstant = timeConstant };
+
+ ///
+ /// Derives (stiffness, damping) from Framer-Motion-compatible
+ /// (0–1) and parameters.
+ ///
+ internal static (double stiffness, double damping) SpringFromBounce(
+ double visualDuration, double bounce, double mass = 1)
+ {
+ double b = Math.Clamp(bounce, 0.0, 1.0);
+ double omega0 = (2.0 * Math.PI) / Math.Max(visualDuration, 0.001);
+ // damping ratio: 0 → fully elastic (bounce=1), 1 → critically damped (bounce=0)
+ double zeta = b < 0.05 ? 1.0 : Math.Sqrt(1.0 - Math.Pow(b, 2.0 / 3.0));
+ return (Math.Max(omega0 * omega0 * mass, 0.001), Math.Max(2.0 * zeta * omega0 * mass, 0.001));
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs
new file mode 100644
index 0000000000..3492bcf741
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs
@@ -0,0 +1,14 @@
+namespace Bit.Bmotion;
+
+/// The animation driver (physics or interpolation model) used for a transition.
+public enum BmotionTransitionType
+{
+ /// Duration- and easing-based interpolation between start and end values.
+ Tween,
+ /// Physics-based spring driven by stiffness, damping and mass.
+ Spring,
+ /// Velocity-based deceleration that coasts to a stop (e.g. momentum after a drag).
+ Inertia,
+ /// Interpolation across an array of keyframe values rather than a single target.
+ Keyframes,
+}
diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs
new file mode 100644
index 0000000000..b76ebbc6aa
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs
@@ -0,0 +1,52 @@
+namespace Bit.Bmotion;
+///
+/// Options that control how a element is tracked within the viewport
+/// for WhileInView and OnViewportEnter /OnViewportLeave animations.
+///
+public class BmotionViewportOptions
+{
+ ///
+ /// If true , once the element enters the viewport the animation will not
+ /// reverse when the element leaves. Default: false .
+ ///
+ public bool Once { get; set; }
+
+ ///
+ /// A CSS margin string added to the viewport detection area, e.g. "0px -20px 0px 100px" .
+ /// Supports the same format as IntersectionObserver.rootMargin .
+ /// Default: "0px" .
+ ///
+ public string Margin { get; set; } = "0px";
+
+ ///
+ /// How much of the element must be visible to be considered "in view".
+ ///
+ /// "some" (default) - any part visible.
+ /// "all" - fully visible.
+ /// A number between 0 and 1 for exact threshold.
+ ///
+ ///
+ public string Amount { get; set; } = "some";
+
+ internal object ToJsObject()
+ {
+ var amount = Amount?.Trim().ToLowerInvariant();
+ double threshold = amount switch
+ {
+ "some" => 0.0,
+ "all" => 1.0,
+ _ => double.TryParse(amount, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var v) && double.IsFinite(v)
+ ? Math.Clamp(v, 0, 1) : 0.0,
+ };
+
+ return new Dictionary
+ {
+ ["once"] = Once,
+ // Fall back to "0px" for null/whitespace so the JS side always receives a valid
+ // IntersectionObserver rootMargin string instead of an empty/invalid value.
+ ["margin"] = string.IsNullOrWhiteSpace(Margin) ? "0px" : Margin,
+ ["threshold"] = threshold,
+ };
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/README.md b/src/Bmotion/Bit.Bmotion/README.md
new file mode 100644
index 0000000000..db38c850f8
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/README.md
@@ -0,0 +1,67 @@
+# Bit.Bmotion
+
+A Framer-Motion-style animation library for Blazor. All animation math (spring, tween,
+inertia, keyframes, easing, color interpolation, gesture state, transform composition) runs
+in C#; JavaScript is used only as a thin bridge to browser-native APIs.
+
+## ⚠️ Platform support: Blazor WebAssembly only
+
+Bit.Bmotion drives its animation loop over **synchronous** JS↔.NET interop. That is only
+available on **Blazor WebAssembly**, so:
+
+- ✅ **Blazor WebAssembly** — fully supported.
+- ❌ **Blazor Server** — not supported (synchronous interop is unavailable). Starting the
+ animation loop throws `PlatformNotSupportedException`.
+- ⏸️ **Server-side prerendering** — components render their initial styles, but animations
+ do not start until the WebAssembly runtime becomes interactive.
+
+## Getting started
+
+```csharp
+// Program.cs (WebAssembly host)
+builder.Services.AddBitBmotionServices();
+```
+
+```razor
+
+```
+
+See the XML documentation on `Bmotion`, `BmotionAnimateService`, and `BmotionTransitionConfig`
+for the full API surface.
+
+## Accessibility: reduced motion is opt-in
+
+`prefers-reduced-motion` is **only** honoured for elements inside a ``. Elements
+with no surrounding config always animate, even when the OS requests reduced motion. This is a
+deliberate choice (so the OS setting never silently disables animations an app didn't opt into),
+but it is the opposite of the web-platform default — if you care about reduced-motion
+accessibility, wrap your app (or the relevant subtree) in a config:
+
+```razor
+ @* ReduceMotion defaults to null = follow the OS setting *@
+ ...
+
+```
+
+Set `ReduceMotion="true"`/`"false"` to force it on or off regardless of the OS preference.
+
+## Security note
+
+String-valued animation properties (`BackgroundColor`, `Width`, `BoxShadow`, `Color`, custom
+`CssVars`, …) are written verbatim into the element's inline style. They are meant for
+developer-authored values. Do **not** bind untrusted end-user input to them, or you risk CSS
+injection into the element's `style`.
+
+## Threading
+
+The animation engine is intentionally lock-free and assumes a single (the WebAssembly UI) thread.
+Do **not** enable WebAssembly multithreading (``) with this library.
+
+## Disposing the programmatic helpers
+
+`BmotionAnimationController` and `BmotionScrollTracker` are registered `Transient`. When obtained
+via `@inject`, Blazor does **not** dispose them per component — the consuming component must
+dispose them itself (from its own `Dispose`/`DisposeAsync`), otherwise their engine registration /
+JS subscription leaks until the app shuts down.
diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs
new file mode 100644
index 0000000000..f469d1d8ba
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs
@@ -0,0 +1,142 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Bit.Bmotion;
+///
+/// Provides a method-based animation API analogous to the animate() function in
+/// motion.dev .
+///
+/// Elements are identified by a CSS selector string or a Blazor .
+/// They do not need to be wrapped in a <Bmotion> component.
+///
+///
+///
+///
+/// // By CSS selector
+/// var controls = await Motion.AnimateAsync(".box", new BmotionAnimationProps { X = 100, Opacity = 1 });
+/// await controls; // wait for completion
+///
+/// // By ElementReference captured via @ref
+/// var controls = await Motion.AnimateAsync(myRef, new BmotionAnimationProps { Scale = 1.2 },
+/// BmotionTransitionConfig.Spring());
+/// controls.Stop(); // cancel early
+///
+///
+public sealed class BmotionAnimateService
+{
+ private readonly BmotionAnimationEngine _engine;
+ private readonly BmotionInterop _interop;
+
+ public BmotionAnimateService(BmotionAnimationEngine engine, BmotionInterop interop)
+ {
+ ArgumentNullException.ThrowIfNull(engine);
+ ArgumentNullException.ThrowIfNull(interop);
+ _engine = engine;
+ _interop = interop;
+ }
+
+ ///
+ /// Animate all DOM elements matching to
+ /// .
+ ///
+ ///
+ /// A CSS selector string, e.g. ".card" , "#hero" , or "div.item" .
+ /// Multiple matching elements are animated simultaneously.
+ ///
+ /// Target animation properties.
+ ///
+ /// Optional transition configuration (easing, duration, spring parameters, etc.).
+ /// Falls back to the global default when omitted.
+ ///
+ ///
+ /// An that can be await ed or stopped early.
+ ///
+ public async ValueTask AnimateAsync(
+ string selector,
+ BmotionAnimationProps keyframes,
+ BmotionTransitionConfig? transition = null)
+ {
+ if (string.IsNullOrWhiteSpace(selector))
+ throw new ArgumentException("Selector must not be null or whitespace.", nameof(selector));
+ ArgumentNullException.ThrowIfNull(keyframes);
+ var ids = await _interop.ResolveOrRegisterBySelectorAsync(selector);
+ return StartAnimations(ids, keyframes, transition);
+ }
+
+ ///
+ /// Animate the element captured by to
+ /// .
+ ///
+ ///
+ /// A Blazor obtained via @ref on any HTML element.
+ ///
+ /// Target animation properties.
+ /// Optional transition configuration.
+ ///
+ /// An that can be await ed or stopped early.
+ ///
+ public async ValueTask AnimateAsync(
+ ElementReference elementReference,
+ BmotionAnimationProps keyframes,
+ BmotionTransitionConfig? transition = null)
+ {
+ ArgumentNullException.ThrowIfNull(keyframes);
+ var id = await _interop.ResolveOrRegisterByRefAsync(elementReference);
+ return StartAnimations([id], keyframes, transition);
+ }
+
+ // ────────────────────────────────────────────────────────────────────────────
+
+ private BmotionAnimationControls StartAnimations(
+ string[] elementIds,
+ BmotionAnimationProps keyframes,
+ BmotionTransitionConfig? transition)
+ {
+ var values = keyframes.ToJsDictionary();
+
+ // Reference-count every target for the lifetime of this call. Overlapping AnimateAsync
+ // invocations (and any wrapping ) each hold a count, so an element is only torn
+ // down once the last animation settles - a single completing call can't unregister an
+ // element out from under another still-running animation.
+ foreach (var id in elementIds)
+ _engine.RegisterElement(id);
+
+ // Start all animations concurrently; collect their completion tasks. If task creation
+ // throws synchronously (e.g. a driver rejects the keyframes), release every element we
+ // already registered so they don't leak a refcount before the exception propagates.
+ Task[] completionTasks;
+ try
+ {
+ completionTasks = elementIds
+ .Select(id => _engine.AnimateToAwaitAsync(id, values, transition).AsTask())
+ .ToArray();
+ }
+ catch
+ {
+ foreach (var id in elementIds)
+ _engine.UnregisterElement(id);
+ throw;
+ }
+
+ var completion = Task.WhenAll(completionTasks);
+
+ // The controls own the single refcount release (idempotent). It fires whichever happens
+ // first: natural completion, Stop(), or Complete(). This is what prevents an
+ // infinite-repeat animation - whose completion task never resolves - from leaking refcounts.
+ var released = false;
+ void Release()
+ {
+ if (released) return;
+ released = true;
+ foreach (var id in elementIds)
+ _engine.UnregisterElement(id);
+ }
+
+ var controls = new BmotionAnimationControls(elementIds, _engine, completion, Release);
+
+ _ = completion.ContinueWith(
+ _ => controls.OnCompletionSettled(),
+ TaskScheduler.Default);
+
+ return controls;
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs
new file mode 100644
index 0000000000..8615f031b2
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs
@@ -0,0 +1,85 @@
+
+namespace Bit.Bmotion;
+///
+/// Programmatic animation controller.
+/// Analogous to Framer Motion's useAnimate() .
+/// Obtain via DI (@inject BmotionAnimationController ) and bind to an element ID.
+/// All animation math runs in the C# .
+///
+/// Lifetime / disposal: registered Transient and meant to be owned by a single
+/// component. When injected with @inject , Blazor resolves it from the root scope and only
+/// disposes it at app shutdown, so the consuming component must dispose it explicitly
+/// (implement and call from the component's
+/// Dispose ) - otherwise the bound element stays registered with the engine until the app ends.
+///
+///
+public sealed class BmotionAnimationController : IDisposable
+{
+ private readonly BmotionAnimationEngine _engine;
+ private string? _elementId;
+
+ public BmotionAnimationController(BmotionAnimationEngine engine)
+ {
+ ArgumentNullException.ThrowIfNull(engine);
+ _engine = engine;
+ }
+
+ ///
+ /// Bind by element ID. Ensures the element is registered with the engine so the controller
+ /// works even when the target isn't wrapped in a <Bmotion> component.
+ ///
+ public void BindTo(string elementId)
+ {
+ if (string.IsNullOrWhiteSpace(elementId))
+ throw new ArgumentException("Element ID must not be null or whitespace.", nameof(elementId));
+ // Already bound to this element: avoid re-registering (which would unbalance the engine refcount).
+ if (_elementId == elementId) return;
+ // Release the previously bound element so repeated BindTo calls don't leak engine state.
+ if (!string.IsNullOrEmpty(_elementId) && _elementId != elementId)
+ _engine.UnregisterElement(_elementId);
+ _elementId = elementId;
+ _engine.RegisterElement(elementId);
+ }
+
+ /// Animate the bound element to the given props (fire-and-forget).
+ public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null)
+ {
+ ArgumentNullException.ThrowIfNull(props);
+ if (_elementId == null) return;
+ await _engine.AnimateToAsync(_elementId, props.ToJsDictionary(), transition);
+ }
+
+ /// Animate and await completion.
+ public async ValueTask AnimateAwaitAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null)
+ {
+ ArgumentNullException.ThrowIfNull(props);
+ if (_elementId == null) return;
+ await _engine.AnimateToAwaitAsync(_elementId, props.ToJsDictionary(), transition);
+ }
+
+ /// Instantly set props without animation.
+ public void Set(BmotionAnimationProps props)
+ {
+ ArgumentNullException.ThrowIfNull(props);
+ if (_elementId == null) return;
+ _engine.SetInstant(_elementId, props.ToJsDictionary());
+ }
+
+ /// Stop animations on the bound element.
+ public void Stop(params string[] properties)
+ {
+ if (_elementId == null) return;
+ var props = properties == null || properties.Length == 0 ? null : properties;
+ _engine.Stop(_elementId, props);
+ }
+
+ /// Unregister the bound element from the engine when the controller is disposed.
+ public void Dispose()
+ {
+ if (!string.IsNullOrEmpty(_elementId))
+ {
+ _engine.UnregisterElement(_elementId);
+ _elementId = null;
+ }
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs
new file mode 100644
index 0000000000..9076772e9e
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs
@@ -0,0 +1,89 @@
+using System.Runtime.CompilerServices;
+
+namespace Bit.Bmotion;
+///
+/// Controls for an in-flight programmatic animation started by
+/// .
+/// The object is directly awaitable - await controls waits for the animation to complete.
+///
+/// freezes the animation at its current (intermediate) values;
+/// jumps it to its target (end) values. Both release the engine refcount immediately, so they also
+/// safely stop infinite-repeat animations (whose completion task never resolves on its own).
+///
+///
+public sealed class BmotionAnimationControls
+{
+ private readonly IReadOnlyList _elementIds;
+ private readonly BmotionAnimationEngine _engine;
+ private readonly Task _completion;
+ private readonly Action _release;
+ private int _released;
+
+ internal BmotionAnimationControls(
+ IReadOnlyList elementIds, BmotionAnimationEngine engine, Task completion, Action release)
+ {
+ _elementIds = elementIds;
+ _engine = engine;
+ _completion = completion;
+ _release = release;
+ }
+
+ // Release the engine refcount exactly once - whether the animation finishes naturally, is
+ // stopped, or is completed early. Without this, an infinite-repeat animation (which never
+ // finishes) would pin its elements in the engine forever.
+ private void ReleaseOnce()
+ {
+ if (System.Threading.Interlocked.Exchange(ref _released, 1) == 0)
+ _release();
+ }
+
+ ///
+ /// Immediately cancel all running animations on the target elements.
+ /// Elements snap to their current (intermediate) positions.
+ ///
+ public void Stop()
+ {
+ // Atomically claim ownership: only the caller that flips _released from 0→1 runs the engine
+ // side effects. Once released (e.g. a natural finish already settled the completion), the
+ // target elements may be owned by newer animations - skip so we don't disturb them.
+ if (System.Threading.Interlocked.CompareExchange(ref _released, 1, 0) != 0) return;
+ try
+ {
+ foreach (var id in _elementIds)
+ _engine.Stop(id, null);
+ }
+ finally
+ {
+ _release();
+ }
+ }
+
+ ///
+ /// Cancel all running animations and snap elements to their target (end) values.
+ ///
+ public void Complete()
+ {
+ // See Stop(): atomically claim ownership so engine side effects run exactly once and never
+ // after a concurrent settlement has handed the elements to newer animations.
+ if (System.Threading.Interlocked.CompareExchange(ref _released, 1, 0) != 0) return;
+ try
+ {
+ foreach (var id in _elementIds)
+ _engine.Complete(id);
+ }
+ finally
+ {
+ _release();
+ }
+ }
+
+ /// A that resolves when all animations finish naturally.
+ public Task WhenCompleteAsync() => _completion;
+
+ /// Makes directly awaitable.
+ public TaskAwaiter GetAwaiter() => _completion.GetAwaiter();
+
+ // Invoked by the owning service once the completion task settles, so a natural finish also
+ // releases the refcount (idempotent with Stop/Complete).
+ internal void OnCompletionSettled() => ReleaseOnce();
+}
diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs
new file mode 100644
index 0000000000..c20a64bbbb
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs
@@ -0,0 +1,126 @@
+using Microsoft.JSInterop;
+
+namespace Bit.Bmotion;
+///
+/// Tracks scroll progress (0–1) for a container element or the window.
+/// Analogous to Framer Motion's useScroll .
+///
+///
+/// Lifetime / disposal: this service is registered Transient and is meant to be
+/// owned by a single component. When injected with @inject , Blazor resolves it from the
+/// root scope and only disposes it at app shutdown - so the consuming component must dispose it
+/// explicitly (implement and call in
+/// the component's DisposeAsync ), otherwise its JS scroll subscription and
+/// DotNetObjectReference leak until the app ends. The reference is created lazily, so an
+/// injected-but-unused tracker holds no JS resources.
+///
+///
+/// Usage:
+///
+/// @implements IAsyncDisposable
+/// @inject BmotionScrollTracker Scroll
+///
+/// protected override async Task OnAfterRenderAsync(bool firstRender)
+/// {
+/// if (firstRender) await Scroll.ObserveAsync(null, info => scrollY = info.ProgressY);
+/// }
+///
+/// public ValueTask DisposeAsync() => Scroll.DisposeAsync();
+///
+///
+public sealed class BmotionScrollTracker : IAsyncDisposable
+{
+ private readonly BmotionInterop _interop;
+ private readonly List _subscriptionKeys = new();
+ private DotNetObjectReference? _dotnet;
+
+ private Func? _onScroll;
+ private bool _disposed;
+
+ public BmotionScrollTracker(BmotionInterop interop)
+ {
+ ArgumentNullException.ThrowIfNull(interop);
+ _interop = interop;
+ }
+
+ // Created on first use so an injected-but-unused tracker doesn't allocate a JS-object reference.
+ private DotNetObjectReference Dotnet
+ => _dotnet ??= DotNetObjectReference.Create(this);
+ /// Horizontal scroll progress 0–1.
+ public double ProgressX { get; private set; }
+
+ /// Vertical scroll progress 0–1.
+ public double ProgressY { get; private set; }
+
+ /// Raw pixel scroll offset.
+ public double ScrollX { get; private set; }
+ public double ScrollY { get; private set; }
+
+ ///
+ /// Start observing scroll events on the given container (or the window if null).
+ ///
+ /// HTML element id, or null for window.
+ /// Callback invoked on every scroll event.
+ public async Task ObserveAsync(string? containerId, Func onChange)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(onChange);
+ // Remove any existing subscription so only one stays active.
+ foreach (var existing in _subscriptionKeys)
+ await _interop.UnobserveScrollAsync(existing);
+ _subscriptionKeys.Clear();
+
+ _onScroll = onChange;
+ var key = await _interop.ObserveScrollAsync(containerId, Dotnet);
+ if (key != null) _subscriptionKeys.Add(key);
+ }
+
+ /// Synchronous overload.
+ public Task ObserveAsync(string? containerId, Action onChange)
+ {
+ ArgumentNullException.ThrowIfNull(onChange);
+ return ObserveAsync(containerId, info => { onChange(info); return Task.CompletedTask; });
+ }
+
+ // ── JS → C# callback ─────────────────────────────────────────────────────
+
+ [JSInvokable]
+ public async Task OnScroll(BmotionScrollInfo info)
+ {
+ // info crosses the JS→C# boundary; guard against a null payload so the property reads below
+ // don't throw a NullReferenceException inside the interop callback.
+ if (info is null) return;
+ ProgressX = info.ProgressX;
+ ProgressY = info.ProgressY;
+ ScrollX = info.ScrollX;
+ ScrollY = info.ScrollY;
+ if (_onScroll != null)
+ {
+ // Guard the user callback so a faulting handler can't fault the JS-invokable flow
+ // (which would destabilise the interop bridge / host circuit).
+ try { await _onScroll(info); }
+ catch { /* swallow user-callback failures to keep the scroll bridge alive */ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ try
+ {
+ foreach (var key in _subscriptionKeys)
+ await _interop.UnobserveScrollAsync(key);
+ }
+ finally
+ {
+ // Always release local resources, even if a JS unobserve call faults during teardown,
+ // so the DotNetObjectReference and callback don't leak.
+ _subscriptionKeys.Clear();
+ _onScroll = null;
+ _dotnet?.Dispose();
+ // Note: BmotionInterop itself is DI-scoped and disposed by the DI container
+ }
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs
new file mode 100644
index 0000000000..2e30baf3a6
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs
@@ -0,0 +1,183 @@
+namespace Bit.Bmotion;
+///
+/// A reactive numeric value whose changes can be observed and linked to animations.
+/// Analogous to Framer Motion's MotionValue<T> .
+/// Purely C# - no JS synchronisation required.
+///
+public class BmotionValue : IDisposable where T : struct
+{
+ private readonly string _id;
+ private T _value;
+ private readonly List> _subscribers = new();
+
+ /// Numeric value types accepted by the range-mapping Transform overload.
+ private static readonly HashSet _numericTypes = new()
+ {
+ typeof(byte), typeof(sbyte), typeof(short), typeof(ushort),
+ typeof(int), typeof(uint), typeof(long), typeof(ulong),
+ typeof(float), typeof(double), typeof(decimal),
+ };
+
+ /// Subscription to a parent BmotionValue when this instance is a derived/transformed value.
+ private IDisposable? _upstream;
+
+ internal BmotionValue(string id, T initial)
+ {
+ _id = id;
+ _value = initial;
+ }
+
+ // ── Value access ──────────────────────────────────────────────────────────
+
+ public T Value
+ {
+ get => _value;
+ set => SetSync(value);
+ }
+
+ ///
+ /// Synchronously updates the value and notifies subscribers. Subscriber tasks are
+ /// observed (rather than dropped) so their exceptions don't go unobserved.
+ ///
+ public void SetSync(T value)
+ {
+ _value = value;
+ foreach (var sub in _subscribers.ToArray())
+ {
+ // Guard the invocation itself: a subscriber may throw synchronously before returning a
+ // Task. Catch so one faulty subscriber can't skip the rest of the chain.
+ try { _ = ObserveAsync(sub(value)); }
+ catch { /* subscriber failures are swallowed to avoid faulting the host */ }
+ }
+ }
+
+ private static async Task ObserveAsync(Task task)
+ {
+ try { await task; }
+ catch { /* subscriber failures are swallowed to avoid faulting the host */ }
+ }
+
+ /// Update the value and notify all subscribers.
+ public async Task SetAsync(T value)
+ {
+ _value = value;
+ foreach (var sub in _subscribers.ToArray())
+ {
+ // Catch both synchronous throws and faulted tasks so a single failing subscriber
+ // doesn't prevent the remaining subscribers from being notified.
+ try { await sub(value); }
+ catch { /* subscriber failures are swallowed to avoid faulting the host */ }
+ }
+ }
+
+ // ── Subscriptions ─────────────────────────────────────────────────────────
+
+ /// Subscribe to value changes. Returns an unsubscribe action.
+ public IDisposable Subscribe(Func callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ _subscribers.Add(callback);
+ return new Subscription(() => _subscribers.Remove(callback));
+ }
+
+ /// Synchronous convenience overload.
+ public IDisposable Subscribe(Action callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ return Subscribe(v => { callback(v); return Task.CompletedTask; });
+ }
+
+ // ── Transforms ────────────────────────────────────────────────────────────
+
+ ///
+ /// Create a derived BmotionValue that applies a transformation function.
+ /// Analogous to Framer Motion's useTransform .
+ ///
+ public BmotionValue Transform(Func fn) where TOut : struct
+ {
+ ArgumentNullException.ThrowIfNull(fn);
+ var derived = new BmotionValue($"{_id}_t", fn(_value));
+ // Subscribe weakly: the parent must not keep the derived value alive (that would make the
+ // parent→derived link a leak for callers that drop the derived). The derived keeps the
+ // parent alive via _upstream for as long as the caller holds the derived, which is the
+ // intended direction. The subscription self-removes once the derived is collected.
+ derived._upstream = SubscribeWeak(derived, fn);
+ return derived;
+ }
+
+ ///
+ /// Map from an input range to an output range using linear interpolation.
+ ///
+ public BmotionValue Transform(double[] inputRange, double[] outputRange)
+ {
+ ArgumentNullException.ThrowIfNull(inputRange);
+ ArgumentNullException.ThrowIfNull(outputRange);
+ if (!_numericTypes.Contains(typeof(T)))
+ throw new ArgumentException(
+ $"Transform(inputRange, outputRange) only supports numeric value types; '{typeof(T).Name}' is not numeric.");
+ if (inputRange.Length != outputRange.Length)
+ throw new ArgumentException("inputRange and outputRange must have the same length.");
+ if (inputRange.Length < 2)
+ throw new ArgumentException("inputRange and outputRange must contain at least 2 points.");
+ for (int i = 0; i < inputRange.Length - 1; i++)
+ if (inputRange[i + 1] <= inputRange[i])
+ throw new ArgumentException("inputRange must be strictly increasing (no repeated or decreasing points).");
+
+ // Snapshot the ranges so the Map closure isn't affected by the caller mutating the
+ // passed-in arrays after this method returns (which would bypass the validation above).
+ var inRange = (double[])inputRange.Clone();
+ var outRange = (double[])outputRange.Clone();
+
+ double Map(T v)
+ {
+ double x = Convert.ToDouble(v);
+ for (int i = 0; i < inRange.Length - 1; i++)
+ {
+ if (x >= inRange[i] && x <= inRange[i + 1])
+ {
+ double t = (x - inRange[i]) / (inRange[i + 1] - inRange[i]);
+ return outRange[i] + t * (outRange[i + 1] - outRange[i]);
+ }
+ }
+ return x < inRange[0] ? outRange[0] : outRange[^1];
+ }
+
+ var derived = new BmotionValue($"{_id}_tr", Map(_value));
+ derived._upstream = SubscribeWeak(derived, Map);
+ return derived;
+ }
+
+ ///
+ /// Subscribes the value to this value's changes through a weak
+ /// reference, so this (parent) value never keeps the derived one alive. The subscription
+ /// removes itself the first time it fires after the derived value has been collected.
+ ///
+ private IDisposable SubscribeWeak(BmotionValue derived, Func project)
+ where TOut : struct
+ {
+ var weak = new WeakReference>(derived);
+ IDisposable? sub = null;
+ sub = Subscribe(async v =>
+ {
+ if (weak.TryGetTarget(out var target))
+ await target.SetAsync(project(v));
+ else
+ sub?.Dispose(); // derived value collected - drop the dead subscription
+ });
+ return sub;
+ }
+
+ public void Dispose()
+ {
+ _upstream?.Dispose();
+ _upstream = null;
+ _subscribers.Clear();
+ }
+
+ private sealed class Subscription : IDisposable
+ {
+ private readonly Action _dispose;
+ public Subscription(Action dispose) => _dispose = dispose;
+ public void Dispose() => _dispose();
+ }
+}
diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs
new file mode 100644
index 0000000000..125b0c31df
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs
@@ -0,0 +1,8 @@
+namespace Bit.Bmotion;
+
+/// Factory helper for creating BmotionValues.
+public static class BmotionValueFactory
+{
+ public static BmotionValue Create(T initial) where T : struct
+ => new($"mv_{Guid.NewGuid():N}", initial);
+}
diff --git a/src/Bmotion/Bit.Bmotion/_Imports.razor b/src/Bmotion/Bit.Bmotion/_Imports.razor
new file mode 100644
index 0000000000..841dccaa9f
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/_Imports.razor
@@ -0,0 +1,2 @@
+@using Microsoft.AspNetCore.Components.Web
+@using Bit.Bmotion
diff --git a/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js
new file mode 100644
index 0000000000..0708554e8d
--- /dev/null
+++ b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js
@@ -0,0 +1,542 @@
+/**
+ * bit-bmotion.js - slim browser-API bridge
+ *
+ * All animation math (spring, tween, inertia, keyframes, easing, colour
+ * interpolation, gesture state, transform composition) now lives in the
+ * C# AnimationEngine / ElementAnimationState classes running as WebAssembly.
+ *
+ * This file only touches browser-native APIs:
+ * requestAnimationFrame drives the C# animation engine each tick
+ * element.style applies CSS updates returned by ComputeFrame
+ * Pointer / Focus events gesture input forwarded to the C# component
+ * IntersectionObserver viewport visibility forwarded to C#
+ * Scroll events scroll progress forwarded to C#
+ * getBoundingClientRect FLIP layout snapshot
+ * Web Animations API FLIP playback
+ */
+
+//
+// rAF loop C# ComputeFrame is called synchronously each tick (Blazor WASM)
+//
+
+let _rafId = null;
+// Set of engine DotNetObjectReferences. Using a set (rather than a single global) means
+// multiple Blazor roots / engine instances sharing this module each get ticked, instead of a
+// second startRafLoop silently hijacking the loop from the first.
+const _engines = new Set();
+
+export function startRafLoop(dotnetRef) {
+ _engines.add(dotnetRef);
+ if (_rafId === null) _rafId = requestAnimationFrame(_tick);
+}
+
+export function stopRafLoop(dotnetRef) {
+ // With an argument, stop only that engine; without one, stop everything (back-compat).
+ if (dotnetRef) _engines.delete(dotnetRef);
+ else _engines.clear();
+ if (_engines.size === 0 && _rafId !== null) {
+ cancelAnimationFrame(_rafId);
+ _rafId = null;
+ }
+}
+
+function _tick(timestamp) {
+ if (_engines.size === 0) { _rafId = null; return; }
+ for (const ref of _engines) {
+ try {
+ // invokeMethod is synchronous in Blazor WASM C# does all animation math here
+ const updates = ref.invokeMethod('ComputeFrame', timestamp);
+ if (updates) {
+ for (const elementId in updates) {
+ const el = document.getElementById(elementId);
+ if (!el) continue;
+ _applyStyles(el, updates[elementId]);
+ }
+ }
+ } catch (e) {
+ // Never let a fault from one engine's synchronous tick stop the shared rAF loop.
+ console.error('bmotion: ComputeFrame tick failed', e);
+ }
+ }
+ _rafId = requestAnimationFrame(_tick);
+}
+
+//
+// Style helpers
+//
+
+function _applyStyles(el, styles) {
+ for (const prop in styles) {
+ if (prop.startsWith('--')) el.style.setProperty(prop, styles[prop]);
+ else el.style[prop] = styles[prop];
+ }
+}
+
+/** Apply a styles object to an element by ID (used for instant set() calls). */
+export function applyStyles(elementId, styles) {
+ const el = document.getElementById(elementId);
+ if (el) _applyStyles(el, styles);
+}
+
+//
+// Accessibility - prefers-reduced-motion
+//
+
+/** Returns true when the user has requested reduced motion at the OS/browser level. */
+export function prefersReducedMotion() {
+ return typeof matchMedia === 'function' &&
+ matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
+
+// Live prefers-reduced-motion change notifications. Keyed by engine DotNetObjectReference so
+// each engine can subscribe/unsubscribe independently and we only keep one media-query listener.
+const _reducedMotionRefs = new Set();
+let _reducedMotionMql = null;
+let _reducedMotionListener = null;
+
+function _ensureReducedMotionListener() {
+ if (_reducedMotionMql || typeof matchMedia !== 'function') return;
+ _reducedMotionMql = matchMedia('(prefers-reduced-motion: reduce)');
+ _reducedMotionListener = (e) => {
+ for (const ref of _reducedMotionRefs) {
+ try { ref.invokeMethodAsync('OnReducedMotionChanged', e.matches); }
+ catch { /* a disposed/faulted engine ref must not break the others */ }
+ }
+ };
+ // addEventListener is the modern API; addListener is the deprecated Safari fallback.
+ if (_reducedMotionMql.addEventListener) _reducedMotionMql.addEventListener('change', _reducedMotionListener);
+ else if (_reducedMotionMql.addListener) _reducedMotionMql.addListener(_reducedMotionListener);
+}
+
+export function watchReducedMotion(dotnetRef) {
+ _reducedMotionRefs.add(dotnetRef);
+ _ensureReducedMotionListener();
+}
+
+export function unwatchReducedMotion(dotnetRef) {
+ _reducedMotionRefs.delete(dotnetRef);
+ if (_reducedMotionRefs.size === 0 && _reducedMotionMql && _reducedMotionListener) {
+ if (_reducedMotionMql.removeEventListener) _reducedMotionMql.removeEventListener('change', _reducedMotionListener);
+ else if (_reducedMotionMql.removeListener) _reducedMotionMql.removeListener(_reducedMotionListener);
+ _reducedMotionMql = null;
+ _reducedMotionListener = null;
+ }
+}
+
+//
+// Element registration
+//
+
+const _eventCleanup = new Map(); // elementId Array<() => void>
+
+export function registerElement(elementId) {
+ const el = document.getElementById(elementId);
+ if (el) el.setAttribute('data-bmid', elementId);
+}
+
+//
+// Programmatic animate() API - resolve elements by CSS selector or ElementReference
+// Assigns a stable id + data-bmid so the engine can address them via getElementById.
+//
+
+let _programmaticSeq = 0;
+
+function _ensureElementId(el) {
+ const existing = el.getAttribute('data-bmid');
+ if (existing) return existing;
+ const id = el.id || ('bm-p' + (++_programmaticSeq));
+ el.id = id;
+ el.setAttribute('data-bmid', id);
+ return id;
+}
+
+/** Resolve all elements matching a CSS selector and return their element IDs. */
+export function resolveOrRegisterBySelector(selector) {
+ try {
+ return Array.from(document.querySelectorAll(selector)).map(el => _ensureElementId(el));
+ } catch {
+ return [];
+ }
+}
+
+/** Resolve the element for a Blazor ElementReference and return its element ID. */
+export function resolveOrRegisterByRef(element) {
+ return _ensureElementId(element);
+}
+
+export function unregisterElement(elementId) {
+ const el = document.getElementById(elementId);
+ if (el) el.removeAttribute('data-bmid');
+ _runCleanup(elementId);
+ // Detach from every viewport observer (drops membership and evicts empty observers).
+ _detachFromObservers(el, elementId);
+ _vpRefs.delete(elementId);
+}
+
+function _runCleanup(elementId) {
+ const fns = _eventCleanup.get(elementId);
+ if (fns) { fns.forEach(fn => fn()); _eventCleanup.delete(elementId); }
+}
+
+//
+// Gesture event listeners (hover / tap / focus / drag)
+// C# handles all state-machine logic; JS only forwards raw browser events.
+//
+
+/**
+ * Attach event listeners to an element.
+ * @param {string} elementId
+ * @param {{ hover?: bool, tap?: bool, focus?: bool, drag?: bool,
+ * dragAxis?: string, dragConstraints?: object,
+ * dragElastic?: number }} events
+ * @param dotnetRef DotNetObjectReference
+ */
+export function attachEventListeners(elementId, events, dotnetRef) {
+ const el = document.getElementById(elementId);
+ if (!el) return;
+ _runCleanup(elementId);
+ const cleanups = [];
+ _eventCleanup.set(elementId, cleanups);
+
+ // Hover
+ if (events.hover) {
+ const onEnter = () => dotnetRef.invokeMethodAsync('OnPointerEnter');
+ const onLeave = () => dotnetRef.invokeMethodAsync('OnPointerLeave');
+ el.addEventListener('pointerenter', onEnter);
+ el.addEventListener('pointerleave', onLeave);
+ cleanups.push(() => { el.removeEventListener('pointerenter', onEnter); el.removeEventListener('pointerleave', onLeave); });
+ }
+
+ // Tap
+ if (events.tap) {
+ let pressing = false;
+ const onDown = (e) => {
+ if (e.button !== 0 && e.pointerType !== 'touch') return; // primary button / touch only
+ pressing = true; dotnetRef.invokeMethodAsync('OnPointerDown');
+ };
+ const onUp = (e) => {
+ if (e.button !== 0 && e.pointerType !== 'touch') return; // ignore non-primary releases
+ if (!pressing) return; pressing = false;
+ dotnetRef.invokeMethodAsync('OnPointerUp', el.contains(e.target) || el === e.target);
+ };
+ const onCancel = () => { if (!pressing) return; pressing = false; dotnetRef.invokeMethodAsync('OnPointerCancel'); };
+ el.addEventListener('pointerdown', onDown);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('pointercancel', onCancel);
+ cleanups.push(() => {
+ el.removeEventListener('pointerdown', onDown);
+ window.removeEventListener('pointerup', onUp);
+ window.removeEventListener('pointercancel', onCancel);
+ });
+ }
+
+ // Focus
+ if (events.focus) {
+ const onIn = () => dotnetRef.invokeMethodAsync('OnFocusIn');
+ const onOut = () => dotnetRef.invokeMethodAsync('OnFocusOut');
+ el.addEventListener('focusin', onIn);
+ el.addEventListener('focusout', onOut);
+ cleanups.push(() => { el.removeEventListener('focusin', onIn); el.removeEventListener('focusout', onOut); });
+ }
+
+ // Pan (detects movement ≥ 3px without moving the element)
+ if (events.pan) {
+ // When drag is also active it already calls setPointerCapture; let pan reuse that capture
+ // instead of grabbing the pointer a second time for the same element.
+ _attachPan(el, dotnetRef, cleanups, !!events.drag);
+ }
+
+ // Drag
+ if (events.drag) {
+ _attachDrag(elementId, el, events, dotnetRef, cleanups);
+ }
+}
+
+function _attachPan(el, dotnetRef, cleanups, skipCapture) {
+ const PAN_THRESHOLD = 3; // pixels before pan is detected
+ let down = false; // whether a pointer is currently pressed on this element
+ let panning = false;
+ let startX, startY, lastX, lastY, lastT;
+ let velX = 0, velY = 0;
+
+ const onDown = (e) => {
+ if (e.button !== 0 && e.pointerType !== 'touch') return;
+ down = true;
+ startX = lastX = e.clientX; startY = lastY = e.clientY;
+ lastT = performance.now(); velX = velY = 0; panning = false;
+ // Skip when drag already owns the pointer capture for this element.
+ if (!skipCapture) el.setPointerCapture(e.pointerId);
+ };
+
+ const onMove = (e) => {
+ // Ignore moves when no pointer is pressed (e.g. plain hover) so stale start
+ // coordinates from a previous gesture can't trigger a phantom pan.
+ if (!down) return;
+ const dx = e.clientX - startX, dy = e.clientY - startY;
+ const now = performance.now(), dt = now - lastT;
+ const deltaX = e.clientX - lastX, deltaY = e.clientY - lastY;
+ if (dt > 0) {
+ velX = deltaX / dt * 1000;
+ velY = deltaY / dt * 1000;
+ }
+
+ if (!panning && Math.sqrt(dx * dx + dy * dy) >= PAN_THRESHOLD) {
+ panning = true;
+ dotnetRef.invokeMethodAsync('OnPanStart_');
+ }
+ if (panning) {
+ dotnetRef.invokeMethodAsync('OnPanMove',
+ e.clientX, e.clientY,
+ deltaX, deltaY,
+ dx, dy,
+ velX, velY);
+ }
+
+ lastX = e.clientX; lastY = e.clientY; lastT = now;
+ };
+
+ const onUp = () => { down = false; if (panning) { panning = false; dotnetRef.invokeMethodAsync('OnPanEnd_'); } };
+
+ el.addEventListener('pointerdown', onDown);
+ el.addEventListener('pointermove', onMove);
+ el.addEventListener('pointerup', onUp);
+ el.addEventListener('pointercancel', onUp);
+ cleanups.push(() => {
+ el.removeEventListener('pointerdown', onDown);
+ el.removeEventListener('pointermove', onMove);
+ el.removeEventListener('pointerup', onUp);
+ el.removeEventListener('pointercancel', onUp);
+ });
+}
+
+function _attachDrag(elementId, el, opts, dotnetRef, cleanups) {
+ // Velocity is sampled per pointer-move as px/ms and scaled to "px per frame" (~16ms) so the
+ // C# inertia driver receives a frame-relative figure consistent with its release-velocity math.
+ const FRAME_MS = 16;
+ const axis = opts.dragAxis ?? null;
+ const constraints = opts.dragConstraints ?? null;
+ const elastic = typeof opts.dragElastic === 'number' ? opts.dragElastic : 0.35;
+ const dirLock = !!opts.dragDirectionLock;
+
+ let dragging = false;
+ let lockedAxis = null; // null = not yet locked, 'x' or 'y' once detected
+ let startPX, startPY, startElX, startElY;
+ let lastPX, lastPY, lastT, velX = 0, velY = 0;
+
+ function applyElastic(overflow) {
+ return elastic > 0 ? overflow * elastic : 0;
+ }
+
+ const onDown = (e) => {
+ if (e.button !== 0 && e.pointerType !== 'touch') return;
+ // Retrieve starting transform position from C# state synchronously
+ const pos = dotnetRef.invokeMethod('GetCurrentXY');
+ startElX = pos ? pos.x : 0;
+ startElY = pos ? pos.y : 0;
+ startPX = e.clientX; startPY = e.clientY;
+ lastPX = e.clientX; lastPY = e.clientY; lastT = performance.now();
+ velX = velY = 0;
+ dragging = true;
+ lockedAxis = null;
+ el.setPointerCapture(e.pointerId);
+ dotnetRef.invokeMethodAsync('OnPointerDown_Drag');
+ };
+
+ const onMove = (e) => {
+ if (!dragging) return;
+ const now = performance.now(), dt = now - lastT;
+ if (dt > 0) { velX = (e.clientX - lastPX) / dt * FRAME_MS; velY = (e.clientY - lastPY) / dt * FRAME_MS; }
+ lastPX = e.clientX; lastPY = e.clientY; lastT = now;
+
+ // Direction lock detection
+ let effectiveAxis = axis;
+ if (dirLock && !lockedAxis) {
+ const dx = Math.abs(e.clientX - startPX), dy = Math.abs(e.clientY - startPY);
+ if (dx > 3 || dy > 3) lockedAxis = dx >= dy ? 'x' : 'y';
+ }
+ if (dirLock && lockedAxis) effectiveAxis = lockedAxis;
+
+ let x = startElX + (effectiveAxis === 'y' ? 0 : e.clientX - startPX);
+ let y = startElY + (effectiveAxis === 'x' ? 0 : e.clientY - startPY);
+
+ if (constraints) {
+ if (constraints.left != null && x < constraints.left) x = constraints.left - applyElastic(constraints.left - x);
+ if (constraints.right != null && x > constraints.right) x = constraints.right + applyElastic(x - constraints.right);
+ if (constraints.top != null && y < constraints.top) y = constraints.top - applyElastic(constraints.top - y);
+ if (constraints.bottom != null && y > constraints.bottom) y = constraints.bottom + applyElastic(y - constraints.bottom);
+ }
+
+ // Sync drag position into C# state synchronously so ComputeFrame picks it up
+ dotnetRef.invokeMethod('SetDragPosition', x, y);
+ dotnetRef.invokeMethodAsync('OnDragMove');
+ };
+
+ const onUp = (e) => {
+ if (!dragging) return;
+ dragging = false;
+ dotnetRef.invokeMethodAsync('OnPointerUp_Drag', velX, velY);
+ };
+
+ el.style.cursor = 'grab';
+ el.style.userSelect = 'none';
+ el.style.touchAction = axis === 'x' ? 'pan-y' : axis === 'y' ? 'pan-x' : 'none';
+
+ el.addEventListener('pointerdown', onDown);
+ el.addEventListener('pointermove', onMove);
+ el.addEventListener('pointerup', onUp);
+ el.addEventListener('pointercancel', onUp);
+ cleanups.push(() => {
+ el.removeEventListener('pointerdown', onDown);
+ el.removeEventListener('pointermove', onMove);
+ el.removeEventListener('pointerup', onUp);
+ el.removeEventListener('pointercancel', onUp);
+ el.style.cursor = el.style.userSelect = el.style.touchAction = '';
+ });
+}
+
+//
+// Viewport observation (whileInView)
+//
+
+// Cache observers keyed by their options signature so we can re-use them. Each entry tracks the
+// element IDs it currently observes so the observer can be disconnected once it falls empty
+// (otherwise distinct margin/threshold combinations would accumulate observers forever).
+const _vpObservers = new Map(); // sig → { observer, members: Set }
+const _vpRefs = new Map(); // elementId → { dotnetRef, once }
+
+function _vpSig(margin, threshold) { return `${margin}|${threshold}`; }
+
+function _getVpEntry(margin, threshold) {
+ const sig = _vpSig(margin, threshold);
+ let entry = _vpObservers.get(sig);
+ if (entry) return entry;
+ const observer = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ const id = entry.target.getAttribute('data-bmid');
+ const ref = _vpRefs.get(id);
+ if (!ref) continue;
+ ref.dotnetRef.invokeMethodAsync('OnIntersect', entry.isIntersecting);
+ if (ref.once && entry.isIntersecting) {
+ _detachFromObservers(entry.target, id);
+ _vpRefs.delete(id);
+ }
+ }
+ }, { rootMargin: margin || '0px', threshold: threshold ?? 0 });
+ entry = { observer, members: new Set() };
+ _vpObservers.set(sig, entry);
+ return entry;
+}
+
+// Unobserve an element from every observer that might track it, dropping membership and
+// disconnecting (and evicting) any observer left with no members.
+function _detachFromObservers(el, elementId) {
+ for (const [sig, entry] of _vpObservers) {
+ if (el) entry.observer.unobserve(el);
+ entry.members.delete(elementId);
+ if (entry.members.size === 0) {
+ entry.observer.disconnect();
+ _vpObservers.delete(sig);
+ }
+ }
+}
+
+export function observeViewport(elementId, dotnetRef, options) {
+ const el = document.getElementById(elementId);
+ if (!el) return;
+ const once = options?.once ?? false;
+ const margin = options?.margin ?? '0px';
+ const threshold = options?.threshold ?? 0;
+ // Detach from any previously assigned observer first so re-observing with different options
+ // doesn't stack duplicate subscriptions (which would fire OnIntersect multiple times and
+ // break the "once" behaviour across option changes).
+ _detachFromObservers(el, elementId);
+ _vpRefs.set(elementId, { dotnetRef, once });
+ const entry = _getVpEntry(margin, threshold);
+ entry.members.add(elementId);
+ entry.observer.observe(el);
+}
+
+export function unobserveViewport(elementId) {
+ const el = document.getElementById(elementId);
+ _detachFromObservers(el, elementId);
+ _vpRefs.delete(elementId);
+}
+
+//
+// FLIP layout animation support
+//
+
+/** Returns the element's DOMRect as a plain object for C# to snapshot. */
+export function getBoundingRect(elementId) {
+ const el = document.getElementById(elementId);
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ return { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, left: r.left };
+}
+
+/**
+ * Run a FLIP animation via the Web Animations API.
+ * The element is currently at its NEW layout position; this animates it
+ * from the OLD (inverted) position to identity.
+ */
+export function playWaapiFlip(elementId, dx, dy, sx, sy, durationMs, easingStr, finalTransform) {
+ const el = document.getElementById(elementId);
+ if (!el) return;
+ el.style.transformOrigin = '0 0';
+ const anim = el.animate(
+ [
+ { transform: `translate(${dx}px,${dy}px) scaleX(${sx}) scaleY(${sy})` },
+ { transform: 'translate(0px,0px) scaleX(1) scaleY(1)' },
+ ],
+ { duration: durationMs, easing: easingStr || 'ease', fill: 'forwards' }
+ );
+ anim.onfinish = () => {
+ el.style.transform = finalTransform || '';
+ el.style.transformOrigin = '';
+ };
+}
+
+//
+// Scroll tracking
+//
+
+let _scrollKeySeq = 0;
+const _scrollSubs = new Map(); // key cleanup fn
+
+export function observeScroll(containerId, dotnetRef) {
+ const el = containerId ? document.getElementById(containerId) : window;
+ if (!el) return null;
+ const key = `scroll_${++_scrollKeySeq}`;
+
+ const onScroll = () => {
+ let sX, sY, sW, sH, cW, cH;
+ if (el === window) {
+ sX = window.scrollX; sY = window.scrollY;
+ sW = document.documentElement.scrollWidth;
+ sH = document.documentElement.scrollHeight;
+ cW = window.innerWidth; cH = window.innerHeight;
+ } else {
+ sX = el.scrollLeft; sY = el.scrollTop;
+ sW = el.scrollWidth; sH = el.scrollHeight;
+ cW = el.clientWidth; cH = el.clientHeight;
+ }
+ const pX = sW > cW ? sX / (sW - cW) : 0;
+ const pY = sH > cH ? sY / (sH - cH) : 0;
+ dotnetRef.invokeMethodAsync('OnScroll', {
+ scrollX: sX, scrollY: sY,
+ progressX: pX, progressY: pY,
+ scrollWidth: sW, scrollHeight: sH,
+ clientWidth: cW, clientHeight: cH,
+ });
+ };
+
+ el.addEventListener('scroll', onScroll, { passive: true });
+ _scrollSubs.set(key, () => el.removeEventListener('scroll', onScroll));
+ onScroll(); // fire immediately with current position
+ return key;
+}
+
+export function unobserveScroll(key) {
+ _scrollSubs.get(key)?.();
+ _scrollSubs.delete(key);
+}
diff --git a/src/Bmotion/README.md b/src/Bmotion/README.md
new file mode 100644
index 0000000000..5c434640a0
--- /dev/null
+++ b/src/Bmotion/README.md
@@ -0,0 +1,387 @@
+# Bit.Bmotion
+
+A Blazor-native animation library inspired by [Framer Motion](https://www.framer.com/motion/). Springs, gestures, layout animations, variants, and keyframes - **no manual JavaScript wiring required**. All animation math runs in C# via WebAssembly; the slim browser bridge is auto-loaded for you.
+
+> Targets **.NET 8, 9, and 10**
+
+---
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Quick Start](#quick-start)
+- [Components](#components)
+ - [Bmotion](#bmotion)
+ - [BmotionAnimatePresence](#bmotionanimatepresence)
+ - [BmotionConfig](#bmotionconfig)
+- [Animation Models](#animation-models)
+ - [BmotionAnimationProps](#bmotionanimationprops)
+ - [BmotionTransitionConfig](#bmotiontransitionconfig)
+ - [BmotionMotionVariants](#bmotionmotionvariants)
+ - [BmotionDragOptions](#bmotiondragoptions)
+ - [BmotionViewportOptions](#bmotionviewportoptions)
+- [Services](#services)
+ - [BmotionAnimationController](#bmotionanimationcontroller)
+ - [BmotionAnimateService](#bmotionanimateservice)
+ - [BmotionValue](#bmotionvalue)
+- [Examples](#examples)
+- [Accessibility](#accessibility)
+
+---
+
+## Installation
+
+```bash
+dotnet add package Bit.Bmotion
+```
+
+Register the services in `Program.cs`:
+
+```csharp
+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 `