DEV Community

Cover image for auto-animate now speaks marko
defunkt-dev
defunkt-dev

Posted on

auto-animate now speaks marko

As of @formkit/auto-animate@0.10.0, the library ships an official Marko adapter (PR #239).

If you write Marko 6, you can now get animated list additions, removals, and reorders with one tag and no per-item work:

npm install @formkit/auto-animate
Enter fullscreen mode Exit fullscreen mode
import AutoAnimate from "@formkit/auto-animate/marko"

<let/items = [
  { id: 1, text: "Mango" },
  { id: 2, text: "Papaya" },
]>

<ul/listRef>
  <for|item| of=items by="id">
    <li>${item.text}</li>
  </for>
</ul>
<AutoAnimate parent=listRef/>
Enter fullscreen mode Exit fullscreen mode

That's the whole integration. Mutate items however you like (push, filter, reverse, shuffle) and the DOM changes animate. There's a live demo covering the full API, server-rendered with @marko/run: demo ยท source.

The rest of this post is the part that's actually interesting: what shape an auto-animate adapter should take in a framework that has no directives, no hooks, does not perform a hydration re-render during initialization/resume (it resumes the server-rendered DOM in place) and what we learned when the published package met the newest Vite.

Marko in sixty seconds

If you haven't met Marko: it's an HTML-based language for building reactive UIs, born at eBay (it powers eBay.com) and now under the OpenJS Foundation. Nearly any valid HTML is valid Marko; you add reactivity with tags instead of a component API:

<let/count=0>
<button onClick() { count++ }>
  Clicked ${count} times
</button>
Enter fullscreen mode Exit fullscreen mode

Three properties matter for this article:

  • Fine-grained by compilation. The compiler analyzes which parts of a template are actually interactive and ships JavaScript only for those, at sub-template granularity. Static content ships zero JS. This goes further than islands: you don't declare server versus client components, the compiler works it out from the state tree.
  • Resumable, not hydrated. The server's HTML is the app. The client picks it up where the server left off, with no re-render pass to rebuild a component tree. (This is the property the SSR section below hinges on.)
  • Tiny output. In an independent comparison that built the same kanban app in ten frameworks, Marko shipped the smallest bundles of all: 6.8 kB compressed for the homepage and 28.8 kB for the full interactive board, versus 176.1 kB for Next.js. Our own numbers agree: every fully interactive route in the demo linked above ships about 10 kB of gzipped JavaScript in total, framework runtime and animation library included. Marko's runtime is estimated to be 7KB Gzipped.

Marko also streams HTML (in-order and out-of-order) as it's ready and has built-in TypeScript support. @marko/run is its file-based SSR meta-framework, which the demo uses.

What auto-animate is (and isn't)

auto-animate does exactly one thing: when the direct children of an element are added, removed, or moved, it FLIP-animates the change. No springs, no gestures, no orchestration, no timelines. Its about 14K stars are for the effort-to-payoff ratio, not for depth. It's the animation you were never going to hand-write for every CRUD list in your app, delivered in one line.

That small surface matters for this story. It's why a complete Marko adapter fits in a ~45-line tag, and why the demo linked above covers the complete Marko-adapter surface in five routes.

The adapter's shape: why a tag

Every framework adapter in this library mirrors its host framework's idiom. React and Preact get a hook that returns a ref. Vue and Angular get a directive you sprinkle on the parent element. Solid gets a directive too. Svelte uses actions.

Marko has none of those primitives, and it turns out not to need them. The adapter is a tag with three attributes:

<AutoAnimate
  parent=listRef      // a native element tag-variable
  options={ duration: 300, easing: "ease-in-out" }  // or a plugin function
  enabled=showMotion  // reactive boolean
/>
Enter fullscreen mode Exit fullscreen mode
  • parent is a Marko tag-variable: write <ul/listRef> and pass listRef. The target is explicit and typed, with no wrapper element and no DOM query.
  • options takes the same object as the core (duration, easing) or a plugin function. It's read once when the tag mounts, matching the core's behavior (there is no setOptions).
  • enabled is reactive. Flip it and the adapter calls the underlying controller's enable()/disable() for you.

The honest trade-off versus a directive: two touch points instead of one. You name the parent and place a tag, where Vue users write a single v-auto-animate. What you get back is more than the ceremony costs:

Reactive enable/disable as a plain attribute. In hook-based adapters, runtime toggling means capturing a second return value and calling methods on it. In directive-based ones it's awkward or impossible. In Marko it's an attribute bound to any reactive value. The demo drives it with a two-way-bound checkbox (checked:=enabled) and nothing else:

<let/enabled = true>
<input type="checkbox" checked:=enabled/>

<ul/listRef> ... </ul>
<AutoAnimate parent=listRef enabled=enabled/>
Enter fullscreen mode Exit fullscreen mode

Auto-discovery. The package ships a marko.json, so Marko's taglib discovery exposes <auto-animate> in your templates without an import statement. The explicit import AutoAnimate from "@formkit/auto-animate/marko" form works too, and is what the demo uses.

Lifecycle correctness for free. The tag's <lifecycle> owns setup, the reactive enabled diff, and teardown (controller.destroy() when the tag unmounts). Unmount the tag, or the branch containing it, and cleanup happens. There's no effect-ordering to reason about.

And if you don't want the tag at all, the core function is one import away:

client import autoAnimate from "@formkit/auto-animate"

<div/panelRef> ... </div>
<lifecycle onMount() { autoAnimate(panelRef()); }/>
Enter fullscreen mode Exit fullscreen mode

Same escape hatch every other framework's docs show; now Marko has it too.

Does it survive resume? (And why that question is different here)

Let's be precise about what's special and what isn't. Server-rendering auto-animate is not a Marko-only trick. React under Next and Vue under Nuxt server-render lists fine, and after hydration a useEffect/onMounted attaches the animation. Same outcome. Qwik, which also resumes rather than hydrates, has an adapter too.

What's different in Marko is the mechanism, and it changes how the adapter must be built. Marko never re-renders on the client. There is no hydration pass that reconstructs the component tree and hands you a fresh ref. The HTML the server printed is the app, and Marko resumes it in place. So the adapter can't hang off "the render that happens on the client," because there isn't one. Instead:

  1. The <ul> and its rows arrive as real server HTML.
  2. On resume, the tag's onMount runs (a client-only lifecycle phase).
  3. input.parent() dereferences the tag-variable, which points at the server-printed DOM node.
  4. autoAnimate() attaches its MutationObserver to that node.

The subtle failure mode this design avoids: the adapter never depends on a sibling effect having run first, because the parent is a real DOM node reachable through the ref the moment resume reaches the tag. Marko's leaf-first resume ordering, a genuine trap for other integration shapes, simply doesn't apply here.

You can verify the whole story in ten seconds: open the demo, view source, and find the <li> rows sitting in the server HTML. No client render produced them; the adapter attached to them anyway. There's a passing SSR test suite upstream asserting exactly this, including plain object options after resume and an inline-template plugin case. Runtime plugin functions passed through server input are a different case and should be treated as client-only.

The full API in five routes

The demo is structured so each route teaches one thing, and the five together are the complete surface:

  • / covers add / remove / shuffle / reverse with the tag, plus the view-source SSR check.
  • /options covers duration and easing, shown as a side-by-side race: two identical lists mutated by the same click, one at 150ms, one at 1500ms.
  • /plugin covers a custom plugin returning KeyframeEffects per action (add / remove / remain) for a bounce-in, shrink-out, springy-slide feel.
  • /enabled covers the reactive enabled attribute on a checkbox.
  • /dropdown covers the raw-core escape hatch, no tag involved.

One perceptual finding from building /options is worth passing on, because you'll hit it too: at the same duration, removals look slow and additions look instant. That's not a bug and not the adapter. It's the library's default keyframes. A removed row is kept in the DOM as an absolutely-positioned ghost and fades out visibly for the full duration while its siblings slide up. An entering row never moves: it's held completely invisible for the first half of its animation, then fades in (at 1.5x your configured duration, per the core source). A delayed fade with no motion reads as a pop, however long it runs. If you want loud entrances, that's what the plugin API is for. The /plugin route's bounce-in is the proof.

What we learned shipping it: a Vite 8 gotcha

The adapter merged, 0.10.0 published, the ./marko export went live. Then the first fresh npm install into a new @marko/run app crashed the dev server:

[UNRESOLVED_IMPORT] Could not resolve '../index' in
node_modules/@formkit/auto-animate/tags/auto-animate.marko
Enter fullscreen mode Exit fullscreen mode

The cause is a packaging subtlety. In the source repo, the tag imports the core as "../index", extensionless, which resolves to src/index.ts and passes every test. The release build copies the tag into the tarball next to the compiled index.mjs, keeping the import as-is. Older tooling would guess the extension. Vite 8's rolldown-based dependency optimizer applies strict ESM resolution inside a type: module package and refuses to guess. Boom.

No spelling of that import works in both places. Using ../index.mjs would break the repo's tests, and ../index.ts would break consumers. So the package-side fix needs to make the published .marko file resolvable under Vite 8โ€™s dependency optimizer. Rewriting the import during the release copy step is one possible fix; excluding the package from optimizeDeps is the current consumer workaround.

Until that lands upstream, the workaround is one config entry, which also reflects a general truth about .marko taglib packages (they should be compiled by the Marko plugin, not prebundled):

// vite.config.ts
export default defineConfig({
  plugins: [marko()],
  optimizeDeps: {
    exclude: ["@formkit/auto-animate"],
  },
});
Enter fullscreen mode Exit fullscreen mode

The demo ships with this and documents it. It's also a neat illustration of why "the tests pass" and "the published package works" are different claims: the upstream test suites import the tag from source, so no CI run ever exercises the installed tarball under a consumer's bundler. The only way to catch this class of bug is to do what you'd tell a user to do. Install the real release into a fresh app.

Try it

If you ship something with it, or hit an edge the five routes don't cover, I'd like to hear about it.

by defunkt-dev

Top comments (0)