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
2 changes: 1 addition & 1 deletion packages/docs/reference/items/Motion/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The `motion` peer dependency is resolved with a lazy `import()` the first time a

## Usage

Option values are parsed as JSON, so object keys must be quoted:
Option values are parsed as JSON, so object keys must be quoted. The animation below plays once on mount, which a preview finishes booting before you look at it — hence the replay button, which calls `play()` to restart it:

<llm-exclude>
<PreviewPlayground
Expand Down
16 changes: 16 additions & 0 deletions packages/docs/reference/items/Motion/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ 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:

<!-- prettier-ignore-start -->
```html {4}
<div
data-component="Motion"
data-option-initial='{ "opacity": 0 }'
data-option-animate='{ "opacity": [0, 1] }'
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.

### `transition`

- Type: `AnimationOptions`
Expand Down
3 changes: 2 additions & 1 deletion packages/docs/reference/items/Motion/stories/basic/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { registerComponents } from '@studiometa/js-toolkit';
import { Action } from '@studiometa/ui';
import { Motion } from '@studiometa/ui-motion';

registerComponents(Motion);
registerComponents(Action, Motion);
32 changes: 24 additions & 8 deletions packages/docs/reference/items/Motion/stories/basic/app.twig
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
<div
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 24, "scale": 0.9 }'
data-option-animate='{ "opacity": 1, "y": 0, "scale": 1 }'
data-option-transition='{ "type": "spring", "bounce": 0.3, "duration": 0.8 }'
data-option-autoplay
class="px-6 py-4 rounded-lg bg-blue-400 dark:bg-blue-600 text-white font-bold">
Hello, I animate on mount!
{# 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. #}
<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-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">
Hello, I animate on mount!
</div>

<button
type="button"
data-component="Action"
data-on:click="Motion(#basic-box)->target.play()"
class="rounded bg-zinc-200 px-4 py-2 dark:bg-zinc-800">
Replay
</button>
</div>
42 changes: 42 additions & 0 deletions packages/docs/reference/items/MotionScrollTimeline/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,45 @@ title: MotionScrollTimeline examples
:::

</llm-only>

## Reading progress bar

The timeline element is the article, and the [`offset`](./js-api#offset) maps progress `0` to its top reaching the top of the viewport and progress `1` to its bottom reaching the bottom — the reading range, rather than the default entering-and-leaving range. The sticky bar holds a single `Motion` child scrubbed from `scaleX: 0` to `scaleX: 1`, which is the cheap way to draw a progress indicator: the browser animates a transform instead of a width, anchored to the left edge by `origin-left`.

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/reading-progress/app.twig')"
:script="() => import('./stories/reading-progress/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/reading-progress/app.twig
<<< ./stories/reading-progress/app.js

:::

</llm-only>

## Parallax layers

One timeline, one scroll range, three `Motion` children: only the travelled distance changes. The far disc moves 80 pixels, the card 180 and the badge 300 over the same progress, and the speed difference alone reads as depth. The timeline scrubs every child with the same progress, so depth is authored entirely in the keyframes — there is no per-layer speed or offset option to set.

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/parallax-layers/app.twig')"
:script="() => import('./stories/parallax-layers/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/parallax-layers/app.twig
<<< ./stories/parallax-layers/app.js

:::

</llm-only>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { registerComponents } from '@studiometa/js-toolkit';
import { MotionScrollTimeline } from '@studiometa/ui-motion';

registerComponents(MotionScrollTimeline);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<p class="py-8 text-center text-current/60">
Scroll down ↓
</p>

{# One timeline, one scroll range, three Motion children: only the travelled
distance differs. The far layer moves the least and the near one the most,
so the speed difference alone reads as depth — no per-layer wiring, the
timeline scrubs them all with the same progress. #}
<section data-component="MotionScrollTimeline" class="relative h-[250vh]">
<div
class="sticky top-0 grid h-screen grid-cols-1 grid-rows-1 place-items-center overflow-hidden">
<div
data-component="Motion"
data-option-animate='{ "y": [80, -80] }'
class="col-start-1 row-start-1 size-72 rounded-full bg-blue-400/30 dark:bg-blue-600/30"></div>
<div
data-component="Motion"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Match parallax distances to the documented travel values

The keyframes move the disc from y: 80 to y: -80, resulting in 160 pixels of travel rather than the documented 80 pixels; the other layers likewise travel 360 and 600 pixels instead of 180 and 300. Use [0, 80], [0, 180], and [0, 300] (or update the documentation to describe the actual displacement).

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 3c34b7a.

data-option-animate='{ "y": [180, -180] }'
class="col-start-1 row-start-1 w-64 rounded-lg bg-white p-6 text-black shadow-2xl dark:bg-zinc-100">
<h2 class="text-xl font-bold">Parallax layers</h2>
<p class="mt-2 text-black/60">
Same range, three speeds.
</p>
</div>
<div
data-component="Motion"
data-option-animate='{ "y": [300, -300] }'
class="col-start-1 row-start-1 mt-56 ml-56 rounded-full bg-emerald-400 px-4 py-2 font-bold text-white dark:bg-emerald-600">
Closest
</div>
</div>
</section>

<p class="py-8 text-center text-current/60">
Done ↑
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { registerComponents } from '@studiometa/js-toolkit';
import { MotionScrollTimeline } from '@studiometa/ui-motion';

registerComponents(MotionScrollTimeline);
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{# The article itself is the timeline, and the `offset` maps progress 0 to its
top reaching the top of the viewport and progress 1 to its bottom reaching
the bottom — the reading range. The single Motion child inside the sticky
bar is scrubbed by that progress, so its scaleX track reads as the share of
the article already scrolled. #}
<section
data-component="MotionScrollTimeline"
data-option-offset='["start start", "end end"]'
class="relative">
<div class="sticky top-0 h-1.5 bg-zinc-200 dark:bg-zinc-800">
<div
data-component="Motion"
data-option-animate='{ "scaleX": [0, 1] }'
class="h-full origin-left bg-blue-500 dark:bg-blue-400"></div>
</div>

<article class="mx-auto max-w-xl px-6 py-16 flex flex-col gap-6">
<h2 class="text-3xl font-bold">Reading progress</h2>
<p>
Scroll this article: the bar above sticks to the top of the section and
fills from left to right as the content goes by. Nothing listens to the
scroll event — the timeline binds the bar to the scroll position once, on
mount.
</p>
<p>
The bar is a plain <code>Motion</code> child. It declares a single
<code>scaleX</code> track from 0 to 1, keeps its
<code>autoplay</code> off, and the timeline takes over its playback: the animation
never runs on its own clock, it is scrubbed.
</p>
<p>
Scaling is the cheap way to draw a progress bar: the browser animates a
transform instead of a width, and the
<code>origin-left</code> class anchors the growth to the left edge.
</p>
<p>
The default <code>offset</code> would start the bar as soon as the section enters
the viewport from the bottom, so the bar would already be part-filled on the
first paint. The
<code>["start start", "end end"]</code> range instead follows the section's
own scroll, which is what a reader expects from a progress indicator.
</p>
<p>
Any number of children can share the same timeline. A second
<code>Motion</code> could fade a "back to top" button in over the last part
of the range, without a single line of JavaScript.
</p>
<p>
Where the browser supports <code>ScrollTimeline</code>, the link is
hardware-accelerated and runs off the main thread; elsewhere Motion falls
back to a scroll listener with the same result.
</p>
<p>
The link is released when the timeline is destroyed, so a section that
leaves the page leaves nothing behind.
</p>
</article>
</section>
21 changes: 21 additions & 0 deletions packages/docs/reference/items/MotionSequence/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,24 @@ title: MotionSequence examples
:::

</llm-only>

## Hero intro on mount

A badge, a heading, a paragraph and a button entering as one choreography: `data-option-autoplay` goes on the sequence, never on the children, because the sequence owns their playback. The three positioning modes are mixed in one timeline — the badge and the heading follow one another in DOM order, the paragraph starts _with_ the heading through [`data-option-at="<"`](./js-api#at-on-the-children), and the button overlaps the end of the paragraph with the relative offset `data-option-at="-0.2"`. Each child keeps its own `initial`/`animate` keyframes and its own spring. A mount animation is over before a reader reaches the preview, so the replay button restarts the whole choreography with a single `play()` on the sequence.

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/hero-intro/app.twig')"
:script="() => import('./stories/hero-intro/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/hero-intro/app.twig
<<< ./stories/hero-intro/app.js

:::

</llm-only>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { registerComponents } from '@studiometa/js-toolkit';
import { Action } from '@studiometa/ui';
import { MotionSequence } from '@studiometa/ui-motion';

registerComponents(Action, MotionSequence);
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{# One choreography, played on mount: `data-option-autoplay` belongs to the
sequence, never to the children — the sequence owns their playback. The
badge and the heading follow one another in DOM order, the paragraph joins
the heading with `data-option-at="<"`, and the button overlaps the end of
the paragraph with the relative offset `data-option-at="-0.2"`. The replay
button below drives the whole timeline with a single `play()`. #}
<div
id="hero-sequence"
data-component="MotionSequence"
data-option-autoplay
class="grid max-w-xl justify-items-start gap-4 rounded-lg p-8 ring">
<span
data-component="Motion"
data-option-initial='{ "opacity": 0, "scale": 0.8 }'
data-option-animate='{ "opacity": [0, 1], "scale": [0.8, 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
</span>

<h2
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 24 }'
data-option-animate='{ "opacity": [0, 1], "y": [24, 0] }'
data-option-transition='{ "type": "spring", "bounce": 0.2 }'
class="text-3xl font-bold">
Animate the whole hero as one timeline
</h2>

<p
data-component="Motion"
data-option-initial='{ "opacity": 0, "y": 16 }'
data-option-animate='{ "opacity": [0, 1], "y": [16, 0] }'
data-option-at="<"
class="text-current/70">
This paragraph starts with the heading, not after it: <code>"&lt;"</code>
positions a segment at the start of the previous one.
</p>

<button
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-transition='{ "type": "spring", "bounce": 0.4 }'
data-option-at="-0.2"
class="rounded bg-blue-600 px-4 py-2 font-semibold text-white">
Get started
</button>
</div>

<button
type="button"
data-component="Action"
data-on:click="MotionSequence(#hero-sequence)->target.play()"
class="mt-4 rounded bg-zinc-200 px-4 py-2 dark:bg-zinc-800">
Replay the sequence
</button>
4 changes: 2 additions & 2 deletions packages/docs/reference/items/MotionView/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ title: MotionView examples

## Zero-wiring exit animation for `data-bind:if`

[Ambient wiring](./js-api#ambient-wiring) in action: the `MotionView` has no wiring attribute at all — it wraps the `dom-update` event that [`data-bind:if`](/reference/items/DataBind/js-api#conditional-rendering-with-data-bind-if) announces before changing the DOM, so the template content animates in **and out**, even though the removed nodes are already gone when the exit plays. The `layout` option morphs the container's size with a spring.
[Ambient wiring](./js-api#ambient-wiring) in action: the `MotionView` has no wiring attribute at all — it wraps the `dom-update` event that [`data-bind:if`](/reference/items/DataBind/js-api#conditional-rendering-with-data-bind-if) announces before changing the DOM, so the template content animates in **and out**, even though the removed nodes are already gone when the exit plays. The `layout` option morphs the container's size with a spring. Because the event bubbles, the `MotionView` only needs to sit just outside the `<template>` instead of around the whole widget: the morph then hugs the panel region alone and leaves the checkbox above it out of the transition.

<llm-exclude>
<PreviewPlayground
Expand All @@ -48,7 +48,7 @@ title: MotionView examples

## Ambient view transitions in a `Dialog`

The same containment story with [`Dialog`](/reference/items/Dialog/): nested inside it, the `MotionView` joins the bubbling `open`/`close` lifecycle by itself — the dialog calls its `enter()`/`leave()` through `waitUntil()` and stays painted until the spring view transition settles. Compare with the [explicit `Motion` wiring](/reference/items/Motion/examples#spring-entrance-and-exit-for-a-dialog): same result, zero attributes.
The same containment story with [`Dialog`](/reference/items/Dialog/): nested inside it, a `MotionView` joins the bubbling `open`/`close` lifecycle by itself — the dialog calls its `enter()`/`leave()` through `waitUntil()` and stays painted until the view transition settles. Two instances share the same lifecycle here, the backdrop fading and the box springing in, because `waitUntil()` is additive: every transitioner that registers on the event is awaited, so any number of them can animate one dialog without ever knowing about each other. Compare with the [explicit `Motion` wiring](/reference/items/Motion/examples#spring-entrance-and-exit-for-a-dialog): same result, zero attributes.

<llm-exclude>
<PreviewPlayground
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
{# The MotionView carries no wiring attribute: it ambiently wraps the
`dom-update` event that `data-bind:if` announces before changing the DOM,
so the insertion AND the removal play as one spring view transition. #}
<div
data-component="MotionView"
data-option-transition='{ "type": "spring", "bounce": 0.2 }'
data-option-layout
class="max-w-sm">
data-component="DataScope"
data-option-group="ambient-panel"
class="grid gap-4 rounded ring p-4 max-w-sm">
<label class="flex items-center gap-2">
<input type="checkbox" name="details" data-component="DataModel" />
<span>Show the details</span>
</label>
{# The MotionView sits just outside the template, not around the whole
widget: the `dom-update` event that `data-bind:if` announces before
changing the DOM bubbles up to it, so the insertion AND the removal play
as one spring view transition — and the `layout` morph hugs the panel
region only, leaving the checkbox above it untouched. #}
<div
data-component="DataScope"
data-option-group="ambient-panel"
class="grid gap-4 rounded ring p-4">
<label class="flex items-center gap-2">
<input type="checkbox" name="details" data-component="DataModel" />
<span>Show the details</span>
</label>
data-component="MotionView"
data-option-transition='{ "type": "spring", "bounce": 0.2 }'
data-option-layout>
<template data-component="DataBind" data-option-key="details" data-bind:if>
<p class="p-4 rounded bg-emerald-400/20 ring ring-emerald-400">
I was inserted inside a view transition — and I animate out too, even
Expand Down
Loading
Loading