Why Wardic

Wardic gives Solid the API of Motion for React, on an engine built with Effect. Three things set it apart: it's fast where motion happens, it keeps Motion's API while working the Solid way, and you can replace any part of its engine. Two more come with it: animations that play before your JavaScript loads, and observability built in.

Fast where motion happens

Wardic steps every animation on a page on one Frame loop, and draws what they all write together, once a frame. Measured against Motion, the work done while things move takes about half of its time or less: retargeting an animation, unmounting, and each frame while animations run.

1,000 elements, msWardic, Solid 1Wardic, Solid 2Motion 14.9
Mount37.738.839.4
A page's first mount62.368.353.4
Retarget while running17.116.343.6
Retarget at rest16.616.336.4
Unmount2.62.812.0
A frame while animating3.122.995.50

Lower is faster. Mounting is where Motion keeps up: see what it costs.

Measured on 2026-11-06, Wardic 1.0.0-rc.2 (main at f8afa96) against Motion for React 14.9.0 (framer-motion), in Chromium 147.0.7727.15, on an AMD Ryzen 7 9700X (8 cores, 16 threads) with 62 GiB, on Ubuntu 24.04.5 with Linux 6.8: 1,000 elements, each task timed with the two frames after it (an unmount with one), the median of 11 repetitions. A page's first mount is its first round. bun run bench:motion runs it again: its pages and protocol are in packages/solid/bench/motion. Each run's results are in #519.

Motion's API, the Solid way

A w.div, with the Features its provider gives, takes what a motion.div takes: initial, animate, exit and transition, variants that pass down the tree, hover, press, focus, drag and pan, layout and shared-element animations, presence, and values that follow the scroll or the pointer. What changes is Solid's:

  • A component runs once, so Wardic reads its props as they change, and nothing re-renders.
  • Hooks are create* primitives, as Solid names them.
  • On Solid 2, an element's events come from its ward.

Its values are typed: a bare number is taken only where it means something, and on a length it's drawn in px. Coming from Motion maps every API.

Animations before the JavaScript loads

On a server-rendered page, an element's mount animation, when the server knows all of it, plays from the first paint. The server samples it, from Wardic's own springs and tweens, into a CSS animation in the page's head, so it runs before any code arrives, and to its end with JavaScript off. When the client hydrates, it takes the animation over where it has got to, its speed included. See server rendering.

Built on Effect: replace any part

Wardic's engine asks for what it needs as Effect services: a clock, a renderer, its input, the page's measurements, the kinds of value it blends, the default transitions. @wardic/dom provides them for the browser, most of them as DomLive, and the provider's env takes any Layer that gives them. So you can replace parts of it:

  • the default transitions, which keys spring and which tween;
  • the kinds of value, so colours blend in OKLCH;
  • the announcer, to speak through your own live region;
  • the renderer;
  • tracing, logs and failure reports, Effect's own.

A Layer merged after DomLive replaces its service:

replace/solid2.tsx
import type { JSX } from "@solidjs/web"
import * as DefaultTransition from "@wardic/core/DefaultTransition"
import { DomLive } from "@wardic/dom"
import * as DomDefaultTransition from "@wardic/dom/DomDefaultTransition"
import { domAnimation, WardicProvider } from "@wardic/solid/unstable/v2"
import * as Layer from "effect/Layer"

/** One spring for every key: the same object each time, so the keys on it run together. */
const SPRING: DefaultTransition.Transition = { type: "spring", stiffness: 300, damping: 30 }

/**
 * Every key with no transition of its own on that spring, where the DOM's springs the transforms and
 * tweens the rest. Keyframes through more than two stops keep the DOM's tween.
 */
const layerOneSpring = Layer.succeed(DefaultTransition.DefaultTransition, {
  transitionFor: () => SPRING,
  keyframes: DomDefaultTransition.keyframes,
})

/** The DOM's Environment, its default transitions replaced: the later Layer wins. */
export const env = Layer.mergeAll(DomLive, layerOneSpring)

export default function App(props: { readonly children: JSX.Element }) {
  return (
    <WardicProvider env={env} features={[domAnimation]}>
      {props.children}
    </WardicProvider>
  )
}
replace/solid1.tsx
import * as DefaultTransition from "@wardic/core/DefaultTransition"
import { DomLive } from "@wardic/dom"
import * as DomDefaultTransition from "@wardic/dom/DomDefaultTransition"
import { domAnimation, WardicProvider } from "@wardic/solid"
import * as Layer from "effect/Layer"
import type { JSX } from "solid-js"

/** One spring for every key: the same object each time, so the keys on it run together. */
const SPRING: DefaultTransition.Transition = { type: "spring", stiffness: 300, damping: 30 }

/**
 * Every key with no transition of its own on that spring, where the DOM's springs the transforms and
 * tweens the rest. Keyframes through more than two stops keep the DOM's tween.
 */
const layerOneSpring = Layer.succeed(DefaultTransition.DefaultTransition, {
  transitionFor: () => SPRING,
  keyframes: DomDefaultTransition.keyframes,
})

/** The DOM's Environment, its default transitions replaced: the later Layer wins. */
export const env = Layer.mergeAll(DomLive, layerOneSpring)

export default function App(props: { readonly children: JSX.Element }) {
  return (
    <WardicProvider env={env} features={[domAnimation]}>
      {props.children}
    </WardicProvider>
  )
}

The services are @wardic/core's, so replacing one means installing @wardic/core too, at the version your @wardic/dom uses. A replaced default transition runs every animation on the client; a server-rendered Appear animation still plays on the DOM's own, since the server builds no Environment.

The same engine runs on any Environment that provides its services, the clock and the Frame loop included: a headless one tests Wardic today, and Environments beyond the DOM are the 1.5 milestone. Otherwise Effect stays out of your way: an app hands its provider DomLive and writes Solid.

Observability, built in

Every animation runs in its own span, the Frame loop keeps metrics, and Wardic logs what it does at Debug. Hand your provider Effect's tracing, logging or failure reporting once, beside DomLive, and every animation reports through it. In development, you can turn on a frame check that fails an animation whose frame holds a NaN, and names the key. See observability.

What it costs

  • Size. One Wardic element is 122 KB gzipped on Solid 1 and 158 KB on Solid 2, for the whole app, Solid included. On Solid 1, about two fifths of the minified code is Effect's runtime, half is Wardic's engine and binding, and the rest is Solid's and the colour library's. Gestures, layout and the wider colour spaces are Features, shipped only where a provider or a LazyWardic gives them, and the 1.1 milestone works on the base.
  • Mounting. A mount takes about as long as Motion's, but a page's first mount takes longer, while the engine sets up and its code first runs.
  • Effect. effect is a dependency you install beside Wardic.