diff --git a/.github/contributing.md b/.github/contributing.md
new file mode 100644
index 0000000..1c72125
--- /dev/null
+++ b/.github/contributing.md
@@ -0,0 +1,50 @@
+# Contributing
+
+Install the dependencies with [Bun](https://bun.sh):
+
+```sh
+bun install
+```
+
+Run every check before you open a pull request:
+
+```sh
+bun run check
+```
+
+It runs these steps in order, and stops at the first one that fails:
+
+| Script | What it does |
+| ---------------------- | ------------------------------------------------------ |
+| `bun run typecheck` | `tsc --noEmit` over `src` and `tests`. |
+| `bun run lint` | `oxlint`, with warnings treated as errors. |
+| `bun run check:format` | `oxfmt --check` for code, `prettier --check` for docs. |
+| `bun run test` | `vitest run`. |
+| `bun run build` | `tsc` writes the declarations to `dist`. |
+
+`bun run format` fixes the formatting.
+
+## Tests
+
+Tests run on jsdom. `tests/clock.ts` fakes the clock and the frame loop, so an
+animation's progress depends only on how far the test advances the clock. Use
+`useFakeClock()` in a suite and `await advance(ms)` to move time forward.
+
+jsdom does no layout. A layout test gives each element a box, as
+`tests/layout.test.tsx` does.
+
+A test drives the behavior through `motion.*` or one of the `create*` functions,
+and checks what the element looks like. It does not mock `motion-dom`.
+
+## Docs
+
+The manual is in [`docs/`](../docs/README.md). Each page has an example. Run an
+example before you change the text around it.
+
+Format changed Markdown files with:
+
+```sh
+bunx prettier --print-width 80 --prose-wrap always --write docs/animating-elements.md
+```
+
+For the code layout, read [Architecture](../docs/architecture.md).
diff --git a/README.md b/README.md
index 7b60743..83dc000 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,27 @@
-# Solid Motion
+# solid-motion
-`@totallynotdavid/solid-motion` is an animation package for Solid 2. It is built
-directly on [`motion-dom`](https://motion.dev), the framework-agnostic core of
-Motion. Solid's reactive graph drives animation state, and the package starts
-per-value `motion-dom` animations directly.
+Animation for [Solid](https://www.solidjs.com) 2, built on
+[`motion-dom`](https://motion.dev), the core of Motion.
-It covers `initial`/`animate`/`exit` targets, variants and stagger, hover,
-press, focus and in-view gestures, presence (exit animations), layout and shared
-layout animations, scroll-linked values, SVG, and reduced-motion handling.
+```tsx
+
+```
## Install
```sh
-npm install @totallynotdavid/solid-motion solid-js @solidjs/web
+npm install @totallynotdavid/solid-motion solid-js@next @solidjs/web@next
```
-`solid-js` and `@solidjs/web` are peer dependencies, both `^2.0.0-rc.9`. Solid 2
-is still a prerelease, so install the `next` tag until it is stable:
-`npm install solid-js@next @solidjs/web@next`.
-
-## Setup
+`solid-js` and `@solidjs/web` are peer dependencies, and both must be version 2.
+Solid 2 is a prerelease, so install it from the `next` tag.
-The package ships its TSX source, not compiled JavaScript, and exposes it under
-the `solid` export condition. Your build must compile Solid JSX for
-dependencies, which `@solidjs/vite-plugin` does:
+The package ships TSX source, so your build must compile Solid JSX inside
+`node_modules`. `@solidjs/vite-plugin` does this:
```ts
// vite.config.ts
@@ -33,11 +31,10 @@ import { defineConfig } from "vite";
export default defineConfig({ plugins: [solid()] });
```
-Type declarations are emitted to `dist/` when the package is packed. With
-`"jsx": "preserve"` and `"jsxImportSource": "@solidjs/web"` in your
-`tsconfig.json`, no other TypeScript setup is needed.
+For TypeScript, set `"jsx": "preserve"` and `"jsxImportSource": "@solidjs/web"`
+in `tsconfig.json`.
-## Quick start
+## Get started
```tsx
import { render } from "@solidjs/web";
@@ -64,546 +61,31 @@ function App() {
render(() => , document.getElementById("root")!);
```
-## API overview
-
-| Export | Purpose |
-| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
-| [`motion`](#motion) | Animated elements: `motion.div`, `motion.create(Component)` |
-| [`createMotion`](#createmotion) | The engine behind `motion.*`, without a component boundary |
-| [`createMotionValue`](#createmotionvalue) | A value that animates outside Solid's render path |
-| [`AnimatePresence`](#presence-animatepresence--animatepresencelist), `AnimatePresenceList`, `usePresence` | Exit animations |
-| [`MotionConfig`](#motionconfig--usemotionconfig), `useMotionConfig` | Shared defaults for descendants |
-| [`useReducedMotion`](#usereducedmotion) | Accessor for `prefers-reduced-motion` |
-| [`createInView`](#gestures) | Viewport visibility as a boolean accessor |
-| [`stagger`, `transform`](#re-exported-helpers-stagger-transform) | Re-exported from `motion-dom` |
-| [`createScroll`, `createVelocity`, `createTime`](#layout-scroll-values-and-imperative-primitives) | Scroll-linked and time-linked values |
-| [`createAnimate`, `createWillChange`](#layout-scroll-values-and-imperative-primitives) | Imperative animation of elements you do not render |
-
-## `motion.*`
-
-`motion` is a proxy. Accessing `motion.div`, `motion.button`, `motion.line`, or
-any other intrinsic tag lazily creates an animated version of that element.
-Every non-motion prop (`class`, `onClick`, `children`, …) passes straight
-through. `motion.create(Component)` wraps a custom component similarly.
-
-```tsx
-import type { JSX } from "@solidjs/web";
-
-const Card = (props: {
- title: string;
- style?: JSX.CSSProperties;
- ref?: JSX.Ref;
-}) => (
-
- {props.title}
-
-);
-const MotionCard = motion.create(Card);
-```
-
-The wrapper hands the wrapped component `style` and `ref` the same way it hands
-an intrinsic tag its own (see [`createMotion`](#createmotion) below). Motion
-mounts on the element only through `ref`, so a component that drops it never
-animates at all. `style` isn't involved in mounting; a component that forwards
-`ref` but drops `style` still animates, so it just flashes the wrong value on
-first paint since there's no server-rendered/first-paint style to match against.
-`initial={false}` is the exception: the element is born already at its `animate`
-target, so the first pass sees nothing to move and never writes to the node.
-Dropping `style` there leaves the element at its browser-default value until a
-later prop change gives the pass something to correct. Both `ref` and `style`
-should be forwarded to the node that should animate.
-
-Rendering `motion.line`, `motion.path`, or another tag on this package's fixed
-SVG list animates SVG geometry correctly. See [SVG animation](#svg-animation)
-below for that list. The element receives its initial style, or SVG attributes,
-inline during server rendering and the client's first paint. Nothing is applied
-after mount, so hydration does not flash.
-
-The component form adds a component boundary and a runtime prop spread. For a
-leaf animation under a long list, [`createMotion`](#createmotion) skips both.
-
-## Animation props
-
-These are `MotionOptions`, accepted by both `motion.*` and `createMotion`.
-Animation props are read from a thunk, so values resolved from signals are
-re-diffed. The `initial` target is the exception: it is resolved once because it
-describes the first paint.
-
-- `initial`: The target the element starts at. `initial={false}` (or a `false`
- from an enclosing `AnimatePresence`) skips the entrance animation for a
- subtree that was already on screen.
-- `animate`: The target while the element is present. This is the only layer
- that is always active.
-- `exit`: The target while a `hold`-based presence boundary animates the element
- out. See [Presence](#presence-animatepresence--animatepresencelist). `exit`
- sits above `animate`, rather than replacing it. A key owned by `animate` but
- omitted from `exit` keeps its animated value while leaving. A key named only
- in `exit` uses its own `transition`, if declared.
-- `whileHover` / `whilePress` / `whileFocus` / `whileInView`: See
- [Gestures](#gestures).
-- `custom`: Arbitrary data handed to a variant function. If absent, it falls
- back to the nearest ancestor's `custom`, then to the enclosing presence
- boundary's value. This lets a list item drive its own per-row variant from a
- value set at the top.
-- `variants`: A `VariantMap`, so any of the layers above can be a string label
- instead of an inline target. See
- [Variants, stagger and orchestration](#variants-stagger-and-orchestration).
-- `transition`: A `Transition` (re-exported from `motion-dom`) applied to
- whichever layer is active. Falls back to `MotionConfig`'s `transition` when
- omitted.
-- `style`: Plain CSS, except entries may be a `MotionValue` or a Solid accessor.
- Motion-owned keys are written on the animation frame instead of through
- Solid's DOM diffing. Naming the same key in `animate` or another target
- animates an explicit, caller-owned `MotionValue` in place, so it can be read
- back once a pass has actually changed it. `initial` does not do this: it only
- seeds the first paint's inline style and never writes the bound value itself.
- A plain accessor has no caller-owned value to read back at all; it drives a
- private `MotionValue` this package creates and keeps to itself.
-- `onAnimationStart(definition)` / `onAnimationComplete(definition)` /
- `onUpdate(latest)`: Plain callbacks, not reactive.
-
-A target may also be a list of variant labels
-(`animate={["hidden", "visible"]}`), resolved against `variants` and merged left
-to right, with later entries winning per key. Inline targets cannot be mixed
-into that array; `AnimationDefinition` only accepts `string[]` alongside a
-single label, a single inline target, or `false`. A value inside an inline
-target can itself be a keyframe array (`animate={{ opacity: [0, 1] }}`). For
-`height`, `width`, and other pairs with incompatible start and end units (`0` to
-`"auto"`, `"none"`, or `"100%"`), the package measures the element and animates
-between the resulting numbers. This measurement path applies to HTML elements
-only. SVG uses its attribute path instead.
-
-## `createMotion`
-
-```tsx
-import { createMotion } from "@totallynotdavid/solid-motion";
-
-function CollapsibleRow(props: { open: boolean }) {
- const motionHandle = createMotion(() => ({
- animate: { height: props.open ? "auto" : 0 },
- transition: { duration: 0.2 },
- }));
-
- return ;
-}
-```
-
-`createMotion(options, tag?)` is the primitive behind `motion.div`. It contains
-the whole engine without a component boundary. It takes a thunk of
-`MotionOptions` and an optional tag name. The tag only decides whether the
-initial paint uses style or SVG attributes. It returns a `MotionHandle`:
-
-- `style`: The inline style the element must be born with. Merge it over any
- style of your own. The motion target must win the first paint.
-- `attrs`: The same, for SVG geometry attributes. Empty for HTML.
-- `ref`: Attach it to the element. It runs the animation lifecycle and registers
- variant children for stagger.
-- `scope`: What this element offers descendants when it names a variant label,
- or `null`. It exists so `motion.div` can provide it through context for its
- own children; the scope type itself is internal and not exported, so a leaf
- animation never needs it and a custom wrapper cannot plug into it directly.
-
-Use `createMotion` instead of `motion.div` when an element sits under a long,
-frequently updating list. The primitive keeps `class`, `onClick`, and every
-other attribute on Solid's compiled setters instead of a runtime spread.
-
-## `createMotionValue`
-
-```tsx
-import { createMotionValue, motion } from "@totallynotdavid/solid-motion";
-import { createSignal } from "solid-js";
-
-function Ball() {
- const [targetX, setTargetX] = createSignal(0);
-
- // Follows the source immediately.
- const scale = createMotionValue(1);
-
- // Springs toward source changes.
- const x = createMotionValue(targetX, { stiffness: 300, damping: 30 });
-
- return (
- <>
-
-
- >
- );
-}
-```
-
-`createMotionValue(source, transition?)` creates a `MotionValue` that lives
-outside Solid's render path: writes land straight on the element on the
-animation frame, so nothing re-renders when it moves. `source` can be a
-constant, a Solid accessor, or another `MotionValue`. With a `transition`,
-source changes retarget a spring from its current position and velocity instead
-of restarting it. The value is destroyed automatically when its owning scope is
-cleaned up.
-
-Bind it to an element through `style`, and it stays reactive there like any
-other bound style entry (see `style` above). Solid does not need `useTransform`
-or `useMotionTemplate`: a derived value is just an accessor. Use the re-exported
-[`transform`](#re-exported-helpers-stagger-transform) for range mapping, as in
-`style={{ opacity: () => fade(scroll()) }}`. Use a template literal for the
-template form.
-
-## Gestures
-
-`whileHover`, `whilePress`, and `whileFocus` each define a target that applies
-only while that state is active. Drag (`drag`, `dragControls`) is not
-implemented.
-
-```tsx
-
-```
-
-Hover and press use `motion-dom`'s gesture recognizers rather than hand-written
-`pointerenter`/`pointerdown` listeners. Hover filters polyfilled touch events
-and defers its end while a press is in flight, so a button does not flicker when
-the pointer slips off mid-click. Press filters secondary and multi-touch
-pointers, ends during the capture phase, and also fires from Enter on a
-keyboard. `whileFocus` gates on `:focus-visible`, so a mouse click does not
-trigger a focus style. A missing prop attaches no listener. An element without
-`whileHover` pays nothing for hover handling.
-
-`whileInView` applies while the element is in the viewport, tuned by a sibling
-`viewport` prop:
-
-```tsx
-
-```
-
-`viewport: { once, root, margin, amount }`: `once` unobserves after the element
-has entered, rather than re-checking a flag. `root` and `margin` map to
-`IntersectionObserver`'s `root` and `rootMargin`. `amount` is `"some"` (any part
-visible), `"all"`, or a 0–1 threshold.
-
-`createInView(element, viewport?)` exposes the same observer as a plain boolean
-accessor, with no animation attached. It is useful for an infinite-scroll
-sentinel, a lazily mounted chart, or an impression tracker that has nothing to
-do with motion:
-
-```tsx
-import { createInView } from "@totallynotdavid/solid-motion";
-import { createSignal } from "solid-js";
-
-function LazyChart() {
- const [node, setNode] = createSignal();
- const inView = createInView(node, { once: true });
-
- return
{inView() ? : null}
;
-}
-```
-
-Like every other primitive here, it needs a reactive owner: called from a
-component body or an effect, not from module scope. Called without one, the
-observer is still created and starts observing, but its disconnect on cleanup
-has nowhere to register, so it leaks for the page's lifetime instead of being
-torn down.
+The element starts at `initial`, animates to `animate` on mount, and animates
+again each time a signal read inside `animate` changes.
-## Presence: `AnimatePresence` / `AnimatePresenceList`
+## Features
-Both keep a subtree mounted while its `exit` animation plays and unmount it once
-every animated element inside has released its hold. A hold is used instead of a
-promise because a cancelled `motion-dom` animation's `finished` promise never
-settles.
+- `initial`, `animate` and `exit` targets, with keyframes and `"auto"` sizes
+- Variants, with stagger and parent-child ordering
+- Hover, press, focus and in-view states
+- Exit animations with `AnimatePresence`
+- Layout and shared-layout animations
+- Motion values, scroll-linked values and velocity
+- SVG geometry and path drawing
+- Reduced-motion support and a switch that turns all animation off
+- Server rendering: the first paint is in the HTML
+- `animate()` for elements you do not render yourself
-This does not reach through a nested presence boundary. A nested
-`AnimatePresence` or `AnimatePresenceList` provides its own presence scope,
-which shadows the outer one for everything inside it, so any exit hold taken
-there registers with the inner boundary instead of the outer one. If the outer
-subtree exits while that nested boundary is still finishing its own exit, the
-outer boundary sees zero holds and unmounts the whole nested subtree during its
-own cleanup, cutting the nested exit short. Keep an `AnimatePresence` at the
-boundary that actually needs to wait, rather than nesting one inside a subtree
-another boundary can also remove.
+Drag is not implemented.
-`AnimatePresence` is the single/keyed form. Control it with a `when` value
-rather than a conditional child. Solid disposes a branch as soon as its
-condition flips, before a boundary can notice it. The boundary must build and
-own the subtree itself so it can hold the outgoing element:
-
-```tsx
-import { AnimatePresence, motion } from "@totallynotdavid/solid-motion";
-
-
- {(id) => (
-
- {id}
-
- )}
-;
-```
+## Manual
-`when` doubles as the identity key: falsy renders nothing, and changing it to a
-different truthy value swaps, with the outgoing and incoming subtrees animating
-at the same time by default (`mode="wait"` holds the newcomer back until the
-outgoing one is fully gone). Returning to a key still mid-exit revives that
-subtree instead of building a second one beside it.
-
-`AnimatePresenceList` is the keyed-list form:
-
-```tsx
-import { AnimatePresenceList, motion } from "@totallynotdavid/solid-motion";
-
- item.id}>
- {(item) => (
-
- {item().label}
-
- )}
-;
-```
-
-The child function receives an accessor, not the raw item. Use `item()` inside
-it so a surviving row picks up new data without being recreated. A row removed
-from `each` stays mounted at its old position, rather than collapsing to the end
-of the list, until its exit animation finishes. Both components accept
-`initial={false}`, `custom` (handed down as each element's inherited `custom`),
-and `onExitComplete`. `initial={false}` is a property of the boundary, not of
-one render: it skips the entrance animation for every element the boundary ever
-renders, including a row added long after the boundary itself first mounted, not
-only the ones present at that first render.
-
-`usePresence()` returns the enclosing boundary's presence context, or `null`
-outside one. `createMotion` uses it to resolve the `exit` layer and animation
-holds. Most consumers never need it directly.
-
-## `MotionConfig` / `useMotionConfig`
-
-```tsx
-import { MotionConfig } from "@totallynotdavid/solid-motion";
-
-
-
-;
-```
-
-Sets shared defaults for descendants. Nested providers merge their values over
-inherited configuration:
-
-- `transition`: The fallback `Transition` for any element that names none of its
- own.
-- `skipAnimations`: A hard off switch. Every animation started under it jumps
- straight to its target. This applies at the point where every animation is
- created, so it also overrides a target's own `transition`.
-- `reducedMotion`: `"never"` (default), `"always"`, or `"user"` (follows the OS
- preference via `useReducedMotion`). This does not disable every animation:
- positional and layout keys jump while opacity and colour still animate,
- matching Motion's reduced-motion contract.
-
-## `useReducedMotion`
-
-```tsx
-import { useReducedMotion } from "@totallynotdavid/solid-motion";
-
-const prefersReducedMotion = useReducedMotion();
-```
-
-A Solid accessor for `(prefers-reduced-motion: reduce)`. A single `matchMedia`
-listener is installed at module load, not per element. Read it directly only for
-your own conditional logic. `MotionConfig reducedMotion="user"` already wires it
-into every motion element's animations.
-
-## Variants, stagger, and orchestration
-
-A `variants` map lets `initial`/`animate`/`exit`/the gesture props take a string
-label instead of an inline target, optionally as a function of `custom`:
-
-```tsx
-import { motion, type VariantMap } from "@totallynotdavid/solid-motion";
-
-const variants: VariantMap = {
- hidden: { opacity: 0 },
- visible: (custom) => ({
- opacity: 1,
- transition: { delay: (custom as number) * 0.05 },
- }),
-};
-
-;
-```
-
-`motion.*` types every variant function's parameter as `unknown`, not the
-`custom` type a particular instance passes, so a cast is unavoidable there.
-`createMotion` (the primitive behind it) does carry a real generic:
-call it directly when you want the compiler to check `custom` end to end.
-
-An element whose layer names a label becomes a variant-controlling scope. Its
-resolved label propagates to descendants through context, so a child with a
-matching `variants` map inherits it without repeating the prop. An inline target
-is never inherited. It does not resolve against a child's different `variants`
-map, and a child's own prop always wins over inherited state.
-
-Stagger is offered through `transition` on the controlling parent:
-
-```tsx
-
-
-
-
-```
-
-`delayChildren` is the base offset every child gets. `staggerChildren` adds a
-per-position increment, and `staggerDirection: -1` reverses which end starts
-first. Position is read live from the DOM, so a keyed list reordering rows
-staggers correctly without re-registering them.
-
-For non-constant stagger, `delayChildren` also accepts the `stagger()` function
-provided by `motion-dom`. It is re-exported from this package, so consumers do
-not need to import `motion-dom` directly. A function replaces the constant form
-instead of adding to it:
-
-```tsx
-import { motion, stagger } from "@totallynotdavid/solid-motion";
-
-
-
-
-;
-```
-
-`transition.when` sequences a controlling element's pass with its children's:
-
-```tsx
-transition={{ duration: 0.2, when: "beforeChildren" }}
-```
-
-`"beforeChildren"` holds every descendant's pass until the parent's own pass
-finishes. `"afterChildren"` holds the parent back until every descendant's pass
-finishes. Sequencing composes with presence, so a `beforeChildren`-sequenced
-exit stays inside its `AnimatePresence` hold until it starts.
-
-## SVG animation
-
-`motion.line`, `motion.path`, `motion.circle`, and the rest of the fixed tag
-list this package recognizes as SVG (`animate`, `circle`, `defs`, `desc`,
-`ellipse`, `filter`, `g`, `image`, `line`, `marker`, `mask`, `metadata`, `path`,
-`pattern`, `polygon`, `polyline`, `rect`, `stop`, `svg`, `switch`, `symbol`,
-`text`, `tspan`, `use`, `view`) render in the SVG namespace. Geometry that CSS
-cannot animate (`x1`, `x2`, `r`, `viewBox`, …) is written and animated as
-attributes on those tags, never as style. A tag missing from that list —
-`foreignObject`, `clipPath`, and `linearGradient` among them — is not recognized
-as SVG by this package, so its initial geometry is emitted as `style` instead of
-attributes, even where the tag itself is genuinely SVG:
-
-```tsx
-
-```
-
-`pathLength` animates a path drawing itself by driving the `stroke-dasharray`
-pair behind the scenes:
-
-```tsx
-
-```
-
-`opacity`, `transform`, `fill`, and other CSS-animatable properties still go
-through `style` on an SVG element, as they do on HTML. A custom component
-(`motion.create(MyComponent)`) is always assumed to render HTML. Only a literal
-SVG tag name is known before the element exists.
-
-## Re-exported helpers: `stagger`, `transform`
-
-Both functions are re-exported from `motion-dom`, so consumers of this package's
-orchestration or value APIs do not need a direct `motion-dom` dependency:
-
-- `stagger(duration, options?)`: Builds the per-child delay function
- `delayChildren` takes. See [stagger](#variants-stagger-and-orchestration)
- above.
-- `transform(inputRange, outputRange, options?)`: The pure range-mapping
- interpolator behind Motion's `useTransform`. In Solid, a derived value is just
- an accessor over it, not a second value kept in sync by a subscription:
-
- ```tsx
- import { motion, transform } from "@totallynotdavid/solid-motion";
-
- const fade = transform([0, 200], [0, 1]);
- fade(scrollY()) }} />;
- ```
-
-## Layout, scroll values, and imperative primitives
-
-`layout` and `layoutId` are `MotionOptions` props: `layout` animates an element
-across a change in its own measured box, and two elements sharing a `layoutId`
-animate as one shared element across a change in which of them is mounted.
-Neither needs `VisualElement`; the projection engine behind them is generic over
-the host element instead.
-
-`createScroll`, `createVelocity`, and `createTime` are scroll-linked values, for
-a `style` entry driven by scroll position rather than a discrete state change:
-`createScroll(options?)` tracks a container/target pair (or the page, by
-default) and returns four `MotionValue`s (`scrollX`, `scrollY`,
-`scrollXProgress`, `scrollYProgress`) as raw pixel offsets and normalized 0-1
-progress, `createVelocity(source)` derives another `MotionValue`'s rate of
-change, decaying to zero once its source goes stale rather than freezing at the
-last reading, and `createTime()` is the animation frame loop's own clock.
-
-`createAnimate` and `createWillChange` are the imperative primitives for an
-element this package does not render itself, such as a chart library's own
-nodes. `createAnimate` returns `[scope, animate]`: `scope` is a callback-plus-
-signal ref, matching this package's own ref convention rather than motion-dom's
-`AnimationScope`, and `animate` targets the scope's own element, a descendant,
-an explicit element or array, or a sequence of `[target, definition]` steps
-played one after another. `createWillChange` builds a shared `will-change` value
-that several independent animations can each add a hint to.
-
-## Development
-
-```sh
-bun install
-bun run check
-```
+The [manual](docs/README.md) has one page per part of the API. Start with
+[Animating elements](docs/animating-elements.md).
-`check` runs the type check, lint, format check, tests (vitest on jsdom, with a
-fake animation clock) and the declaration build. `bun run format` fixes
-formatting.
+To work on the package, see [contributing](.github/contributing.md).
## License
-[MIT](LICENSE). The animation engine is `motion-dom`, which is MIT licensed by
-its authors.
+[MIT](LICENSE)
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..3d39d63
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,22 @@
+# Manual
+
+Each page has an example you can run. Start with the first one.
+
+1. [Animating elements](animating-elements.md): `motion.*`, targets, layers,
+ callbacks.
+2. [Gestures](gestures.md): hover, press, focus and in-view states, and
+ `createInView`.
+3. [Variants](variants.md): named targets, stagger and ordering.
+4. [Presence](presence.md): exit animations with `AnimatePresence` and
+ `AnimatePresenceList`.
+5. [Motion values](motion-values.md): `createMotionValue`, `transform`,
+ `createVelocity`, `createTime` and `createWillChange`.
+6. [Scroll](scroll.md): `createScroll`.
+7. [Layout](layout.md): `layout` and `layoutId`.
+8. [SVG](svg.md): geometry and path drawing.
+9. [Configuration](configuration.md): `MotionConfig`, reduced motion and
+ `useReducedMotion`.
+10. [createMotion and custom components](create-motion.md): animate an element
+ you render yourself.
+11. [createAnimate](create-animate.md): animate elements you do not render.
+12. [Architecture](architecture.md): what each source file does.
diff --git a/docs/animating-elements.md b/docs/animating-elements.md
new file mode 100644
index 0000000..f2a94ec
--- /dev/null
+++ b/docs/animating-elements.md
@@ -0,0 +1,131 @@
+# Animating elements
+
+`motion.div` is a `div` that animates. Any intrinsic tag works: `motion.button`,
+`motion.li`, `motion.path`. Props that motion does not know, such as `class`,
+`onClick` and `children`, go to the element.
+
+```tsx
+import { createSignal } from "solid-js";
+import { motion } from "@totallynotdavid/solid-motion";
+
+function Panel() {
+ const [wide, setWide] = createSignal(false);
+
+ return (
+ setWide((value) => !value)}
+ >
+ Click to resize
+
+ );
+}
+```
+
+The element is painted at `initial`, then animates to `animate`. `animate` is
+read in a tracking scope, so a signal inside it re-runs the animation. Only the
+values that changed start a new animation.
+
+## Props
+
+| Prop | Meaning |
+| ------------------------------------------------------- | --------------------------------------------------------------------------- |
+| `initial` | Where the element starts. `false` starts it at `animate`. |
+| `animate` | The target while the element is present. |
+| `exit` | The target while a [presence](presence.md) boundary removes the element. |
+| `transition` | How to get to a target. Falls back to the `transition` of a `MotionConfig`. |
+| `whileHover`, `whilePress`, `whileFocus`, `whileInView` | Targets that apply while a [gesture](gestures.md) is active. |
+| `variants`, `custom` | Named targets. See [variants](variants.md). |
+| `style` | CSS, where an entry may also be a [motion value](motion-values.md). |
+| `layout`, `layoutId` | See [layout](layout.md). |
+| `onAnimationStart`, `onAnimationComplete` | Called with the definition that was animated. |
+| `onUpdate` | Called on every frame with the latest values. |
+
+`initial` is read once, because it describes the first paint. Every other prop
+is read again when its signals change.
+
+## Targets
+
+A target maps a property to its end value.
+
+```tsx
+
+```
+
+- An array is a list of keyframes. `null` in the first slot starts from the
+ current value.
+- `x`, `y`, `scale`, `rotate` and the other transform shorthands are composed
+ into one `transform`.
+- `height`, `width` and similar properties can go between units, for example `0`
+ to `"auto"`. The element is measured and the two numbers are animated. This
+ works for HTML elements. SVG uses [attributes](svg.md).
+- `transition` inside a target applies to that target only.
+- `transitionEnd` is applied when the animation finishes.
+
+A target can also be a variant label or a list of labels. See
+[variants](variants.md).
+
+## Layers
+
+An element can be in several states at once. Each state has a target, and the
+higher state wins for the keys it sets. From lowest to highest:
+
+1. `animate`
+2. `whileInView`
+3. `whileFocus`
+4. `whileHover`
+5. `whilePress`
+6. `exit`
+
+When a state ends, the keys it set return to the next layer down. A key that no
+layer sets goes back to the value it had before it was first animated. An `exit`
+target sets only the keys it names. Other keys keep their `animate` value while
+the element leaves.
+
+## Skipping the entrance
+
+`initial={false}` paints the element at its `animate` target. There is nothing
+left to animate on mount, and the element is not touched until a later change.
+
+```tsx
+
+```
+
+An enclosing [`AnimatePresence`](presence.md) can set `initial={false}` for
+every element inside it.
+
+## Callbacks
+
+```tsx
+ console.log("start", definition)}
+ onAnimationComplete={(definition) => console.log("done", definition)}
+ onUpdate={(latest) => console.log(latest.x)}
+/>
+```
+
+`definition` is the value you passed to `animate` (or `exit`): a target, a
+label, or a list of labels. When a pass changes nothing, for example on mount
+with `initial={false}`, only `onAnimationComplete` is called.
+
+## Transitions
+
+`transition` is a `Transition` from `motion-dom`. It takes `duration`, `ease`,
+`delay`, `type: "spring"` with `stiffness` and `damping`, and the other options
+in the [Motion documentation](https://motion.dev/docs/react-transitions).
+
+## Custom components
+
+`motion.create(Component)` returns an animated version of your own component.
+See [createMotion](create-motion.md#custom-components).
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..d601ad0
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,71 @@
+# Architecture
+
+The package starts `motion-dom` animations directly, one per value. Solid
+signals decide what the target is. Motion's `VisualElement`, with its props
+model and variant tree, is not used. Two small adapters in `src/values.ts` and
+`src/projection.ts` give `motion-dom` the few members it reads from one.
+
+## How an animation runs
+
+```text
+motion.div / createMotion reads the options, owns the element's lifecycle
+ resolve.ts, target.ts options -> one merged target
+ controller.ts diffs the target, starts animations
+ values.ts one MotionValue per property, bound to the element
+ projection.ts layout and layoutId, through motion-dom projection
+ layout-updates.ts finds DOM changes that move a layout element
+```
+
+1. `createMotion` resolves each layer (`animate`, the `while*` props, `exit`)
+ against `variants`.
+2. `mergeLayers` combines the active layers. The highest layer wins for each
+ key.
+3. The controller compares the merged target with the values it applied last. It
+ starts an animation only for the keys that changed.
+4. `values.ts` creates a `MotionValue` for each key and binds it to the element.
+ `motion-dom` composes the transform and writes the style or attributes.
+
+The first paint does not wait for this. `initial.ts` turns the initial target
+into inline style and attributes. It uses no DOM, so the server and the client
+produce the same markup.
+
+## Files
+
+| File | Owns |
+| --------------------------- | --------------------------------------------------------------------- |
+| `src/index.tsx` | The public exports. |
+| `src/types.ts` | Public types: `MotionOptions`, `MotionProps`, `VariantMap`. |
+| `src/motion.tsx` | `motion.*` and `motion.create`: `createMotion` plus a component. |
+| `src/create-motion.ts` | `createMotion`. Wires every other module for one element. |
+| `src/resolve.ts` | Variant labels and arrays to a target. The target of the first paint. |
+| `src/target.ts` | Merging layers. The reduced-motion rule. |
+| `src/initial.ts` | Target to inline style and attributes. |
+| `src/svg.ts` | Which tags are SVG. Attribute names. |
+| `src/controller.ts` | One element's passes: diff, start, complete, supersede. |
+| `src/values.ts` | Per-element `MotionValue` store, shared with `createAnimate`. |
+| `src/motion-values.ts` | `createMotionValue`, `createVelocity`, `createTime`, style binding. |
+| `src/gestures.ts` | Hover, press, focus and in-view state. `createInView`. |
+| `src/variants.ts` | Label inheritance, stagger delays, `when` ordering. |
+| `src/presence.tsx` | `AnimatePresence`, the hold protocol, `usePresence`. |
+| `src/presence-list.tsx` | `AnimatePresenceList`. |
+| `src/config.tsx` | `MotionConfig`, `useMotionConfig`. |
+| `src/reduced-motion.ts` | `useReducedMotion`. |
+| `src/scroll.ts` | `createScroll`. |
+| `src/create-animate.ts` | `createAnimate`: selectors, sequences, playback controls. |
+| `src/create-will-change.ts` | `createWillChange`. |
+| `src/projection.ts` | Adapts a `motion-dom` projection node to an element. |
+| `src/layout-updates.ts` | Creates layout nodes, and watches DOM changes that move them. |
+
+## Boundaries
+
+- Presence works by holds, not promises. A cancelled `motion-dom` animation
+ never settles its `finished` promise, so each leaving element takes a hold and
+ releases it on every path. See `PresenceChild` in `src/presence.tsx`.
+- Animations start inside `frame.update`, so they read the frame's clock.
+- `createMotion` and `createAnimate` share one value store per element, so they
+ never bind two `MotionValue`s to the same property.
+
+## Tests
+
+`tests/` runs on jsdom with a fake clock (`tests/clock.ts`). jsdom does no
+layout, so `tests/layout.test.tsx` supplies element boxes.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..8356a51
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,66 @@
+# Configuration
+
+`MotionConfig` sets defaults for every motion element inside it.
+
+```tsx
+import { MotionConfig, motion } from "@totallynotdavid/solid-motion";
+
+
+
+;
+```
+
+| Prop | Default | Meaning |
+| ---------------- | --------- | --------------------------------------------------------- |
+| `transition` | none | The transition for any element that sets none of its own. |
+| `reducedMotion` | `"never"` | `"never"`, `"always"` or `"user"`. See below. |
+| `skipAnimations` | `false` | Jump every animation to its target. |
+
+A `MotionConfig` inside another one overrides the props it sets and inherits the
+rest. `useMotionConfig()` returns the current values.
+
+The settings also apply to animations started with
+[`createAnimate`](create-animate.md).
+
+## Reduced motion
+
+`reducedMotion` decides what happens for people who ask their system for less
+motion.
+
+- `"never"`: animate normally.
+- `"always"`: reduce motion for everyone.
+- `"user"`: reduce motion when the `prefers-reduced-motion: reduce` media query
+ matches.
+
+Reduced motion does not stop every animation. Properties that move or resize an
+element jump to their target: `x`, `y`, `scale`, `rotate` and the other
+transform properties, `width`, `height`, `top`, `left`, `right` and `bottom`,
+and layout animations. Other properties, such as `opacity` and colors, still
+animate.
+
+## skipAnimations
+
+`skipAnimations` removes all animation. Every value jumps to its target, even if
+the element or target sets its own `transition`.
+
+```tsx
+
+
+
+```
+
+## useReducedMotion
+
+`useReducedMotion()` returns an accessor that is `true` while the
+`prefers-reduced-motion: reduce` query matches. Use it for your own logic. The
+package listens to the query once, not once per element. On the server it is
+`false`.
+
+```tsx
+import { useReducedMotion } from "@totallynotdavid/solid-motion";
+
+function Hero() {
+ const reduced = useReducedMotion();
+ return
{reduced() ? "Motion is reduced" : "Motion is on"}
;
+}
+```
diff --git a/docs/create-animate.md b/docs/create-animate.md
new file mode 100644
index 0000000..274acbe
--- /dev/null
+++ b/docs/create-animate.md
@@ -0,0 +1,75 @@
+# createAnimate
+
+`createAnimate` animates elements that you do not render with `motion.*`: nodes
+that a chart library creates, or elements that you pick by selector.
+
+```tsx
+import { createAnimate } from "@totallynotdavid/solid-motion";
+
+function Toolbar() {
+ const [scope, animate] = createAnimate();
+
+ return (
+
+
+
+
+ );
+}
+```
+
+`createAnimate()` returns `[scope, animate]`.
+
+- `scope` is a ref. Put it on the root element. `scope.current` is that element,
+ or `undefined` before it mounts.
+- `animate(target, definition)` animates the target and returns the playback
+ controls.
+
+`target` is an element, an array of elements, or a CSS selector. A selector is
+searched inside `scope.current`. Before the scope mounts, a selector matches
+nothing.
+
+`definition` is a target as in `animate={...}`: values, an optional `transition`
+and an optional `transitionEnd`.
+
+## Controls
+
+The return value of `animate` has `stop()`, `pause()`, `play()`, `complete()`,
+`cancel()`, `time`, `speed`, `duration`, `state` and `finished`. It is also
+awaitable.
+
+```tsx
+await animate(".dot", { opacity: 0 });
+```
+
+`finished` also settles when you call `stop()`. A call that you make before the
+first frame runs, such as `pause()`, is applied once the animation starts.
+
+## Sequences
+
+Pass a list of `[target, definition]` steps to run them one after another. Each
+step starts when the one before it ends.
+
+```tsx
+await animate([
+ [".title", { opacity: 1 }],
+ [".body", { opacity: 1 }],
+]);
+```
+
+## Rules
+
+- Animations stop when the owner of `createAnimate` is cleaned up.
+- `MotionConfig` applies: its `transition` is the default, `skipAnimations`
+ jumps to the target, and `reducedMotion` makes movement jump.
+- If a `motion.*` element and `animate` target the same property of one element,
+ the later call takes over, and the earlier one settles.
diff --git a/docs/create-motion.md b/docs/create-motion.md
new file mode 100644
index 0000000..be899a8
--- /dev/null
+++ b/docs/create-motion.md
@@ -0,0 +1,108 @@
+# createMotion and custom components
+
+`createMotion` is the primitive behind `motion.div`. It animates an element that
+you render yourself.
+
+```tsx
+import { createSignal } from "solid-js";
+import { createMotion } from "@totallynotdavid/solid-motion";
+
+function CollapsibleRow() {
+ const [open, setOpen] = createSignal(false);
+
+ const handle = createMotion(() => ({
+ initial: false,
+ animate: { height: open() ? "auto" : 0 },
+ transition: { duration: 0.2 },
+ }));
+
+ return (
+
setOpen((value) => !value)}
+ />
+ );
+}
+```
+
+`createMotion(options, tag?)` takes a function that returns the same options as
+`motion.div` takes as props: `initial`, `animate`, `exit`, `transition`,
+`variants`, `custom`, the `while*` props, `viewport`, `layout`, `layoutId`,
+`style` and the callbacks. It returns a handle:
+
+| Field | Meaning |
+| ------- | ------------------------------------------------------------------------ |
+| `ref` | Attach it to the element. Animation starts when the ref runs. |
+| `style` | The inline style the element must be born with. |
+| `attrs` | The attributes the element must be born with. Empty unless `tag` is SVG. |
+
+Apply `style` and `attrs` to the element. Together they are the first paint, on
+the server and on the client. If you also set a `style`, merge the handle's
+style over yours so the motion target wins.
+
+```tsx
+
+```
+
+`tag` is the name of the element you render. Pass it for an SVG tag, so the
+first paint uses attributes, and spread `attrs`:
+
+```tsx
+const handle = createMotion(
+ () => ({ initial: { x2: 0 }, animate: { x2: 100 } }),
+ "line",
+);
+
+;
+```
+
+## Why use it
+
+`motion.div` adds a component and spreads all props at runtime. With
+`createMotion` your own element keeps Solid's compiled setters for `class`,
+`onClick` and the rest. The difference matters for a leaf element in a long
+list. Elsewhere, `motion.div` is shorter.
+
+An element made with `createMotion` reads variant names from the motion element
+above it, but it cannot pass names to its own children. Only `motion.*` does
+that.
+
+`createMotion` types `custom` and the variant functions in `variants`
+together.
+
+## Custom components
+
+`motion.create(Component)` returns an animated version of your own component. It
+takes the same props as `motion.div`, plus the props of your component.
+
+```tsx
+import type { JSX } from "@solidjs/web";
+import { motion } from "@totallynotdavid/solid-motion";
+
+const Card = (props: {
+ title: string;
+ style?: JSX.CSSProperties;
+ ref?: JSX.Ref;
+}) => (
+
+ {props.title}
+
+);
+
+const MotionCard = motion.create(Card);
+
+;
+```
+
+The wrapper passes `style` and `ref` to your component. Forward both to the node
+that should animate.
+
+- Without `ref`, the element never animates.
+- Without `style`, the element is not painted at `initial`. It animates from its
+ CSS value. With `initial={false}`, it stays at its CSS value until a later
+ change.
+
+The first paint of a custom component is always HTML style, even if it renders
+an SVG element.
diff --git a/docs/gestures.md b/docs/gestures.md
new file mode 100644
index 0000000..76df7d8
--- /dev/null
+++ b/docs/gestures.md
@@ -0,0 +1,82 @@
+# Gestures
+
+`whileHover`, `whilePress`, `whileFocus` and `whileInView` each take a target.
+The target applies while the state is active. When the state ends, the element
+animates back. See [layers](animating-elements.md#layers) for how states
+combine.
+
+```tsx
+import { motion } from "@totallynotdavid/solid-motion";
+
+
+ Save
+;
+```
+
+| Prop | Active while |
+| ------------- | ------------------------------------------------------------- |
+| `whileHover` | A pointer that is not a touch is over the element. |
+| `whilePress` | The primary pointer button is down on it, or Enter is held. |
+| `whileFocus` | The element has focus and the browser would draw a focus ring |
+| `whileInView` | Part of the element is inside the viewport. |
+
+Listeners are added only for the props you set. An element without `whileHover`
+has no hover listeners.
+
+`whileHover` ignores touch events, and it waits for a press to end before it
+ends, so a button does not flicker when the pointer leaves during a click.
+`whilePress` ignores secondary mouse buttons and every touch after the first.
+
+`whileFocus` uses `:focus-visible`. A mouse click on a button does not trigger
+it. Keyboard focus does. A browser that does not support `:focus-visible` treats
+every focus as visible.
+
+Hover and press come from `motion-dom`. In-view uses `IntersectionObserver`, and
+the element is never in view where the browser has none.
+
+## In view
+
+```tsx
+
+```
+
+`viewport` tunes what counts as in view:
+
+| Option | Type | Default | Meaning |
+| -------- | --------------------------- | -------- | ----------------------------------------------------------- |
+| `once` | `boolean` | `false` | Stay in view after the first entry and stop observing. |
+| `root` | `Element \| Document` | viewport | The scrolling ancestor to measure against. |
+| `margin` | `string` | none | Grows or shrinks the detection box, in CSS margin syntax. |
+| `amount` | `"some" \| "all" \| number` | `"some"` | How much of the element must show. A number is from 0 to 1. |
+
+## createInView
+
+`createInView(element, viewport?)` returns a boolean accessor with the same
+observer and no animation. Use it for a lazy mount, an infinite-scroll sentinel,
+or an impression count.
+
+```tsx
+import { createSignal } from "solid-js";
+import { createInView } from "@totallynotdavid/solid-motion";
+
+function LazySection() {
+ const [node, setNode] = createSignal();
+ const inView = createInView(node, { once: true });
+
+ return {inView() ? "Loaded" : "Waiting"};
+}
+```
+
+The first argument is an accessor, because the element exists only after the ref
+runs. Call `createInView` in a component or another reactive owner, so the
+observer is disconnected on cleanup.
diff --git a/docs/layout.md b/docs/layout.md
new file mode 100644
index 0000000..f48fb56
--- /dev/null
+++ b/docs/layout.md
@@ -0,0 +1,115 @@
+# Layout
+
+Add `layout` to an element and it animates to its new box when a render moves or
+resizes it. You change the DOM or the CSS. The element measures itself before
+and after, and animates the difference.
+
+```tsx
+import { createSignal } from "solid-js";
+import { motion } from "@totallynotdavid/solid-motion";
+
+function Switch() {
+ const [on, setOn] = createSignal(false);
+
+ return (
+
setOn((value) => !value)}
+ >
+
+
+ );
+}
+```
+
+```css
+.track {
+ display: flex;
+ width: 60px;
+ padding: 4px;
+ background: #ccc;
+}
+.track.on {
+ justify-content: flex-end;
+}
+.knob {
+ width: 24px;
+ height: 24px;
+ background: white;
+ border-radius: 50%;
+}
+```
+
+The `.on` class moves the knob to the other end. The knob slides there instead
+of jumping.
+
+## layout
+
+| Value | Animates |
+| ------------------- | ------------------------------------------------------- |
+| `true` | Position and size. |
+| `"position"` | Position only. |
+| `"size"` | Size only. |
+| `"x"` or `"y"` | That axis only. |
+| `"preserve-aspect"` | Position only when the aspect ratio changes, else both. |
+
+`transition` sets the timing. To give the layout its own timing, add a `layout`
+key to the transition:
+
+```tsx
+
+```
+
+Layout works on HTML elements.
+
+An element is re-measured after a DOM change that can move it. These count:
+adding or removing nodes, changing text, and changing `class`, `hidden`, a
+`data-*` attribute or the inline `style` of the element, an ancestor or a
+descendant. A move with another cause, such as a window resize or a media query,
+is not animated.
+
+## layoutId
+
+Give two elements the same `layoutId`. When one replaces the other, the new
+element animates from the box the old one had, and the two crossfade.
+
+```tsx
+import { createSignal, For } from "solid-js";
+import { motion } from "@totallynotdavid/solid-motion";
+
+const tabs = ["Home", "Docs", "Blog"];
+
+function Tabs() {
+ const [active, setActive] = createSignal("Home");
+
+ return (
+
+ );
+}
+```
+
+The underline moves from one tab to the next. `layoutId` also works across an
+[`AnimatePresence`](presence.md) boundary, where the old element is still
+exiting while the new one enters.
+
+`layoutId` alone turns layout animation on. You do not need `layout` as well.
+
+## Reduced motion
+
+Under `reducedMotion` and `skipAnimations` (see
+[configuration](configuration.md)), a layout change is applied at once.
diff --git a/docs/motion-values.md b/docs/motion-values.md
new file mode 100644
index 0000000..b87611b
--- /dev/null
+++ b/docs/motion-values.md
@@ -0,0 +1,122 @@
+# Motion values
+
+A motion value holds one number or string and writes it to the element on the
+animation frame. Nothing re-renders when it changes.
+
+```tsx
+import { createSignal } from "solid-js";
+import { createMotionValue, motion } from "@totallynotdavid/solid-motion";
+
+function Ball() {
+ const [targetX, setTargetX] = createSignal(0);
+
+ // Follows targetX with a spring.
+ const x = createMotionValue(targetX, { stiffness: 300, damping: 30 });
+
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+## createMotionValue
+
+`createMotionValue(source, transition?)` returns a `MotionValue`.
+
+`source` is one of:
+
+- a constant: the initial value.
+- a Solid accessor: the value follows it.
+- another `MotionValue`: the value follows that.
+
+Without a `transition`, the value jumps to every new source value. With one, the
+value moves there with that transition. A change that arrives mid-flight starts
+from the current position and velocity.
+
+Read the current value with `get()`. The value is destroyed when the owner that
+created it is cleaned up, so call `createMotionValue` in a component or another
+reactive owner.
+
+## In `style`
+
+An entry of `style` can be a `MotionValue` or an accessor. Both are bound to the
+element and written on the frame. Other entries are plain CSS and stay reactive
+through Solid.
+
+```tsx
+ 1 - progress(), color: color() }} />
+```
+
+- Names like `x`, `scale` and `rotate` are composed into one `transform`.
+- Which keys are bound is fixed when the element is created. The values stay
+ live.
+- If `animate`, `whileHover` or another target names a bound key, it animates
+ the value you passed in, so `x.get()` reads the current position.
+- An accessor has no value you can read back. The package makes a private one.
+
+## Derived values
+
+A value derived from signals is an accessor. Solid has no `useTransform`.
+
+`transform(inputRange, outputRange, options?)` maps one range to another. It is
+re-exported from `motion-dom`.
+
+```tsx
+import { createSignal } from "solid-js";
+import { motion, transform } from "@totallynotdavid/solid-motion";
+
+const [position, setPosition] = createSignal(100);
+const fade = transform([0, 200], [0, 1]);
+
+ fade(position()) }} />;
+```
+
+## createVelocity
+
+`createVelocity(source)` returns a value that holds the rate of change of a
+numeric motion value. It falls to zero when the source stops changing.
+
+```tsx
+import {
+ createMotionValue,
+ createVelocity,
+} from "@totallynotdavid/solid-motion";
+
+const x = createMotionValue(0);
+const speed = createVelocity(x);
+```
+
+## createTime
+
+`createTime()` returns a value with the milliseconds since its first animation
+frame. It updates on every frame.
+
+```tsx
+import { createTime, motion } from "@totallynotdavid/solid-motion";
+
+function Slider() {
+ // Moves one pixel per millisecond.
+ const time = createTime();
+ return ;
+}
+```
+
+## createWillChange
+
+`createWillChange()` returns a `will-change` value. Call `add(property)` and it
+adds the property to the list, once.
+
+```tsx
+import { createWillChange, motion } from "@totallynotdavid/solid-motion";
+
+function Card() {
+ const willChange = createWillChange();
+ willChange.add("transform");
+ willChange.add("opacity");
+
+ return ;
+}
+```
diff --git a/docs/presence.md b/docs/presence.md
new file mode 100644
index 0000000..1c70983
--- /dev/null
+++ b/docs/presence.md
@@ -0,0 +1,142 @@
+# Presence
+
+A presence boundary keeps a subtree mounted while its `exit` animation plays.
+The subtree unmounts when every motion element inside it has finished leaving.
+
+There are two boundaries. `AnimatePresence` shows one subtree at a time.
+`AnimatePresenceList` shows a keyed list.
+
+## AnimatePresence
+
+```tsx
+import { createSignal } from "solid-js";
+import { AnimatePresence, motion } from "@totallynotdavid/solid-motion";
+
+function Modal() {
+ const [open, setOpen] = createSignal(false);
+
+ return (
+ <>
+
+
+ {() => (
+
+ Hello
+
+ )}
+
+ >
+ );
+}
+```
+
+Pass what is on screen as `when`, not as a conditional child. Solid removes a
+conditional branch the moment its condition changes, before the boundary can
+hold it. The boundary builds the subtree itself so it can keep it.
+
+`when` is also the identity of the subtree:
+
+- A falsy value renders nothing.
+- Changing to a different truthy value swaps the subtree. The old one exits
+ while the new one enters.
+- Changing back to a value that is still exiting brings that subtree back
+ instead of building a second one.
+
+The children are a function. It receives the truthy `when` value.
+
+```tsx
+
+ {(id) => {id}}
+
+```
+
+| Prop | Meaning |
+| ---------------- | -------------------------------------------------------------- |
+| `when` | What is on screen. Also the key. |
+| `initial` | `false` skips the entrance of everything the boundary renders. |
+| `custom` | Passed to the variant functions of the elements inside. |
+| `mode` | `"sync"` (default) or `"wait"`. See below. |
+| `onExitComplete` | Called when no subtree is still leaving. |
+
+`mode="wait"` holds a new subtree back until the old one has gone.
+
+## AnimatePresenceList
+
+```tsx
+import { AnimatePresenceList, motion } from "@totallynotdavid/solid-motion";
+
+ item.id}>
+ {(item) => (
+
+ {item().label}
+
+ )}
+;
+```
+
+`getKey` returns a string. Give each item a unique key. If two items share a
+key, the first one is used and the others are skipped with a `console.error`.
+
+The children function receives an accessor. Call `item()` so a row that stays
+picks up new data without being rebuilt.
+
+A removed row stays at its old position until its exit ends. It does not drop to
+the end of the list.
+
+`AnimatePresenceList` takes the same `initial`, `custom`, `mode` and
+`onExitComplete` props. With `mode="wait"`, only the rows that are leaving are
+rendered until the last one has gone. The other rows are removed and created
+again after that, so use `"wait"` for a list that shows one row at a time.
+
+## How an exit finishes
+
+Each motion element inside a boundary takes a hold when it starts to leave and
+releases it when its exit ends or is cancelled. The boundary removes the subtree
+when the last hold is released. An element with no `exit` releases at once.
+
+## Nested boundaries
+
+A boundary covers the elements between it and the next boundary below. If an
+outer subtree leaves while an inner boundary inside it is still animating out,
+the outer boundary does not wait for the inner one, and the inner exit is cut
+short. Put the boundary at the level that has to wait.
+
+## usePresence
+
+`usePresence()` returns the boundary around the current component, or `null` if
+there is none. You rarely need it. The object has:
+
+- `isPresent()`: `false` while the subtree is leaving.
+- `hold()`: takes a hold and returns the function that releases it. Release it
+ on every path, including when the exit is interrupted.
+- `initial()` and `custom()`: the boundary's props.
+
+This component keeps its boundary open for 500 ms after it starts to leave:
+
+```tsx
+import { createEffect } from "solid-js";
+import { usePresence } from "@totallynotdavid/solid-motion";
+
+function Toast() {
+ const presence = usePresence();
+
+ createEffect(
+ () => presence?.isPresent(),
+ (present) => {
+ if (present !== false) return;
+ const release = presence!.hold();
+ setTimeout(release, 500);
+ },
+ );
+
+ return
Saved
;
+}
+```
diff --git a/docs/scroll.md b/docs/scroll.md
new file mode 100644
index 0000000..b8f922a
--- /dev/null
+++ b/docs/scroll.md
@@ -0,0 +1,85 @@
+# Scroll
+
+`createScroll()` returns motion values that follow a scroll position. Bind them
+to `style` to drive an element from scrolling.
+
+```tsx
+import { createScroll, motion } from "@totallynotdavid/solid-motion";
+
+function ProgressBar() {
+ const { scrollYProgress } = createScroll();
+
+ return (
+
+ );
+}
+```
+
+It returns four `MotionValue`s:
+
+| Value | Holds |
+| ----------------- | ------------------------------------ |
+| `scrollX` | Horizontal scroll offset, in pixels. |
+| `scrollY` | Vertical scroll offset, in pixels. |
+| `scrollXProgress` | Horizontal progress, from 0 to 1. |
+| `scrollYProgress` | Vertical progress, from 0 to 1. |
+
+The values are measured on scroll and on resize, and are destroyed with the
+owner. Call `createScroll` in a component or another reactive owner. On the
+server there is nothing to measure and the values stay at 0.
+
+## Options
+
+`createScroll({ container, target, offset, trackContentSize })`
+
+| Option | Default | Meaning |
+| ------------------ | ------------------ | --------------------------------------------------------------- |
+| `container` | The page | An accessor for the element that scrolls. |
+| `target` | The container | An accessor for the element whose position is tracked. |
+| `offset` | `[[0, 0], [1, 1]]` | When progress is 0 and when it is 1. |
+| `trackContentSize` | `false` | Check the scroll size on every frame, to catch growing content. |
+
+`container` and `target` are accessors, because a ref is set after the component
+runs. Until they return an element, nothing is measured.
+
+```tsx
+import { createSignal } from "solid-js";
+import { createScroll, motion } from "@totallynotdavid/solid-motion";
+
+function Panel() {
+ const [box, setBox] = createSignal();
+ const { scrollYProgress } = createScroll({ container: box });
+
+ return (
+
+ Fades in
+
+
+ );
+}
+```
+
+`offset` is two points, `[start, end]`. Each point is `[target, container]`, a
+position along each element from 0 (its start) to 1 (its end). Progress is 0
+when the first point's target position meets its container position, and 1 for
+the second point. The default `[[0, 0], [1, 1]]` is 0 when the target's start
+meets the container's start, and 1 when their ends meet. For the page, that is 0
+at the top and 1 at the bottom.
+
+Set `trackContentSize` when the content of a fixed-height container grows after
+the first measurement, for example when images load. A container that resizes is
+measured without it.
+
+Offsets are measured with `offsetTop` and `offsetLeft`, so a CSS transform on
+the target or on an ancestor is not counted.
diff --git a/docs/svg.md b/docs/svg.md
new file mode 100644
index 0000000..a095df7
--- /dev/null
+++ b/docs/svg.md
@@ -0,0 +1,57 @@
+# SVG
+
+Use `motion.line`, `motion.path`, `motion.circle` and the other SVG tags the
+same way as `motion.div`. Geometry such as `x1`, `x2` and `viewBox` is written
+as attributes.
+
+```tsx
+import { motion } from "@totallynotdavid/solid-motion";
+
+;
+```
+
+The first paint puts `x2="0"` on the element, in the server HTML as well as on
+the client.
+
+## Drawing a path
+
+`pathLength` animates from 0 to 1 and draws the stroke. The package sets the
+`pathLength` attribute to 1 and animates `stroke-dasharray` for you.
+
+```tsx
+
+```
+
+## Style and attributes
+
+CSS properties such as `opacity`, `transform` and `fill` go through `style`, as
+on HTML elements.
+
+The tags that paint their first frame as SVG attributes are: `animate`,
+`circle`, `defs`, `desc`, `ellipse`, `filter`, `g`, `image`, `line`, `marker`,
+`mask`, `metadata`, `path`, `pattern`, `polygon`, `polyline`, `rect`, `stop`,
+`svg`, `switch`, `symbol`, `text`, `tspan`, `use` and `view`. For any other tag,
+such as `foreignObject`, `clipPath` or `linearGradient`, the initial target is
+painted as inline style instead.
+
+`motion.create(Component)` always paints as HTML, because only a tag name says
+what an element will be.
+
+Sizes that go between units, such as `"auto"`, are measured for HTML elements
+only.
diff --git a/docs/variants.md b/docs/variants.md
new file mode 100644
index 0000000..e473935
--- /dev/null
+++ b/docs/variants.md
@@ -0,0 +1,127 @@
+# Variants
+
+A variant is a named target. Define a `variants` map, then use its names where a
+target goes: in `initial`, `animate`, `exit` and the `while*` props.
+
+```tsx
+import { motion, type VariantMap } from "@totallynotdavid/solid-motion";
+
+const variants: VariantMap = {
+ hidden: { opacity: 0 },
+ visible: (custom) => ({
+ opacity: 1,
+ transition: { delay: (custom as number) * 0.05 },
+ }),
+};
+
+;
+```
+
+A variant is a target, or a function that receives `custom` and returns one. A
+name that is not in the map is ignored.
+
+A list of names, `animate={["hidden", "visible"]}`, is merged from left to
+right. The later name wins for each key. A list holds names only, not inline
+targets.
+
+## Passing names down
+
+An element that names a variant offers that name to the motion elements inside
+it. A child with its own `variants` map and no `animate` prop uses the parent's
+name.
+
+```tsx
+const list = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1 },
+};
+const item = {
+ hidden: { opacity: 0, x: -20 },
+ visible: { opacity: 1, x: 0 },
+};
+
+
+
+
+;
+```
+
+The children have no `initial` or `animate`. They read `"hidden"` and
+`"visible"` from the `ul`, and look them up in `item`.
+
+- A prop on the child wins over the name it would inherit.
+- An inline target is never inherited. Only names are, because a name means
+ something only next to its own `variants` map.
+- `custom` is inherited the same way. An element uses its own `custom`, then the
+ nearest parent's, then the one from an enclosing
+ [`AnimatePresence`](presence.md).
+
+## Stagger
+
+Put the timing on the parent's `transition`.
+
+```tsx
+
+
+
+
+
+```
+
+| Option | Meaning |
+| ------------------ | -------------------------------------------------------------- |
+| `delayChildren` | Seconds added to every child's start. A number or `stagger()`. |
+| `staggerChildren` | Seconds added per position. The first child adds none. |
+| `staggerDirection` | `1` starts with the first child. `-1` starts with the last. |
+| `when` | `"beforeChildren"` or `"afterChildren"`. See below. |
+
+The position of a child is its place in document order among the parent's motion
+children, read when the delay is needed. A keyed list that reorders rows keeps
+the right order.
+
+`delayChildren` also takes `stagger()`, which is re-exported from `motion-dom`.
+A function replaces `staggerChildren` and `staggerDirection`. The base delay is
+not added to its result.
+
+```tsx
+import { motion, stagger } from "@totallynotdavid/solid-motion";
+
+
+
+
+;
+```
+
+## Ordering a parent and its children
+
+`when` makes the parent and its children take turns.
+
+```tsx
+transition={{ duration: 0.2, when: "beforeChildren" }}
+```
+
+- `"beforeChildren"`: children wait until the parent's own animation ends.
+- `"afterChildren"`: the parent waits until every child's animation ends.
+
+Any other value of `when` is ignored.
+
+## Types
+
+`motion.*` types the argument of a variant function as `unknown`, so
+`custom as number` needs a cast. [`createMotion`](create-motion.md)
+carries the type, and checks `custom` and the map together.
diff --git a/src/motion-values.ts b/src/motion-values.ts
index a08697f..cdf879e 100644
--- a/src/motion-values.ts
+++ b/src/motion-values.ts
@@ -12,6 +12,9 @@ import type { MotionStyle, MotionStyleValue, Transition } from "./types";
export type MotionSource = T | (() => T) | MotionValue;
+/** A literal `0` is a `number` value, not a value that can only be `0`. */
+type Widen = T extends number ? number : string;
+
/**
* Creates a MotionValue from a literal, Solid accessor, or MotionValue.
*
@@ -25,6 +28,10 @@ export type MotionSource = T | (() => T) | MotionValue;
* component unmounting destroys the value and tears down its subscriptions
* with it.
*/
+export function createMotionValue(
+ source: MotionSource,
+ transition?: Transition,
+): MotionValue>;
export function createMotionValue(
source: MotionSource,
transition?: Transition,
diff --git a/src/presence-list.tsx b/src/presence-list.tsx
index 277b349..07c6c11 100644
--- a/src/presence-list.tsx
+++ b/src/presence-list.tsx
@@ -51,7 +51,8 @@ export function AnimatePresenceList(props: AnimatePresenceListProps) {
createEffect(
() => props.each,
(items) => {
- setEntries((current) => diffEntries(current, items, props.getKey));
+ const getKey = untrack(() => props.getKey);
+ setEntries((current) => diffEntries(current, items, getKey));
},
);
@@ -71,7 +72,9 @@ export function AnimatePresenceList(props: AnimatePresenceListProps) {
return current.filter((entry) => entry.key !== key);
});
- if (!entries().some((entry) => !entry.present)) props.onExitComplete?.();
+ if (!untrack(entries).some((entry) => !entry.present)) {
+ props.onExitComplete?.();
+ }
};
return (
@@ -81,7 +84,7 @@ export function AnimatePresenceList(props: AnimatePresenceListProps) {
isPresent={entry().present}
initial={props.initial}
custom={props.custom}
- onExitComplete={() => completeExit(entry().key)}
+ onExitComplete={() => completeExit(untrack(entry).key)}
>
{props.children(() => entry().item)}
diff --git a/src/presence.tsx b/src/presence.tsx
index 51a4baf..48de1ac 100644
--- a/src/presence.tsx
+++ b/src/presence.tsx
@@ -47,7 +47,7 @@ export function PresenceChild(props: PresenceChildProps) {
const leaveIfDone = () => {
if (holds !== 0) return;
// The item can come back inside the same flush that its exit started in.
- if (props.isPresent) return;
+ if (untrack(() => props.isPresent)) return;
props.onExitComplete();
};
diff --git a/src/variants.ts b/src/variants.ts
index f47d224..ccaf514 100644
--- a/src/variants.ts
+++ b/src/variants.ts
@@ -226,7 +226,11 @@ function createSequencer(orchestration: () => Orchestration) {
return turn(
(queued) => {
- if (!selfRunning || orchestration().when !== "beforeChildren") {
+ // `hold` runs when a pass starts, outside any tracking scope.
+ if (
+ !selfRunning ||
+ untrack(orchestration).when !== "beforeChildren"
+ ) {
return false;
}
waitingChildren.push(queued);
@@ -245,7 +249,7 @@ function createSequencer(orchestration: () => Orchestration) {
return turn(
(queued) => {
- if (orchestration().when !== "afterChildren") return false;
+ if (untrack(orchestration).when !== "afterChildren") return false;
// Children run their own effects after this one, so the count is not
// final until the next microtask. That is the same reading the
diff --git a/tests/motion.test.tsx b/tests/motion.test.tsx
index 767b53a..a0a7d33 100644
--- a/tests/motion.test.tsx
+++ b/tests/motion.test.tsx
@@ -10,6 +10,7 @@ import {
createMotion,
createInView,
createMotionValue,
+ createVelocity,
createWillChange,
MotionConfig,
motion,
@@ -282,6 +283,25 @@ describe("AnimatePresenceList", () => {
await advance(20);
expect(container.querySelector('[data-id="one"]')).toBeNull();
});
+
+ it("removes items without a Solid read warning", async () => {
+ // The row leaves from a microtask, outside any tracking scope.
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const [items, setItems] = createSignal([{ id: "one" }, { id: "two" }]);
+ const { container } = render(() => (
+ item.id}>
+ {(item) => }
+
+ ));
+
+ setItems([{ id: "two" }]);
+ await advance(20);
+
+ expect(container.querySelector('[data-id="one"]')).toBeNull();
+ expect(container.querySelector('[data-id="two"]')).toBeTruthy();
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
});
const Theme = createContext("unset");
@@ -289,6 +309,26 @@ const Theme = createContext("unset");
describe("AnimatePresence", () => {
afterEach(() => document.body.replaceChildren());
+ it("removes a subtree with no exit without a Solid read warning", async () => {
+ // The subtree leaves from a microtask, outside any tracking scope.
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const [open, setOpen] = createSignal(true);
+ const { container } = render(() => (
+
+ {() => }
+
+ ));
+ await advance(300);
+
+ setOpen(false);
+ flush();
+ await advance(20);
+
+ expect(container.querySelector(".panel")).toBeNull();
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
it("keeps a conditional subtree mounted while it animates out", async () => {
const [open, setOpen] = createSignal(true);
const { container } = render(() => (
@@ -699,6 +739,21 @@ describe("style values", () => {
expect(element.style.transform).toBe("translateX(25px)");
});
+ it("types a numeric literal source as a number value", () => {
+ const x = createMotionValue(0);
+ const label = createMotionValue("left");
+
+ // `createVelocity` takes a `MotionValue`; a value typed as the
+ // literal `0` would not compile here.
+ const speed = createVelocity(x);
+ x.set(10);
+ label.set("right");
+
+ expect(speed.get()).toBe(0);
+ expect(x.get()).toBe(10);
+ expect(label.get()).toBe("right");
+ });
+
it("animates the caller's own value rather than a copy of it", async () => {
const x = createMotionValue(0);
render(() => (
@@ -1937,6 +1992,36 @@ describe("whileInView", () => {
expect(child.style.opacity).toBe("0");
});
+ it.each(["beforeChildren", "afterChildren"] as const)(
+ "sequences a mount animation with %s without a Solid read warning",
+ async (when) => {
+ // The sequencer decides whether to wait when a pass starts, which is
+ // outside any tracking scope. Solid warns about a reactive read there.
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const variants = { hidden: { opacity: 0 }, visible: { opacity: 1 } };
+
+ const { container } = render(() => (
+
+
+
+ ));
+ await advance(700);
+
+ const [parent, child] = [
+ ...container.querySelectorAll("div"),
+ ] as HTMLElement[];
+ expect(parent.style.opacity).toBe("1");
+ expect(child.style.opacity).toBe("1");
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ },
+ );
+
it("keeps a sequenced exit inside its presence boundary until it is done", async () => {
// The gate lives inside `run`, so a pass waiting its turn is still the
// current pass and still owns the boundary's hold. Anything that gated