Skip to content

feat: animated control for mount-once elements with enter animations#39

Merged
jonlaing merged 1 commit into
mainfrom
feat/animated
Jul 16, 2026
Merged

feat: animated control for mount-once elements with enter animations#39
jonlaing merged 1 commit into
mainfrom
feat/animated

Conversation

@jonlaing

Copy link
Copy Markdown
Owner

Summary

New `animated` control function for wrapping a single element with an enter animation. Fills the gap between the existing controls: `each` reconciles a signal against a keyed list, `when`/`match` toggle between branches based on state — none of them naturally handle "just mount this one element and animate it in."

The hand-authored intro sequence is the primary use case. Instead of shoehorning static content into `each` or faking a boolean signal for `when`, a headline of sibling `animated` blocks reads directly:

```ts
const App = () =>
Effect.gen(function* () {
const [g0, g1, g2] = yield* Animation.sequence(3);
return $.h1({}, collect(
animated({ animate: { enterFrom: "opacity-0", enter: "opacity-100 transition duration-300", group: g0 }, intro: true },
() => $.span({}, $.of("Hello,"))),
animated({ animate: { group: g1 } }, // no visual anim — the span has its own CSS keyframes
() => $.span({ class: "wobble" }, $.of("world!"))),
animated({ animate: { enterFrom: "opacity-0", enter: "opacity-100 transition duration-500", group: g2 }, intro: true },
() => $.span({}, $.of("Welcome."))),
));
});
```

API

  • `animated(config, () => child)` — top-level, lowercase, sibling to `each`/`when`/etc.
  • Config: `{ animate?: EnterOnlyAnimationOptions, intro?: boolean, container? }`.
  • New `EnterOnlyAnimationOptions` type — a `Pick` of `AnimationOptions` restricted to enter-lifecycle fields (`enter`, `enterFrom`, `enterTo`, `onBeforeEnter`, `onEnter`, `timeout`, `respectReducedMotion`, `group`). Prevents configuring `exit` fields that would be dead code since `animated` never removes.

Design choices

  • Lowercase, top-level. Consistent with `each`/`when`/`match`/`redraw`. Not PascalCase (avoids "looks like a component"). Not `Animation.enter` (that namespace is for utilities, not render functions).
  • Group inside `animate`. Same shape as every other control — `{ animate: { group: g0, ... } }`. Pure-CSS elements that only need sequencing write `{ animate: { group: g0 } }`.
  • Enter-only type. Element is mounted once and never removed, so exit fields would silently be dead code. Making that a compile-time constraint via `EnterOnlyAnimationOptions` avoids the foot-gun.

Implementation

Direct — bypasses `reconcile` (no signal to reconcile against) and just does `ctx.fork() → getContainer → addSlot("_", …) → finalizeContainer`. That leverages all the existing slot machinery, so `intro` (SSR enterFrom emission + hydration re-animation), `group` (registration + gating + completion), and enter animations work identically to how they do inside `each` / `when` / `match`.

Test plan

  • `npx tsc --noEmit` clean
  • Client tests: mounts child, fires `onBeforeEnter` on client mount, gates second block on first block's group completion.
  • SSR tests: emits `enterFrom` classes when `intro: true`, does not emit when omitted.
  • Full suite: 728 pass / 2 skipped (was 723 / 2)

🤖 Generated with Claude Code

New control function 'animated' for wrapping a single element with an enter animation. Renders once and never removes — designed for hand-authored intro sequences where each/when/match don't quite fit.

Type surface is enter-only via a new EnterOnlyAnimationOptions type (Pick of AnimationOptions) — no exit fields, since the element is never removed. Group and intro flags work the same as on the other controls: group lives inside the animate config (consistent with each/when/match), intro triggers SSR enterFrom emission and hydration re-animation.

Tests cover: mount, client-side enter animation, group sequencing, SSR enterFrom emission, SSR opt-out when intro is omitted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying effex with  Cloudflare Pages  Cloudflare Pages

Latest commit: d95a27a
Status: ✅  Deploy successful!
Preview URL: https://05d537ca.effex.pages.dev
Branch Preview URL: https://feat-animated.effex.pages.dev

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying effex-api with  Cloudflare Pages  Cloudflare Pages

Latest commit: d95a27a
Status: ✅  Deploy successful!
Preview URL: https://498fd690.effex-api.pages.dev
Branch Preview URL: https://feat-animated.effex-api.pages.dev

View logs

@jonlaing
jonlaing merged commit a3f4cd7 into main Jul 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant