Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/contributing.md
Original file line number Diff line number Diff line change
@@ -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).
592 changes: 37 additions & 555 deletions README.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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.
131 changes: 131 additions & 0 deletions docs/animating-elements.md
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
initial={{ opacity: 0, x: -40 }}
animate={{ opacity: 1, x: 0, width: wide() ? 300 : 150 }}
transition={{ duration: 0.3 }}
onClick={() => setWide((value) => !value)}
>
Click to resize
</motion.div>
);
}
```

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
<motion.div
animate={{
x: [0, 100, 0],
backgroundColor: "#ff0080",
height: "auto",
transition: { duration: 0.5 },
transitionEnd: { display: "none" },
}}
/>
```

- 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
<motion.div initial={false} animate={{ opacity: open() ? 1 : 0 }} />
```

An enclosing [`AnimatePresence`](presence.md) can set `initial={false}` for
every element inside it.

## Callbacks

```tsx
<motion.div
animate={{ x: 100 }}
onAnimationStart={(definition) => 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).
71 changes: 71 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Configuration

`MotionConfig` sets defaults for every motion element inside it.

```tsx
import { MotionConfig, motion } from "@totallynotdavid/solid-motion";

<MotionConfig reducedMotion="user" transition={{ duration: 0.2 }}>
<motion.div animate={{ opacity: 1 }} />
</MotionConfig>;
```

| 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
<MotionConfig skipAnimations>
<App />
</MotionConfig>
```

## 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 <p>{reduced() ? "Motion is reduced" : "Motion is on"}</p>;
}
```
Loading
Loading