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
12 changes: 6 additions & 6 deletions packages/docs/reference/items/Motion/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Object options are parsed as JSON: quote the keys (`data-option-animate='{ "x":
- Type: `DOMKeyframesDefinition`
- Default: `{}`

Styles applied to the element on mount, before anything plays. Use it to define the starting state of an enter animation without a flash of the final state.
Styles applied to the element on mount, before anything plays. Use it to define the starting state of an enter animation without a flash of the final state. It doubles as the starting point of the [`animate`](#animate) keyframes, so the declared animation always plays from here — however many times it runs.

<!-- prettier-ignore-start -->
```html {3}
Expand All @@ -35,21 +35,21 @@ Styles applied to the element on mount, before anything plays. Use it to define

The target keyframes of the animation. They play on mount when [`autoplay`](#autoplay) is enabled.

A single value per property (`{ "opacity": 1 }`) animates from whatever the element currently shows, so replaying a settled animation moves nothing. Write the keyframes as `[from, to]` arrays (`{ "opacity": [0, 1] }`) to pin the starting point, and every [`play()`](#methods) replays the same motion:
Each property the [`initial`](#initial) option also describes starts from that style, so the declared animation replays identically for as long as the options stand — declare the starting state once, in `initial`:

<!-- prettier-ignore-start -->
```html {4}
```html {3,4}
<div
data-component="Motion"
data-option-initial='{ "opacity": 0 }'
data-option-animate='{ "opacity": [0, 1] }'
data-option-initial='{ "opacity": 0, "y": 24 }'
data-option-animate='{ "opacity": 1, "y": 0 }'
data-option-autoplay>
</div>
```
<!-- prettier-ignore-end -->

Keep [`initial`](#initial) alongside: it paints the starting state before the component mounts, where the keyframes only apply once the animation runs.
A property `initial` says nothing about animates from whatever the element currently shows, which is what a one-off transition to a new state wants. To pin a starting point without painting it on mount, write that property as a `[from, to]` array (`{ "opacity": [0.2, 1] }`) — an explicit array is never overridden.

### `transition`

Expand Down
9 changes: 4 additions & 5 deletions packages/docs/reference/items/Motion/stories/basic/app.twig
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
{# The animation plays once, on mount. A preview boots before you look at it,
so the story pairs it with a replay button. The keyframes are written as
`[from, to]` arrays on purpose: a single target value would animate from
whatever the element currently shows, so a replay of a settled animation
would have nothing to move. #}
so the story pairs it with a replay button: `play()` restarts the animation
from the `initial` styles, which the component folds into the keyframes as
the starting point. #}
<div class="grid justify-items-start gap-4">
<div
id="basic-box"
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 24, "scale": 0.9 }'
data-option-animate='{ "opacity": [0, 1], "y": [24, 0], "scale": [0.9, 1] }'
data-option-animate='{ "opacity": 1, "y": 0, "scale": 1 }'
data-option-transition='{ "type": "spring", "bounce": 0.3, "duration": 0.8 }'
data-option-autoplay
class="rounded-lg bg-blue-400 px-6 py-4 font-bold text-white dark:bg-blue-600">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<span
data-component="Motion"
data-option-initial='{ "opacity": 0, "scale": 0.8 }'
data-option-animate='{ "opacity": [0, 1], "scale": [0.8, 1] }'
data-option-animate='{ "opacity": 1, "scale": 1 }'
data-option-transition='{ "type": "spring", "bounce": 0.5 }'
class="rounded-full bg-emerald-400/20 px-3 py-1 text-sm font-bold text-emerald-700 ring ring-emerald-400 dark:text-emerald-300">
New
Expand All @@ -21,7 +21,7 @@
<h2
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 24 }'
data-option-animate='{ "opacity": [0, 1], "y": [24, 0] }'
data-option-animate='{ "opacity": 1, "y": 0 }'
data-option-transition='{ "type": "spring", "bounce": 0.2 }'
class="text-3xl font-bold">
Animate the whole hero as one timeline
Expand All @@ -30,7 +30,7 @@
<p
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 16 }'
data-option-animate='{ "opacity": [0, 1], "y": [16, 0] }'
data-option-animate='{ "opacity": 1, "y": 0 }'
data-option-at="<"
class="text-current/70">
This paragraph starts with the heading, not after it: <code>"&lt;"</code>
Expand All @@ -41,7 +41,7 @@
type="button"
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 12, "scale": 0.9 }'
data-option-animate='{ "opacity": [0, 1], "y": [12, 0], "scale": [0.9, 1] }'
data-option-animate='{ "opacity": 1, "y": 0, "scale": 1 }'
data-option-transition='{ "type": "spring", "bounce": 0.4 }'
data-option-at="-0.2"
class="rounded bg-blue-600 px-4 py-2 font-semibold text-white">
Expand Down
30 changes: 30 additions & 0 deletions packages/tests/Motion/Motion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ describe('Motion component', () => {
expect(calls['motion-play']).toBe(0);
});

it('should start each keyframe at its initial style so a replay repeats the motion', async () => {
const { el } = await mountMotion({
dataOptionInitial: '{ "opacity": 0, "y": 24 }',
dataOptionAnimate: '{ "opacity": 1, "y": 0 }',
dataOptionAutoplay: '',
});

// The `initial` styles are applied first, then folded into the keyframes
// as the starting point of the declared animation.
expect(mockAnimate).toHaveBeenNthCalledWith(1, el, { opacity: 0, y: 24 }, { duration: 0 });
expect(mockAnimate).toHaveBeenNthCalledWith(
2,
el,
{ opacity: [0, 1], y: [24, 0] },
{},
);
});

it('should keep explicit keyframe arrays and properties absent from initial', async () => {
const { el } = await mountMotion({
dataOptionInitial: '{ "opacity": 0 }',
dataOptionAnimate: '{ "opacity": [0.2, 1], "x": 100 }',
dataOptionAutoplay: '',
});

// The array wins over `initial`, and `x` — which `initial` says nothing
// about — keeps animating from the current state.
expect(mockAnimate).toHaveBeenNthCalledWith(2, el, { opacity: [0.2, 1], x: 100 }, {});
});

it('should autoplay the animate keyframes with the transition options when enabled', async () => {
const { el, calls } = await mountMotion({
dataOptionAnimate: '{ "x": 100 }',
Expand Down
18 changes: 18 additions & 0 deletions packages/tests/Motion/MotionSequence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ describe('MotionSequence component', () => {
expect(animation.options).toBeUndefined();
});

it('should start each segment at its child initial styles', async () => {
const { kids } = await mountSequence({ dataOptionAutoplay: '' }, [
{ dataOptionInitial: '{ "opacity": 0 }', dataOptionAnimate: '{ "opacity": 1 }' },
{ dataOptionAnimate: '{ "y": 50 }' },
]);

// The child's `initial` styles are their own `animate()` call, so the
// sequence is picked by shape rather than by index.
const sequenced = animations.find((animation) => animation.sequence);

expect(sequenced.sequence).toEqual([
// Folded from `initial`, so replaying the sequence repeats the motion.
[kids[0], { opacity: [0, 1] }, {}],
// No `initial`: the segment still animates from the current state.
[kids[1], { y: 50 }, {}],
]);
});

it('should position segments with the at option, parsing numbers', async () => {
const { kids } = await mountSequence({ dataOptionAutoplay: '' }, [
{ dataOptionAnimate: '{ "x": 100 }', dataOptionAt: '0.5' },
Expand Down
29 changes: 27 additions & 2 deletions packages/ui-motion/src/Motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,13 +402,38 @@ export class Motion<T extends BaseProps = BaseProps> extends Base<MotionProps &
* @protected
*/
__createControls(): AnimationPlaybackControlsWithThen {
const { animate, transition } = this.$options;
const controls = getMotion().animate(this.$el, animate, transition);
const { transition } = this.$options;
const controls = getMotion().animate(this.$el, this.keyframes, transition);
this.__controls = controls;
this.__fromOptions = true;
return controls;
}

/**
* The keyframes of the declared animation, with the `initial` styles folded
* in as the starting point of every property they both describe.
*
* Motion reads a lone target value as "from wherever the element is now", so
* a settled animation would have nothing left to move on the next `play()`.
* Pairing each such value with its `initial` counterpart pins the start, and
* the animation replays identically for as long as the options stand. A
* property already written as a `[from, to]` array is left untouched, and so
* is one the `initial` styles say nothing about — there, animating from the
* current state is the point.
*/
get keyframes(): DOMKeyframesDefinition {
const { initial, animate } = this.$options;
const keyframes: Record<string, unknown> = { ...animate };

for (const [property, to] of Object.entries(keyframes)) {
if (!Array.isArray(to) && property in initial) {
keyframes[property] = [initial[property], to];
}
}

return keyframes as DOMKeyframesDefinition;
}

/**
* Emit `motion-complete` when the given animation finishes, unless another
* playback superseded it in the meantime. The returned promise resolves when
Expand Down
6 changes: 4 additions & 2 deletions packages/ui-motion/src/MotionSequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class MotionSequence<T extends BaseProps = BaseProps> extends Motion<
const { stagger, transition } = this.$options;

const sequence = this.__sequencedChildren.map((child, index) => {
const { animate, transition: childTransition, at } = child.$options;
const { transition: childTransition, at } = child.$options;
const options: Record<string, unknown> = { ...childTransition };

if (at !== '') {
Expand All @@ -96,7 +96,9 @@ export class MotionSequence<T extends BaseProps = BaseProps> extends Motion<
options.at = index * stagger;
}

return [child.$el, animate, options];
// `keyframes`, not the raw `animate` option: each segment starts from
// the child's `initial` styles, so the sequence replays identically.
return [child.$el, child.keyframes, options];
});

const controls = motion.animate(
Expand Down
Loading