From d3227c664d70b86b8e44956663ff8594cc024562 Mon Sep 17 00:00:00 2001 From: David <20960328+totallynotdavid@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:43 +0000 Subject: [PATCH 1/5] variants: fix an untracked read when children are sequenced A mount animation with transition.when of beforeChildren or afterChildren made Solid warn that a reactive value was read outside a tracking scope. --- src/variants.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 From 7f3c97837b95aff39ee6b1824b0aed4f39d26695 Mon Sep 17 00:00:00 2001 From: David <20960328+totallynotdavid@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:43 +0000 Subject: [PATCH 2/5] presence: fix untracked reads when a subtree leaves A subtree or row with no exit leaves from a microtask. Reading the presence state there made Solid warn on every removal. --- src/presence-list.tsx | 9 ++++++--- src/presence.tsx | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) 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(); }; From f47502b73a0ba259aade2952a0443f3fc3f343bf Mon Sep 17 00:00:00 2001 From: David <20960328+totallynotdavid@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:43 +0000 Subject: [PATCH 3/5] values: widen a literal source in createMotionValue createMotionValue(0) returned a value typed as the literal 0, so it could only hold 0 and createVelocity rejected it. --- src/motion-values.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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, From 125eb92ef0ddbfc5ea009f72d7f8140acaf9ae3e Mon Sep 17 00:00:00 2001 From: David <20960328+totallynotdavid@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:43 +0000 Subject: [PATCH 4/5] test: cover the read warnings and the value type Two tests spy on console.warn for the sequenced mount animation, and two for removing a subtree and a list row with no exit. One test calls createVelocity on a createMotionValue(0) result, which fails the typecheck without the fix. --- tests/motion.test.tsx | 85 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) 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 From cee02a4513b96c87e4b551b8e99772ddb3846ad0 Mon Sep 17 00:00:00 2001 From: David <20960328+totallynotdavid@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:35:43 +0000 Subject: [PATCH 5/5] docs: rewrite the readme and add a manual A developer can now install and use the package from the readme, and find each part of the API on its own page with an example that was run. The manual is in docs/, one page per part of the API and a short architecture page. The development notes moved to .github/contributing.md. --- .github/contributing.md | 50 ++++ README.md | 592 +++---------------------------------- docs/README.md | 22 ++ docs/animating-elements.md | 131 ++++++++ docs/architecture.md | 71 +++++ docs/configuration.md | 66 +++++ docs/create-animate.md | 75 +++++ docs/create-motion.md | 108 +++++++ docs/gestures.md | 82 +++++ docs/layout.md | 115 +++++++ docs/motion-values.md | 122 ++++++++ docs/presence.md | 142 +++++++++ docs/scroll.md | 85 ++++++ docs/svg.md | 57 ++++ docs/variants.md | 127 ++++++++ 15 files changed, 1290 insertions(+), 555 deletions(-) create mode 100644 .github/contributing.md create mode 100644 docs/README.md create mode 100644 docs/animating-elements.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/create-animate.md create mode 100644 docs/create-motion.md create mode 100644 docs/gestures.md create mode 100644 docs/layout.md create mode 100644 docs/motion-values.md create mode 100644 docs/presence.md create mode 100644 docs/scroll.md create mode 100644 docs/svg.md create mode 100644 docs/variants.md 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.