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