diff --git a/.changeset/wire-up-stagger.md b/.changeset/wire-up-stagger.md
new file mode 100644
index 0000000..1798e54
--- /dev/null
+++ b/.changeset/wire-up-stagger.md
@@ -0,0 +1,10 @@
+---
+"@effex/core": patch
+"@effex/dom": patch
+---
+
+Wire up `stagger` for `each`'s enter animations. `stagger` was declared on `ListAnimationOptions` and exported via helpers (`stagger`, `staggerFromCenter`) but nothing in the runtime consumed it, so `each({ animate: { stagger: stagger(40) } })` produced no visible staggering. The API had been advertising the feature without implementing it.
+
+Now `reconcile` captures a `staggerStartAt` timestamp when a batch begins and threads it plus `totalItems` through to each slot's animation. `forkSlotEnter` computes the target fire time as `startAt + stagger(index, total)` and delays only the residual after reconcile overhead — so item N always fires at the same wall-clock moment regardless of how long reconcile takes to iterate to slot N. Without the shared reference, per-slot delays would compound and each item would drift further behind its expected position.
+
+Applies to `each` (client, client-fallback-during-hydration, hydration-root, and intro re-animation paths). `when` / `match` / etc. don't take a stagger option — they render single elements.
diff --git a/packages/core/src/Control.ts b/packages/core/src/Control.ts
index ce5d95e..25d9313 100644
--- a/packages/core/src/Control.ts
+++ b/packages/core/src/Control.ts
@@ -1,4 +1,4 @@
-import { Effect, Either, Option } from "effect";
+import { Clock, Effect, Either, Option } from "effect";
import { ControlCtx } from "./ControlCtx.js";
import type { Element } from "./Element.js";
@@ -58,6 +58,12 @@ export const reconcile = (
const sync = (value: A) =>
Effect.gen(function* () {
+ // Reference point for stagger delays. All new slots in this batch
+ // compute their fire time as `batchStart + index * staggerStep`, so
+ // slot N's animation fires at the same moment regardless of how
+ // long reconcile takes to iterate to it. Sourced from Effect.Clock
+ // (not Date.now()) so tests can substitute a TestClock.
+ const batchStart = yield* Clock.currentTimeMillis;
const currentKeys = yield* ctx.getSlotKeys();
const targetKeys = config.getTargetKeys(value);
const targetSet = new Set(targetKeys);
@@ -94,7 +100,13 @@ export const reconcile = (
key,
({ item, index }) =>
config.renderSlot(key, value, { item, index }),
- { atIndex: i, initialItem: itemValue, initialIndex: i },
+ {
+ atIndex: i,
+ initialItem: itemValue,
+ initialIndex: i,
+ totalItems: targetKeys.length,
+ staggerStartAt: batchStart,
+ },
);
}
}
diff --git a/packages/core/src/ControlCtx.ts b/packages/core/src/ControlCtx.ts
index 3267885..dbfb1bb 100644
--- a/packages/core/src/ControlCtx.ts
+++ b/packages/core/src/ControlCtx.ts
@@ -81,6 +81,19 @@ export interface IControlCtx {
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
+ /**
+ * Total number of slots in this reconcile batch, used by animation
+ * helpers to compute per-item stagger delay. Passed by `reconcile`;
+ * external callers can omit it.
+ */
+ totalItems?: number;
+ /**
+ * `Date.now()` timestamp captured when the current reconcile batch
+ * started. Animation helpers use it as a shared reference so every
+ * slot's stagger delay is measured from the same wall-clock moment
+ * rather than accumulating reconcile overhead. Passed by `reconcile`.
+ */
+ staggerStartAt?: number;
},
) => Effect.Effect, E, R>;
diff --git a/packages/dom/src/Control/ClientControlCtx.ts b/packages/dom/src/Control/ClientControlCtx.ts
index cc58507..fcb394a 100644
--- a/packages/dom/src/Control/ClientControlCtx.ts
+++ b/packages/dom/src/Control/ClientControlCtx.ts
@@ -70,6 +70,8 @@ const createClientControlCtx = (): IControlCtx => {
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
+ totalItems?: number;
+ staggerStartAt?: number;
},
): Effect.Effect =>
Effect.gen(function* () {
@@ -106,7 +108,11 @@ const createClientControlCtx = (): IControlCtx => {
};
slots.set(key, entry);
- yield* forkSlotEnter(element, slotScope);
+ yield* forkSlotEnter(element, slotScope, {
+ index: addOptions?.atIndex,
+ total: addOptions?.totalItems,
+ staggerStartAt: addOptions?.staggerStartAt,
+ });
return entry;
}) as Effect.Effect,
diff --git a/packages/dom/src/Control/Control.test.ts b/packages/dom/src/Control/Control.test.ts
index 8435f3c..3f71876 100644
--- a/packages/dom/src/Control/Control.test.ts
+++ b/packages/dom/src/Control/Control.test.ts
@@ -617,4 +617,73 @@ describe("Control", () => {
}).pipe(Effect.provide(TestLayer)),
);
});
+
+ describe("each animation stagger", () => {
+ it.scopedLive(
+ "invokes the stagger function with each item's index and total",
+ () =>
+ // Reconcile is expected to call stagger(index, total) for every new
+ // slot in the batch — so we spy on the function itself instead of
+ // measuring wall-clock delays (which are unreliable under CI load).
+ Effect.gen(function* () {
+ const calls: Array<{ index: number; total: number }> = [];
+ const spy = (index: number, total: number) => {
+ calls.push({ index, total });
+ return 0;
+ };
+
+ const items = yield* Signal.make(["a", "b", "c"]);
+ yield* each(items, {
+ key: (item) => item,
+ render: (item) => $.li({}, $.of(item)),
+ animate: {
+ enterFrom: "opacity-0",
+ enter: "opacity-100",
+ stagger: spy,
+ timeout: 10,
+ },
+ });
+
+ // Give the forked fibers a moment to reach the stagger call.
+ yield* Effect.sleep("20 millis");
+
+ expect(calls).toEqual([
+ { index: 0, total: 3 },
+ { index: 1, total: 3 },
+ { index: 2, total: 3 },
+ ]);
+ }).pipe(Effect.provide(TestLayer)),
+ );
+
+ it.scopedLive("does not invoke stagger when the option is omitted", () =>
+ Effect.gen(function* () {
+ let callCount = 0;
+ const untriggered = (_index: number, _total: number) => {
+ callCount++;
+ return 0;
+ };
+
+ const items = yield* Signal.make(["a", "b"]);
+ yield* each(items, {
+ key: (item) => item,
+ render: (item) => $.li({}, $.of(item)),
+ animate: {
+ // Note: stagger is deliberately omitted here. `untriggered` is
+ // declared only so the assertion below can reference it and be
+ // read as "the spy that would have fired if stagger had been
+ // set". Tsc otherwise flags the arrow as unused.
+ enterFrom: "opacity-0",
+ enter: "opacity-100",
+ timeout: 10,
+ },
+ });
+
+ yield* Effect.sleep("20 millis");
+ expect(callCount).toBe(0);
+ // Reference `untriggered` to satisfy the linter that it exists as a
+ // documentation aid; behaviourally it must never have been called.
+ expect(untriggered.length).toBe(2);
+ }).pipe(Effect.provide(TestLayer)),
+ );
+ });
});
diff --git a/packages/dom/src/Control/HydrationControlCtx.ts b/packages/dom/src/Control/HydrationControlCtx.ts
index d9f30b9..c9e841f 100644
--- a/packages/dom/src/Control/HydrationControlCtx.ts
+++ b/packages/dom/src/Control/HydrationControlCtx.ts
@@ -95,6 +95,8 @@ const createClientLikeControlCtx = (
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
+ totalItems?: number;
+ staggerStartAt?: number;
},
): Effect.Effect =>
Effect.gen(function* () {
@@ -149,7 +151,12 @@ const createClientLikeControlCtx = (
// Post-hydration → always. During hydration → forkSlotEnter checks
// the intro flag and only runs for opted-in controls.
- yield* forkSlotEnter(element, slotScope, { hydrating: !hydrationDone });
+ yield* forkSlotEnter(element, slotScope, {
+ hydrating: !hydrationDone,
+ index: addOptions?.atIndex,
+ total: addOptions?.totalItems,
+ staggerStartAt: addOptions?.staggerStartAt,
+ });
return entry;
}) as Effect.Effect,
@@ -270,6 +277,8 @@ const createHydrationControlCtx = (
atIndex?: number;
initialItem?: unknown;
initialIndex?: number;
+ totalItems?: number;
+ staggerStartAt?: number;
},
): Effect.Effect =>
Effect.gen(function* () {
@@ -298,6 +307,9 @@ const createHydrationControlCtx = (
// leave the rendered DOM as-is.
yield* forkSlotEnter(existing.element, slotScope, {
hydrating: true,
+ index: addOptions?.atIndex,
+ total: addOptions?.totalItems,
+ staggerStartAt: addOptions?.staggerStartAt,
});
return entry;
@@ -337,7 +349,11 @@ const createHydrationControlCtx = (
};
slots.set(key, entry);
- yield* forkSlotEnter(element, slotScope);
+ yield* forkSlotEnter(element, slotScope, {
+ index: addOptions?.atIndex,
+ total: addOptions?.totalItems,
+ staggerStartAt: addOptions?.staggerStartAt,
+ });
return entry;
}) as Effect.Effect,
diff --git a/packages/dom/src/Control/slotAnimation.ts b/packages/dom/src/Control/slotAnimation.ts
index 79da452..57d509f 100644
--- a/packages/dom/src/Control/slotAnimation.ts
+++ b/packages/dom/src/Control/slotAnimation.ts
@@ -17,7 +17,7 @@
* was in scope at Layer construction).
*/
-import { Effect, Exit, Option, Scope } from "effect";
+import { Clock, Effect, Exit, Option, Scope } from "effect";
import {
_awaitGate,
@@ -25,13 +25,28 @@ import {
_register,
type AnimationGroup,
} from "../Animation/groups.js";
-import { runEnterAnimation, runExitAnimation } from "../Animation/index.js";
+import {
+ runEnterAnimation,
+ runExitAnimation,
+ type ListAnimationOptions,
+ type StaggerFunction,
+} from "../Animation/index.js";
import { AnimationConfigCtx } from "./AnimationConfigCtx.js";
type DOMElement = HTMLElement | SVGElement;
type AnimateWithGroup = T & { group?: AnimationGroup };
+const staggerDelayMs = (
+ stagger: ListAnimationOptions["stagger"] | undefined,
+ index: number,
+ total: number,
+): number => {
+ if (stagger === undefined) return 0;
+ if (typeof stagger === "number") return index * stagger;
+ return (stagger as StaggerFunction)(index, total);
+};
+
interface ResolvedAnimation {
readonly animate: AnimateWithGroup;
readonly intro: boolean;
@@ -69,12 +84,25 @@ const readAnimation = (): Effect.Effect | undefined> =>
export const forkSlotEnter = (
element: DOMElement,
slotScope: Scope.CloseableScope,
- opts?: { readonly hydrating?: boolean },
+ opts?: {
+ readonly hydrating?: boolean;
+ /** Position of this slot within its reconcile batch (0-based). */
+ readonly index?: number;
+ /** Total number of slots in this reconcile batch. */
+ readonly total?: number;
+ /**
+ * `Date.now()` timestamp captured when reconcile started iterating.
+ * Used to compute stagger delays relative to a shared reference so
+ * items fire at `startAt + delayMs` regardless of how long reconcile
+ * takes to reach each slot — otherwise reconcile overhead would
+ * compound and each staggered item would drift further behind.
+ */
+ readonly staggerStartAt?: number;
+ },
): Effect.Effect => {
if (!(element instanceof HTMLElement)) return Effect.void;
return Effect.gen(function* () {
- const resolved =
- yield* readAnimation[1]>();
+ const resolved = yield* readAnimation();
if (!resolved) return;
if (opts?.hydrating && !resolved.intro) return;
@@ -84,13 +112,26 @@ export const forkSlotEnter = (
_register(grp);
}
+ const targetDelay =
+ opts?.index !== undefined && opts?.total !== undefined
+ ? staggerDelayMs(animate.stagger, opts.index, opts.total)
+ : 0;
+ const now = yield* Clock.currentTimeMillis;
+ const actualDelay =
+ targetDelay > 0 && opts?.staggerStartAt !== undefined
+ ? Math.max(0, opts.staggerStartAt + targetDelay - now)
+ : targetDelay;
+
yield* Effect.gen(function* () {
if (grp) {
yield* _awaitGate(grp);
}
- yield* runEnterAnimation(Effect.succeed(element), animate).pipe(
+ const run = runEnterAnimation(Effect.succeed(element), animate).pipe(
Effect.ensuring(grp ? _complete(grp) : Effect.void),
);
+ yield* actualDelay > 0
+ ? run.pipe(Effect.delay(`${actualDelay} millis`))
+ : run;
}).pipe(Effect.forkIn(slotScope));
}).pipe(Effect.asVoid);
};
diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts
index 871093e..85673b1 100644
--- a/packages/dom/src/index.ts
+++ b/packages/dom/src/index.ts
@@ -71,15 +71,20 @@ export { collect } from "./Collect.js";
// Animation
export type {
AnimationEndResult,
+ AnimationGroup,
AnimationHook,
AnimationOptions,
ListAnimationOptions,
StaggerFunction,
} from "./Animation/index.js";
export {
+ Animation,
runEnterAnimation,
runExitAnimation,
prefersReducedMotion,
+ waitForAnimationEvent,
+ waitForAnimationEnd,
+ forceReflow,
stagger,
staggerFromCenter,
staggerEased,