...
```
+
-### `keyframes`
+#### `keyframes`
- Type: `keyFrame[]`
- Default: `[]`
@@ -133,22 +338,21 @@ Final keyframe for the animation. Define CSS properties as key-value pairs.
Array of keyframes for complex animations. When provided, `from` and `to` are ignored.
```html
-
+
```
-See the following types defintions and [the `animate` documentation](https://js-toolkit.studiometa.dev/utils/css/animate.html) for more advanced documentation on keyframes.
+See the following types definitions and [the `animate` documentation](https://js-toolkit.studiometa.dev/utils/css/animate.html) for more advanced documentation on keyframes.
```ts twoslash
import { TransformProps } from '@studiometa/js-toolkit/utils';
@@ -166,18 +370,26 @@ interface KeyFrame extends TransformProps {
}
```
-### `easing`
+#### `easing`
- Type: `[number, number, number, number]`
- Default: `[0, 0, 1, 1]`
Cubic-bezier easing values for the animation timing.
-```html
-
...
+
+```html {4}
+
```
+
-#### Common easing values
+##### Common easing values
| Easing | Cubic bezier |
| ---------- | ----------------------------- |
@@ -207,9 +419,40 @@ Cubic-bezier easing values for the animation timing.
| InOutQuint | `[0.86, 0, 0.07, 1]` |
| InOutSine | `[0.445, 0.05, 0.55, 0.95]` |
-## Methods
+#### `dampFactor`
+
+- Type: `number`
+- Default: `0.1`
+
+Damping factor for smooth scroll animations. Lower values create smoother, slower animations. Each `ScrollAnimationTarget` can have its own damping factor, allowing for different animation speeds within the same timeline.
+
+```html {4,9}
+
+
+ Slow and smooth
+
+
+ Fast and snappy
+
+
+```
+
+#### `dampPrecision`
+
+- Type: `number`
+- Default: `0.001`
+
+Precision threshold for damping calculations. Lower values increase precision but may impact performance.
-### `render(progress)`
+### Methods
+
+#### `render(progress)`
- Parameters:
- `progress` (`number`): animation progress between 0 and 1
@@ -217,16 +460,131 @@ Cubic-bezier easing values for the animation timing.
Manually render the animation at a specific progress value.
-## Properties
+### Properties
-### `target`
+#### `target`
- Type: `HTMLElement`
-The element being animated (either the `target` ref or the component's root element).
+The element being animated. Defaults to the component's root element (`$el`).
-### `animation`
+#### `animation`
- Type: `Animation`
The animation instance created from the keyframes and easing options. See [`animate` documentation](https://js-toolkit.studiometa.dev/utils/css/animate.html).
+
+#### `dampedCurrent`
+
+- Type: `{ x: number, y: number }`
+
+Current damped scroll position values for both axes.
+
+#### `dampedProgress`
+
+- Type: `{ x: number, y: number }`
+
+Current damped progress values (0-1) for both axes.
+
+#### `playRange`
+
+- Type: `[number, number]`
+
+The computed play range for the animation, taking into account the staggered format if used.
+
+---
+
+## ScrollAnimation (deprecated) {#scrollanimation-deprecated}
+
+:::warning Deprecated
+The `ScrollAnimation` component is deprecated. Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.
+:::
+
+A standalone component that watches its own position in the viewport and animates a target element. This component requires a `target` ref.
+
+### Usage
+
+```html
+
+```
+
+### Refs
+
+#### `target`
+
+- Type: `HTMLElement`
+
+The element to animate. Required for this component.
+
+### Options
+
+The `ScrollAnimation` component supports the same options as `ScrollAnimationTarget`:
+
+- [`playRange`](#playrange)
+- [`from`](#from)
+- [`to`](#to)
+- [`keyframes`](#keyframes)
+- [`easing`](#easing)
+
+---
+
+## withScrollAnimationDebug
+
+A decorator that adds debug capabilities to `ScrollAnimationTimeline`. When the `debug` option is enabled, it displays visual markers to help understand how the scroll animation is triggered.
+
+This decorator is exported separately to allow tree-shaking the debug code from production bundles.
+
+### Usage
+
+```js
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
+```
+
+```html
+
+```
+
+### Debug features
+
+When the `debug` option is enabled on a `ScrollAnimationTimeline` component wrapped with this decorator:
+
+- **Outline**: A dashed border around the timeline element
+- **Start marker**: A horizontal line showing where in the viewport the animation starts
+- **End marker**: A horizontal line showing where in the viewport the animation ends
+- **Progress indicator**: A progress bar and percentage showing the current scroll progress
+
+Each timeline gets a different color for easy identification when multiple timelines are on the same page.
+
+### Parameters
+
+- `BaseClass` (`typeof ScrollAnimationTimeline`): The `ScrollAnimationTimeline` class to decorate
+
+### Return value
+
+- `typeof ScrollAnimationTimeline`: The decorated class with debug capabilities
diff --git a/packages/docs/components/ScrollAnimation/stories/simple/app.js b/packages/docs/components/ScrollAnimation/stories/cards/app.js
similarity index 56%
rename from packages/docs/components/ScrollAnimation/stories/simple/app.js
rename to packages/docs/components/ScrollAnimation/stories/cards/app.js
index 0724ff31..f0e861e5 100644
--- a/packages/docs/components/ScrollAnimation/stories/simple/app.js
+++ b/packages/docs/components/ScrollAnimation/stories/cards/app.js
@@ -1,12 +1,12 @@
import { Base, createApp } from '@studiometa/js-toolkit';
-import { ScrollAnimation, ScrollAnimationWithEase } from '@studiometa/ui';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
class App extends Base {
static config = {
name: 'App',
components: {
- ScrollAnimation,
- ScrollAnimationWithEase,
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
},
};
}
diff --git a/packages/docs/components/ScrollAnimation/stories/cards/app.twig b/packages/docs/components/ScrollAnimation/stories/cards/app.twig
new file mode 100644
index 00000000..699d0823
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/cards/app.twig
@@ -0,0 +1,51 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/damp-factor/app.js b/packages/docs/components/ScrollAnimation/stories/damp-factor/app.js
new file mode 100644
index 00000000..f0e861e5
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/damp-factor/app.js
@@ -0,0 +1,14 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/damp-factor/app.twig b/packages/docs/components/ScrollAnimation/stories/damp-factor/app.twig
new file mode 100644
index 00000000..dd6b1a39
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/damp-factor/app.twig
@@ -0,0 +1,48 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/debug/app.js b/packages/docs/components/ScrollAnimation/stories/debug/app.js
new file mode 100644
index 00000000..95f8b216
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/debug/app.js
@@ -0,0 +1,18 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/debug/app.twig b/packages/docs/components/ScrollAnimation/stories/debug/app.twig
new file mode 100644
index 00000000..c2be3958
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/debug/app.twig
@@ -0,0 +1,19 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/easing/app.js b/packages/docs/components/ScrollAnimation/stories/easing/app.js
new file mode 100644
index 00000000..f0e861e5
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/easing/app.js
@@ -0,0 +1,14 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/easing/app.twig b/packages/docs/components/ScrollAnimation/stories/easing/app.twig
new file mode 100644
index 00000000..ac85d579
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/easing/app.twig
@@ -0,0 +1,48 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/from-to/app.js b/packages/docs/components/ScrollAnimation/stories/from-to/app.js
new file mode 100644
index 00000000..f0e861e5
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/from-to/app.js
@@ -0,0 +1,14 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/from-to/app.twig b/packages/docs/components/ScrollAnimation/stories/from-to/app.twig
new file mode 100644
index 00000000..36b2c820
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/from-to/app.twig
@@ -0,0 +1,17 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/keyframes/app.js b/packages/docs/components/ScrollAnimation/stories/keyframes/app.js
new file mode 100644
index 00000000..f0e861e5
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/keyframes/app.js
@@ -0,0 +1,14 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/keyframes/app.twig b/packages/docs/components/ScrollAnimation/stories/keyframes/app.twig
new file mode 100644
index 00000000..88859555
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/keyframes/app.twig
@@ -0,0 +1,21 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-center/app.js b/packages/docs/components/ScrollAnimation/stories/offset-center/app.js
new file mode 100644
index 00000000..95f8b216
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-center/app.js
@@ -0,0 +1,18 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-center/app.twig b/packages/docs/components/ScrollAnimation/stories/offset-center/app.twig
new file mode 100644
index 00000000..804ea56f
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-center/app.twig
@@ -0,0 +1,24 @@
+
+ Scroll down ↓
+
+
+
+
+
+
+
Centered offset
+
"start center / end center"
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-default/app.js b/packages/docs/components/ScrollAnimation/stories/offset-default/app.js
new file mode 100644
index 00000000..95f8b216
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-default/app.js
@@ -0,0 +1,18 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-default/app.twig b/packages/docs/components/ScrollAnimation/stories/offset-default/app.twig
new file mode 100644
index 00000000..49e066ab
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-default/app.twig
@@ -0,0 +1,23 @@
+
+ Scroll down ↓
+
+
+
+
+
+
+
Default offset
+
"start end / end start"
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-numbered/app.js b/packages/docs/components/ScrollAnimation/stories/offset-numbered/app.js
new file mode 100644
index 00000000..95f8b216
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-numbered/app.js
@@ -0,0 +1,18 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-numbered/app.twig b/packages/docs/components/ScrollAnimation/stories/offset-numbered/app.twig
new file mode 100644
index 00000000..f3d44161
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-numbered/app.twig
@@ -0,0 +1,24 @@
+
+ Scroll down ↓
+
+
+
+
+
+
+
Numbered offset
+
"0 0.8 / 1 0.2"
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-percentage/app.js b/packages/docs/components/ScrollAnimation/stories/offset-percentage/app.js
new file mode 100644
index 00000000..95f8b216
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-percentage/app.js
@@ -0,0 +1,18 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-percentage/app.twig b/packages/docs/components/ScrollAnimation/stories/offset-percentage/app.twig
new file mode 100644
index 00000000..64c7308a
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-percentage/app.twig
@@ -0,0 +1,24 @@
+
+ Scroll down ↓
+
+
+
+
+
+
+
Percentage offset
+
"0 75% / 100% 25%"
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-top/app.js b/packages/docs/components/ScrollAnimation/stories/offset-top/app.js
new file mode 100644
index 00000000..95f8b216
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-top/app.js
@@ -0,0 +1,18 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ withScrollAnimationDebug,
+} from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/offset-top/app.twig b/packages/docs/components/ScrollAnimation/stories/offset-top/app.twig
new file mode 100644
index 00000000..c478ec74
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/offset-top/app.twig
@@ -0,0 +1,24 @@
+
+ Scroll down ↓
+
+
+
+
+
+
+
Top section offset
+
"start start / end start"
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js b/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js
deleted file mode 100644
index acbca604..00000000
--- a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { Base, createApp } from '@studiometa/js-toolkit';
-import { Figure, ScrollAnimationChild, ScrollAnimationParent } from '@studiometa/ui';
-
-class ParallaxChild extends ScrollAnimationChild {
- static config = {
- ...ScrollAnimationChild.config,
- name: 'ParallaxChild',
- components: {
- Figure,
- },
- };
-
- get target() {
- return this.$children.Figure[0].$el;
- }
-}
-
-class ParallaxParent extends ScrollAnimationParent {
- static config = {
- ...ScrollAnimationParent.config,
- name: 'ParallaxParent',
- components: {
- ParallaxChild,
- },
- };
-
- get target() {
- return this.$children.Figure[0].$el;
- }
-
- scrolledInView(props) {
- this.$children.ParallaxChild.forEach((child) => {
- child.scrolledInView(props);
- });
- }
-}
-
-class App extends Base {
- static config = {
- name: 'App',
- components: {
- ParallaxParent,
- },
- };
-}
-
-export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig b/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig
deleted file mode 100644
index 797ec736..00000000
--- a/packages/docs/components/ScrollAnimation/stories/parallax-parent/app.twig
+++ /dev/null
@@ -1,58 +0,0 @@
-
- Scroll down
-
-
-{% set img_attr = {
- class: 'object-cover',
- style: { top: '-20%', height: '140%' }
-} %}
-
-{% set images = [
- {
- src: 'https://picsum.photos/700/600',
- width: '700',
- height: '600',
- img_attr: img_attr
- },
- {
- src: 'https://picsum.photos/800/600',
- width: '800',
- height: '600',
- img_attr: img_attr
- },
- {
- src: 'https://picsum.photos/600/800',
- width: '600',
- height: '800',
- img_attr: img_attr
- },
- {
- src: 'https://picsum.photos/700/600',
- width: '700',
- height: '600',
- img_attr: img_attr
- },
- {
- src: 'https://picsum.photos/800/600',
- width: '800',
- height: '600',
- img_attr: img_attr
- }
-] %}
-
-
- {% include '@ui/ImageGrid/ImageGrid.twig' with {
- images: images,
- image_attr: {
- data_component: 'ParallaxChild',
- data_option_from: { y: [-20, '%'] },
- data_option_to: { y: [20, '%'] },
- class: 'h-fit overflow-hidden',
- style: { contain: 'content' }
- }
- } %}
-
-
-
- Scroll up
-
diff --git a/packages/docs/components/ScrollAnimation/stories/parallax/app.js b/packages/docs/components/ScrollAnimation/stories/parallax/app.js
index 7d625366..83400937 100644
--- a/packages/docs/components/ScrollAnimation/stories/parallax/app.js
+++ b/packages/docs/components/ScrollAnimation/stories/parallax/app.js
@@ -1,9 +1,9 @@
-import { Base, createApp } from '@studiometa/js-toolkit';
-import { Figure, ScrollAnimation } from '@studiometa/ui';
+import { Base, createApp, withScrolledInView } from '@studiometa/js-toolkit';
+import { Figure, ScrollAnimationTarget } from '@studiometa/ui';
-class Parallax extends ScrollAnimation {
+class Parallax extends withScrolledInView(ScrollAnimationTarget) {
static config = {
- ...ScrollAnimation.config,
+ ...ScrollAnimationTarget.config,
name: 'Parallax',
components: {
Figure,
diff --git a/packages/docs/components/ScrollAnimation/stories/parallax/app.twig b/packages/docs/components/ScrollAnimation/stories/parallax/app.twig
index 75d74a98..1836ee00 100644
--- a/packages/docs/components/ScrollAnimation/stories/parallax/app.twig
+++ b/packages/docs/components/ScrollAnimation/stories/parallax/app.twig
@@ -1,5 +1,5 @@
-
- Scroll down
+
+ Scroll down ↓
{% set img_attr = {
@@ -40,7 +40,7 @@
}
] %}
-
+
{% include '@ui/ImageGrid/ImageGrid.twig' with {
images: images,
image_attr: {
@@ -54,6 +54,6 @@
} %}
-
- Scroll up
+
+ Scroll up ↑
diff --git a/packages/docs/components/ScrollAnimation/stories/parent/app.js b/packages/docs/components/ScrollAnimation/stories/parent/app.js
deleted file mode 100644
index 1e1abeec..00000000
--- a/packages/docs/components/ScrollAnimation/stories/parent/app.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Base, createApp } from '@studiometa/js-toolkit';
-import {
- ScrollAnimationParent as ScrollAnimationParentCore,
- ScrollAnimationChild,
-} from '@studiometa/ui';
-
-class ScrollAnimationParent extends ScrollAnimationParentCore {
- static config = {
- name: 'ScrollAnimationParent',
- components: {
- ScrollAnimationChild,
- },
- };
-}
-
-class App extends Base {
- static config = {
- name: 'App',
- components: {
- ScrollAnimationParent,
- },
- };
-}
-
-export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/parent/app.twig b/packages/docs/components/ScrollAnimation/stories/parent/app.twig
deleted file mode 100644
index f14fa81a..00000000
--- a/packages/docs/components/ScrollAnimation/stories/parent/app.twig
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
- Scroll down
-
-
-
-
diff --git a/packages/docs/components/ScrollAnimation/stories/play-range/app.js b/packages/docs/components/ScrollAnimation/stories/play-range/app.js
new file mode 100644
index 00000000..f0e861e5
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/play-range/app.js
@@ -0,0 +1,14 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/play-range/app.twig b/packages/docs/components/ScrollAnimation/stories/play-range/app.twig
new file mode 100644
index 00000000..6c3f8910
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/play-range/app.twig
@@ -0,0 +1,39 @@
+
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/sequence/app.js b/packages/docs/components/ScrollAnimation/stories/sequence/app.js
index f785ce82..539bc8b4 100644
--- a/packages/docs/components/ScrollAnimation/stories/sequence/app.js
+++ b/packages/docs/components/ScrollAnimation/stories/sequence/app.js
@@ -1,12 +1,13 @@
import { Base, createApp } from '@studiometa/js-toolkit';
-import { ScrollAnimationParent } from '@studiometa/ui';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
class App extends Base {
static config = {
name: 'App',
components: {
- ScrollAnimationParent,
- }
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
};
}
diff --git a/packages/docs/components/ScrollAnimation/stories/sequence/app.twig b/packages/docs/components/ScrollAnimation/stories/sequence/app.twig
index ffc924f1..b7696bc2 100644
--- a/packages/docs/components/ScrollAnimation/stories/sequence/app.twig
+++ b/packages/docs/components/ScrollAnimation/stories/sequence/app.twig
@@ -9,13 +9,13 @@
{% for item in items %}
- html { overflow-x: hidden; }
-
- .text-9xl { font-size: 18rem; }
-
-
-{% macro animated_image(width, height) %}
-
-

-
-{% endmacro %}
-
-
- Scroll down
-
-
-
-
-
- {{ _self.animated_image(400, 600) }}
- {{ _self.animated_image(400, 500) }}
- {{ _self.animated_image(400, 550) }}
-
-
-
-
- {{ _self.animated_image(400, 400) }}
- {{ _self.animated_image(400, 450) }}
- {{ _self.animated_image(400, 580) }}
-
-
-
-
- {{ _self.animated_image(400, 440) }}
- {{ _self.animated_image(400, 460) }}
- {{ _self.animated_image(400, 560) }}
- {{ _self.animated_image(400, 610) }}
-
-
-
-
- {{ _self.animated_image(400, 530) }}
- {{ _self.animated_image(400, 520) }}
- {{ _self.animated_image(400, 620) }}
-
-
-
-
- {{ _self.animated_image(400, 480) }}
- {{ _self.animated_image(400, 540) }}
- {{ _self.animated_image(400, 590) }}
-
-
-
-
-
-
-
-
-
diff --git a/packages/docs/components/ScrollAnimation/stories/staggered/app.js b/packages/docs/components/ScrollAnimation/stories/staggered/app.js
index f785ce82..539bc8b4 100644
--- a/packages/docs/components/ScrollAnimation/stories/staggered/app.js
+++ b/packages/docs/components/ScrollAnimation/stories/staggered/app.js
@@ -1,12 +1,13 @@
import { Base, createApp } from '@studiometa/js-toolkit';
-import { ScrollAnimationParent } from '@studiometa/ui';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
class App extends Base {
static config = {
name: 'App',
components: {
- ScrollAnimationParent,
- }
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
};
}
diff --git a/packages/docs/components/ScrollAnimation/stories/staggered/app.twig b/packages/docs/components/ScrollAnimation/stories/staggered/app.twig
index 2abda814..b803073a 100644
--- a/packages/docs/components/ScrollAnimation/stories/staggered/app.twig
+++ b/packages/docs/components/ScrollAnimation/stories/staggered/app.twig
@@ -12,12 +12,12 @@
{% for index in 1..total %}
+ Scroll down ↓
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollAnimation/stories/text/app.js b/packages/docs/components/ScrollAnimation/stories/text/app.js
new file mode 100644
index 00000000..f0e861e5
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/text/app.js
@@ -0,0 +1,14 @@
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App);
diff --git a/packages/docs/components/ScrollAnimation/stories/text/app.twig b/packages/docs/components/ScrollAnimation/stories/text/app.twig
new file mode 100644
index 00000000..67d7880c
--- /dev/null
+++ b/packages/docs/components/ScrollAnimation/stories/text/app.twig
@@ -0,0 +1,36 @@
+
+
+
+ Scroll down ↓
+
+
+
+
+
+
+
+ Scroll up ↑
+
diff --git a/packages/docs/components/ScrollReveal/index.md b/packages/docs/components/ScrollReveal/index.md
index 13ebc244..051da2a8 100644
--- a/packages/docs/components/ScrollReveal/index.md
+++ b/packages/docs/components/ScrollReveal/index.md
@@ -35,8 +35,7 @@ export default createApp(App);
+ data-option-enter-active="transition">
...
```
diff --git a/packages/docs/components/Slider/index.md b/packages/docs/components/Slider/index.md
index 8803a899..95456af1 100644
--- a/packages/docs/components/Slider/index.md
+++ b/packages/docs/components/Slider/index.md
@@ -16,7 +16,6 @@ badges: [JS]
- [SliderItem](./js-api/slider-item.md)
- [SliderProgress](./js-api/slider-progress.md)
-
## Usage
Use the `Slider` component to display items on a X axis and enable indexed navigation between them.
diff --git a/packages/docs/components/Slider/js-api/slider-count.md b/packages/docs/components/Slider/js-api/slider-count.md
index b05533d1..f7a9061c 100644
--- a/packages/docs/components/Slider/js-api/slider-count.md
+++ b/packages/docs/components/Slider/js-api/slider-count.md
@@ -1,4 +1,5 @@
# SliderCount
+
This component can be used to display the current index of the slider and update it on change.
## Refs
diff --git a/packages/docs/components/Slider/js-api/slider-drag.md b/packages/docs/components/Slider/js-api/slider-drag.md
index f323afc8..c402c6ff 100644
--- a/packages/docs/components/Slider/js-api/slider-drag.md
+++ b/packages/docs/components/Slider/js-api/slider-drag.md
@@ -9,6 +9,7 @@ This component can be used to add drag capabilities to the slider. It should wra
...
+
```
This component uses the [`withDrag` decorator](https://js-toolkit.studiometa.dev/api/decorators/withDrag.html) and inherits from its APIs.
diff --git a/packages/docs/components/Tabs/index.md b/packages/docs/components/Tabs/index.md
index 730ca5b3..01856ab2 100644
--- a/packages/docs/components/Tabs/index.md
+++ b/packages/docs/components/Tabs/index.md
@@ -39,7 +39,7 @@ export default createApp(App, document.body);
content: 'Content for tab 1'
},
{
- title: 'Tab 2',
+ title: 'Tab 2',
content: 'Content for tab 2'
},
{
diff --git a/packages/docs/components/Tabs/js-api.md b/packages/docs/components/Tabs/js-api.md
index 758e0b59..f824aae7 100644
--- a/packages/docs/components/Tabs/js-api.md
+++ b/packages/docs/components/Tabs/js-api.md
@@ -23,6 +23,7 @@ HTMLElement references for tab content panels. Each panel corresponds to a tab b
Configure the styles for different tab states. Available references are `btn` and `content`, each supporting `open`, `active`, and `closed` style states.
+
```html
data-option-styles='{
"btn": {
@@ -35,6 +36,7 @@ data-option-styles='{
}
}'
```
+
## Methods
@@ -69,6 +71,7 @@ Emitted when a tab is disabled. The event data contains the disabled tab item.
- Type: `TabItem[]`
Array of tab items, each containing:
+
- `btn` (HTMLElement) - The tab button element
- `content` (HTMLElement) - The tab content element
- `isEnabled` (boolean) - Whether the tab is currently enabled
diff --git a/packages/docs/components/Tabs/twig-api.md b/packages/docs/components/Tabs/twig-api.md
index 928823c1..aa2015ab 100644
--- a/packages/docs/components/Tabs/twig-api.md
+++ b/packages/docs/components/Tabs/twig-api.md
@@ -44,6 +44,7 @@ Customize the wrapper around all tab buttons. By default, renders all tab button
### `title`
Customize each tab button's content. Defaults to `item.title`. Available variables:
+
- `item` - The current tab item
### `content_wrapper`
@@ -53,4 +54,5 @@ Customize the wrapper around all tab content panels. By default, renders all con
### `content`
Customize each tab content panel. Defaults to `item.content`. Available variables:
+
- `item` - The current tab item
diff --git a/packages/docs/components/Transition/index.md b/packages/docs/components/Transition/index.md
index 10b7a890..1e04e693 100644
--- a/packages/docs/components/Transition/index.md
+++ b/packages/docs/components/Transition/index.md
@@ -64,8 +64,7 @@ You can now add a togglable component in your HTML with the needed option to des
data-option-leave-active="transition duration-500 ease-out-expo"
data-option-leave-to="transform translate-y-20 opacity-0"
data-option-leave-keep
- class="transform translate-y-4 opacity-0"
->
+ class="transform translate-y-4 opacity-0">
...
```
diff --git a/packages/docs/components/Transition/js-api.md b/packages/docs/components/Transition/js-api.md
index bbd0e005..4ffcfea9 100644
--- a/packages/docs/components/Transition/js-api.md
+++ b/packages/docs/components/Transition/js-api.md
@@ -14,12 +14,14 @@ outline: deep
Defines the classes that describe the initial state of the enter transition.
+
```html {2}
...
```
+
### `enterActive`
@@ -28,12 +30,14 @@ Defines the classes that describe the initial state of the enter transition.
Defines the classes that describe the transitioning state of the enter transition.
+
```html {2}
...
```
+
### `enterTo`
@@ -42,12 +46,14 @@ Defines the classes that describe the transitioning state of the enter transitio
Defines the classes that describe the end state of the enter transition.
+
```html {2}
...
```
+
### `enterKeep`
@@ -56,12 +62,14 @@ Defines the classes that describe the end state of the enter transition.
Configure wether or not the `enterTo` classes should be kept on the target element at the end of the enter transition.
+
```html {2}
...
```
+
### `leaveFrom`
@@ -70,12 +78,14 @@ Configure wether or not the `enterTo` classes should be kept on the target eleme
Defines the classes that describe the initial state of the leave transition.
+
```html {2}
...
```
+
### `leaveActive`
@@ -84,12 +94,14 @@ Defines the classes that describe the initial state of the leave transition.
Defines the classes that describe the transitioning state of the leave transition.
+
```html {2}
...
```
+
### `leaveTo`
@@ -98,12 +110,14 @@ Defines the classes that describe the transitioning state of the leave transitio
Defines the classes that describe the end state of the leave transition.
+
```html {2}
...
```
+
### `leaveKeep`
@@ -112,13 +126,14 @@ Defines the classes that describe the end state of the leave transition.
Configure wether or not the `leaveTo` classes should be kept on the target element at the end of the leave transition.
+
```html {2}
...
```
-
+
### `group`
@@ -127,6 +142,7 @@ Configure wether or not the `leaveTo` classes should be kept on the target eleme
Define a group to sync `enter` and `leave` transition between multiple instances.
+
```html {2,7}
@@ -138,7 +154,7 @@ Define a group to sync `enter` and `leave` transition between multiple instances
...
```
-
+
## Properties
diff --git a/packages/docs/migration-guides/0.1.0-0.2.0/index.md b/packages/docs/migration-guides/0.1.0-0.2.0/index.md
index 4c402f59..af55a1a1 100644
--- a/packages/docs/migration-guides/0.1.0-0.2.0/index.md
+++ b/packages/docs/migration-guides/0.1.0-0.2.0/index.md
@@ -4,11 +4,11 @@
The following components have been updated:
-| Component | Previous version | New version | Changed |
-|-----------------------------------------------|-----------------------------------|------------------------|-----------------------------------|
-| [Button](/components/Button/) |
|
| • Twig Template API standardization |
-| [Cursor](/components/Cursor/) |
|
| • Twig Template API standardization |
-| [Figure](/components/Figure/) |
|
| • Twig Template API standardization |
+| Component | Previous version | New version | Changed |
+| ----------------------------------- | --------------------------------- | ---------------------- | ----------------------------------- |
+| [Button](/components/Button/) |
|
| • Twig Template API standardization |
+| [Cursor](/components/Cursor/) |
|
| • Twig Template API standardization |
+| [Figure](/components/Figure/) |
|
| • Twig Template API standardization |
| [Accordion](/components/Accordion/) |
|
| • Twig Template API standardization |
| [Modal](/components/Modal/) |
|
| • Twig Template API standardization |
| [Sticky](/components/Sticky/) |
|
| • Twig Template API standardization |
diff --git a/packages/docs/migration-guides/1.0-2.0/index.md b/packages/docs/migration-guides/1.0-2.0/index.md
new file mode 100644
index 00000000..3549d5bb
--- /dev/null
+++ b/packages/docs/migration-guides/1.0-2.0/index.md
@@ -0,0 +1,235 @@
+# v1.x → v2.x
+
+You will find on this page documentation on all the breaking changes included in the v2.x of the package.
+
+[[toc]]
+
+## ScrollAnimation components have been refactored
+
+The `ScrollAnimation` family of components has been refactored for better performance and flexibility. The new API uses `ScrollAnimationTimeline` as a parent component that manages scroll progress, with `ScrollAnimationTarget` children that handle individual animations.
+
+### Summary of component renames
+
+| v1.x (deprecated) | v2.x (new) |
+| ------------------------------ | --------------------------------------------------- |
+| `ScrollAnimation` | `ScrollAnimationTimeline` + `ScrollAnimationTarget` |
+| `ScrollAnimationParent` | `ScrollAnimationTimeline` |
+| `ScrollAnimationChild` | `ScrollAnimationTarget` |
+| `ScrollAnimationChildWithEase` | `ScrollAnimationTarget` with `dampFactor` option |
+| `ScrollAnimationWithEase` | `ScrollAnimationTimeline` + `ScrollAnimationTarget` |
+| `animationScrollWithEase` | Extend `ScrollAnimationTarget` instead |
+
+### Benefits of the new API
+
+The refactored API provides several benefits:
+
+1. **Better performance**: The timeline only watches one element for scroll position, reducing the number of intersection observers
+2. **Independent damping**: Each target can have its own `dampFactor`, allowing different animation speeds within the same timeline
+3. **Simpler markup**: No need for a `target` ref — the component animates itself
+4. **Clearer naming**: `Timeline` and `Target` clearly describe the parent-child relationship
+5. **More flexible**: Easier to coordinate multiple animations with different play ranges
+
+### Replace `ScrollAnimation` with `ScrollAnimationTimeline` and `ScrollAnimationTarget`
+
+The standalone `ScrollAnimation` component has been removed. Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.
+
+**Before (v1.x):**
+
+```html
+
+```
+
+```js
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimation } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimation,
+ },
+ };
+}
+
+export default createApp(App, document.body);
+```
+
+**After (v2.x):**
+
+```html
+
+
+ Content to animate
+
+
+```
+
+```js
+import { Base, createApp } from '@studiometa/js-toolkit';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+
+class App extends Base {
+ static config = {
+ name: 'App',
+ components: {
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ },
+ };
+}
+
+export default createApp(App, document.body);
+```
+
+::: tip Key differences
+
+- The `target` ref is no longer needed — `ScrollAnimationTarget` animates itself
+- Animation options (`from`, `to`, `playRange`, etc.) are now on `ScrollAnimationTarget`
+- Multiple targets can share the same timeline for coordinated animations
+
+:::
+
+### Replace `ScrollAnimationParent` with `ScrollAnimationTimeline`
+
+The `ScrollAnimationParent` component has been renamed to `ScrollAnimationTimeline`.
+
+**Before (v1.x):**
+
+```html
+
+```
+
+```js
+import { ScrollAnimationParent, ScrollAnimationChild } from '@studiometa/ui';
+```
+
+**After (v2.x):**
+
+```html
+
+```
+
+```js
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+```
+
+### Replace `ScrollAnimationChild` with `ScrollAnimationTarget`
+
+The `ScrollAnimationChild` component has been renamed to `ScrollAnimationTarget`.
+
+```diff
+-
++
+ ...
+
+```
+
+```diff
+- import { ScrollAnimationChild } from '@studiometa/ui';
++ import { ScrollAnimationTarget } from '@studiometa/ui';
+```
+
+### Replace `ScrollAnimationChildWithEase` with `ScrollAnimationTarget`
+
+The `ScrollAnimationChildWithEase` component has been removed. Use `ScrollAnimationTarget` with the `dampFactor` option instead.
+
+**Before (v1.x):**
+
+```html
+
+```
+
+```js
+import { ScrollAnimationChildWithEase } from '@studiometa/ui';
+```
+
+**After (v2.x):**
+
+```html
+
+```
+
+```js
+import { ScrollAnimationTarget } from '@studiometa/ui';
+```
+
+::: tip
+Each `ScrollAnimationTarget` can have its own `dampFactor` value, allowing for different animation speeds within the same timeline.
+:::
+
+### Replace `ScrollAnimationWithEase` with `ScrollAnimationTimeline` and `ScrollAnimationTarget`
+
+The `ScrollAnimationWithEase` component has been removed. Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children that have the `dampFactor` option.
+
+**Before (v1.x):**
+
+```html
+
+```
+
+**After (v2.x):**
+
+```html
+
+```
+
+### Remove `animationScrollWithEase` decorator
+
+The `animationScrollWithEase` decorator has been removed without a direct replacement. If you were using it to create custom scroll animation components, extend `ScrollAnimationTarget` instead.
+
+**Before (v1.x):**
+
+```js
+import { Base } from '@studiometa/js-toolkit';
+import { animationScrollWithEase } from '@studiometa/ui';
+
+class MyScrollAnimation extends animationScrollWithEase(Base) {
+ // ...
+}
+```
+
+**After (v2.x):**
+
+```js
+import { ScrollAnimationTarget } from '@studiometa/ui';
+
+class MyScrollAnimation extends ScrollAnimationTarget {
+ static config = {
+ ...ScrollAnimationTarget.config,
+ name: 'MyScrollAnimation',
+ };
+
+ // Override methods as needed
+}
+```
diff --git a/packages/docs/vite.config.js b/packages/docs/vite.config.js
index 0aad506b..b875c356 100644
--- a/packages/docs/vite.config.js
+++ b/packages/docs/vite.config.js
@@ -1,8 +1,11 @@
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver';
import Components from 'unplugin-vue-components/vite';
import llmstxt from 'vitepress-plugin-llms';
+import sirv from 'sirv';
/**
* Import match as plain text.
@@ -26,8 +29,34 @@ function plainText(match) {
};
}
+/**
+ * Serve the playground static files from the public directory.
+ * This is needed because VitePress intercepts requests to /play/* and renders
+ * them as pages instead of serving the static files.
+ * @returns {import('vite').Plugin}
+ */
+function servePlayground() {
+ const publicDir = resolve(import.meta.dirname, 'public');
+ const playDir = resolve(publicDir, 'play');
+
+ return {
+ name: 'serve-playground',
+ configureServer(server) {
+ // Only add middleware if the play directory exists
+ if (!existsSync(playDir)) {
+ return;
+ }
+
+ // Serve files from public/play at /play
+ const serve = sirv(playDir, { dev: true, etag: true });
+ server.middlewares.use('/play', serve);
+ },
+ };
+}
+
const config = defineConfig({
plugins: [
+ servePlayground(),
llmstxt({
stripHTML: false,
}),
diff --git a/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts
index 0f7d22de..df8652b0 100644
--- a/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts
+++ b/packages/tests/ScrollAnimation/ScrollAnimation.spec.ts
@@ -7,7 +7,7 @@ import {
intersectionObserverAfterEachCallback,
} from '#test-utils';
-describe('ScrollAnimation', () => {
+describe('ScrollAnimation (deprecated)', () => {
let element: HTMLDivElement;
let targetElement: HTMLDivElement;
let animation: ScrollAnimation;
diff --git a/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts
index ff08fb6c..82078703 100644
--- a/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts
+++ b/packages/tests/ScrollAnimation/ScrollAnimationChild.spec.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ScrollAnimationChild } from '@studiometa/ui';
import { h, mount, destroy } from '#test-utils';
-describe('ScrollAnimationChild', () => {
+describe('ScrollAnimationChild (deprecated)', () => {
let element: HTMLDivElement;
let animation: ScrollAnimationChild;
diff --git a/packages/tests/ScrollAnimation/ScrollAnimationChildWithEase.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationChildWithEase.spec.ts
new file mode 100644
index 00000000..78b2e465
--- /dev/null
+++ b/packages/tests/ScrollAnimation/ScrollAnimationChildWithEase.spec.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { ScrollAnimationChildWithEase } from '@studiometa/ui';
+import { h, mount, destroy } from '#test-utils';
+
+describe('ScrollAnimationChildWithEase (deprecated)', () => {
+ let element: HTMLDivElement;
+ let animation: ScrollAnimationChildWithEase;
+
+ beforeEach(async () => {
+ element = h('div');
+ animation = new ScrollAnimationChildWithEase(element);
+ await mount(animation);
+ });
+
+ afterEach(async () => {
+ await destroy(animation);
+ });
+
+ it('should have the correct config', () => {
+ expect(ScrollAnimationChildWithEase.config.name).toBe('ScrollAnimationChildWithEase');
+ expect(ScrollAnimationChildWithEase.config.options.dampFactor.default).toBe(0.1);
+ expect(ScrollAnimationChildWithEase.config.options.dampPrecision.default).toBe(0.001);
+ });
+
+ it('should initialize with correct default damped values', () => {
+ expect(animation.dampedCurrent).toEqual({ x: 0, y: 0 });
+ expect(animation.dampedProgress).toEqual({ x: 0, y: 0 });
+ });
+
+ it('should have damping options accessible', () => {
+ expect(animation.$options.dampFactor).toBe(0.1);
+ expect(animation.$options.dampPrecision).toBe(0.001);
+ });
+
+ it('should override scrolledInView method', () => {
+ const mockProps = {
+ current: { x: 0.5, y: 0.8 },
+ dampedCurrent: { x: 0.4, y: 0.7 },
+ start: { x: 0, y: 0 },
+ end: { x: 1, y: 1 },
+ dampedProgress: { x: 0.4, y: 0.7 },
+ progress: { x: 0.5, y: 0.8 },
+ };
+
+ expect(() => animation.scrolledInView(mockProps)).not.toThrow();
+ });
+
+ it('should inherit from AbstractScrollAnimation', () => {
+ expect(animation.render).toBeDefined();
+ expect(animation.target).toBe(element);
+ expect(animation.playRange).toEqual([0, 1]);
+ });
+});
diff --git a/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts
index 60553096..b8d7df61 100644
--- a/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts
+++ b/packages/tests/ScrollAnimation/ScrollAnimationParent.spec.ts
@@ -7,7 +7,7 @@ import {
intersectionObserverAfterEachCallback,
} from '#test-utils';
-describe('ScrollAnimationParent', () => {
+describe('ScrollAnimationParent (deprecated)', () => {
let parentElement: HTMLDivElement;
let childElement1: HTMLDivElement;
let childElement2: HTMLDivElement;
diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts
new file mode 100644
index 00000000..1c851c64
--- /dev/null
+++ b/packages/tests/ScrollAnimation/ScrollAnimationTarget.spec.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { ScrollAnimationTarget } from '@studiometa/ui';
+import { h, mount, destroy } from '#test-utils';
+
+describe('ScrollAnimationTarget', () => {
+ let element: HTMLDivElement;
+ let animation: ScrollAnimationTarget;
+
+ beforeEach(async () => {
+ element = h('div');
+ animation = new ScrollAnimationTarget(element);
+ await mount(animation);
+ });
+
+ afterEach(async () => {
+ await destroy(animation);
+ });
+
+ it('should have the correct config', () => {
+ expect(ScrollAnimationTarget.config.name).toBe('ScrollAnimationTarget');
+ expect(ScrollAnimationTarget.config.options.dampFactor.default).toBe(0.1);
+ expect(ScrollAnimationTarget.config.options.dampPrecision.default).toBe(0.001);
+ });
+
+ it('should initialize with correct default damped values', () => {
+ expect(animation.dampedCurrent).toEqual({ x: 0, y: 0 });
+ expect(animation.dampedProgress).toEqual({ x: 0, y: 0 });
+ });
+
+ it('should have damping options accessible', () => {
+ expect(animation.$options.dampFactor).toBe(0.1);
+ expect(animation.$options.dampPrecision).toBe(0.001);
+ });
+
+ it('should override scrolledInView method', () => {
+ const mockProps = {
+ current: { x: 0.5, y: 0.8 },
+ dampedCurrent: { x: 0.4, y: 0.7 },
+ start: { x: 0, y: 0 },
+ end: { x: 1, y: 1 },
+ dampedProgress: { x: 0.4, y: 0.7 },
+ progress: { x: 0.5, y: 0.8 },
+ };
+
+ expect(() => animation.scrolledInView(mockProps)).not.toThrow();
+ });
+
+ it('should inherit from AbstractScrollAnimation', () => {
+ expect(animation.render).toBeDefined();
+ expect(animation.target).toBe(element);
+ expect(animation.playRange).toEqual([0, 1]);
+ });
+});
diff --git a/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts
new file mode 100644
index 00000000..bbbb9a49
--- /dev/null
+++ b/packages/tests/ScrollAnimation/ScrollAnimationTimeline.spec.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
+import { ScrollAnimationTimeline, ScrollAnimationTarget } from '@studiometa/ui';
+import { domScheduler } from '@studiometa/js-toolkit/utils';
+import {
+ h,
+ destroy,
+ mockIsIntersecting,
+ intersectionObserverBeforeAllCallback,
+ intersectionObserverAfterEachCallback,
+} from '#test-utils';
+
+describe('ScrollAnimationTimeline', () => {
+ let parentElement: HTMLDivElement;
+ let childElement1: HTMLDivElement;
+ let childElement2: HTMLDivElement;
+ let parent: ScrollAnimationTimeline;
+
+ beforeAll(() => {
+ intersectionObserverBeforeAllCallback();
+ });
+
+ afterEach(() => {
+ intersectionObserverAfterEachCallback();
+ });
+
+ beforeEach(async () => {
+ parentElement = h('div');
+ childElement1 = h('div', { 'data-component': 'ScrollAnimationTarget' });
+ childElement2 = h('div', { 'data-component': 'ScrollAnimationTarget' });
+
+ parentElement.appendChild(childElement1);
+ parentElement.appendChild(childElement2);
+
+ parent = new ScrollAnimationTimeline(parentElement);
+ await mockIsIntersecting(parentElement, true);
+ });
+
+ afterEach(async () => {
+ await mockIsIntersecting(parentElement, false);
+ });
+
+ it('should have the correct config', () => {
+ expect(ScrollAnimationTimeline.config.name).toBe('ScrollAnimationTimeline');
+ expect(ScrollAnimationTimeline.config.components.ScrollAnimationTarget).toBe(ScrollAnimationTarget);
+ });
+
+ it('should have ScrollAnimationTarget components', () => {
+ expect(parent.$children.ScrollAnimationTarget).toHaveLength(2);
+ expect(parent.$children.ScrollAnimationTarget[0]).toBeInstanceOf(ScrollAnimationTarget);
+ expect(parent.$children.ScrollAnimationTarget[1]).toBeInstanceOf(ScrollAnimationTarget);
+ });
+
+ it('should propagate scrolledInView to all children', () => {
+ const child1Spy = vi.spyOn(parent.$children.ScrollAnimationTarget[0], 'scrolledInView');
+ const child2Spy = vi.spyOn(parent.$children.ScrollAnimationTarget[1], 'scrolledInView');
+
+ const mockProps = {
+ current: { x: 0.5, y: 0.8 },
+ dampedCurrent: { x: 0.4, y: 0.7 },
+ start: { x: 0, y: 0 },
+ end: { x: 1, y: 1 },
+ dampedProgress: { x: 0.4, y: 0.7 },
+ progress: { x: 0.5, y: 0.8 },
+ };
+
+ parent.scrolledInView(mockProps);
+
+ expect(child1Spy).toHaveBeenCalledWith(mockProps);
+ expect(child2Spy).toHaveBeenCalledWith(mockProps);
+ });
+
+ it('should work with no children', async () => {
+ const emptyParent = new ScrollAnimationTimeline(h('div'));
+ await mockIsIntersecting(emptyParent.$el, true);
+
+ expect(emptyParent.$children.ScrollAnimationTarget).toHaveLength(0);
+
+ const mockProps = {
+ current: { x: 0.5, y: 0.8 },
+ dampedCurrent: { x: 0.4, y: 0.7 },
+ start: { x: 0, y: 0 },
+ end: { x: 1, y: 1 },
+ dampedProgress: { x: 0.4, y: 0.7 },
+ progress: { x: 0.5, y: 0.8 },
+ };
+
+ expect(() => emptyParent.scrolledInView(mockProps)).not.toThrow();
+
+ await mockIsIntersecting(emptyParent.$el, false);
+ });
+
+ it('should be extended from withScrolledInView(Base)', () => {
+ expect(parent.scrolledInView).toBeDefined();
+ });
+
+ it('should not share dampedProgress between children', async () => {
+ parentElement = h('div');
+ childElement1 = h('div', {
+ 'data-component': 'ScrollAnimationTarget',
+ 'data-option-damp-factor': '0.1',
+ });
+ childElement2 = h('div', {
+ 'data-component': 'ScrollAnimationTarget',
+ 'data-option-damp-factor': '1',
+ });
+
+ parentElement.appendChild(childElement1);
+ parentElement.appendChild(childElement2);
+
+ const timeline = new ScrollAnimationTimeline(parentElement);
+ await mockIsIntersecting(parentElement, true);
+
+ const child1 = timeline.$children.ScrollAnimationTarget[0];
+ const child2 = timeline.$children.ScrollAnimationTarget[1];
+
+ const child1RenderSpy = vi.spyOn(child1, 'render');
+ const child2RenderSpy = vi.spyOn(child2, 'render');
+
+ const mockProps = {
+ current: { x: 0, y: 100 },
+ start: { x: 0, y: 0 },
+ end: { x: 0, y: 1000 },
+ progress: { x: 0, y: 0.1 },
+ dampedCurrent: { x: 0, y: 0 },
+ dampedProgress: { x: 0, y: 0 },
+ };
+
+ timeline.scrolledInView(mockProps as any);
+
+ // Wait for domScheduler
+ await new Promise((resolve) => domScheduler.read(() => domScheduler.write(resolve)));
+
+ expect(child1RenderSpy).toHaveBeenCalledWith(0.01);
+ expect(child2RenderSpy).toHaveBeenCalledWith(0.1);
+
+ await mockIsIntersecting(parentElement, false);
+ await destroy(timeline);
+ });
+});
diff --git a/packages/tests/ScrollAnimation/index.spec.ts b/packages/tests/ScrollAnimation/index.spec.ts
index 9271a828..f9659a72 100644
--- a/packages/tests/ScrollAnimation/index.spec.ts
+++ b/packages/tests/ScrollAnimation/index.spec.ts
@@ -1,6 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
AbstractScrollAnimation,
+ ScrollAnimationTimeline,
+ ScrollAnimationTarget,
+ // Deprecated exports
ScrollAnimation,
ScrollAnimationChild,
ScrollAnimationChildWithEase,
@@ -15,33 +18,44 @@ describe('ScrollAnimation exports', () => {
expect(AbstractScrollAnimation.config.name).toBe('AbstractScrollAnimation');
});
- it('should export ScrollAnimation', () => {
+ it('should export ScrollAnimationTimeline', () => {
+ expect(ScrollAnimationTimeline).toBeDefined();
+ expect(ScrollAnimationTimeline.config.name).toBe('ScrollAnimationTimeline');
+ });
+
+ it('should export ScrollAnimationTarget', () => {
+ expect(ScrollAnimationTarget).toBeDefined();
+ expect(ScrollAnimationTarget.config.name).toBe('ScrollAnimationTarget');
+ });
+
+ // Deprecated exports - kept for backward compatibility
+ it('should export ScrollAnimation (deprecated)', () => {
expect(ScrollAnimation).toBeDefined();
expect(ScrollAnimation.config.name).toBe('ScrollAnimation');
});
- it('should export ScrollAnimationChild', () => {
+ it('should export ScrollAnimationChild (deprecated)', () => {
expect(ScrollAnimationChild).toBeDefined();
expect(ScrollAnimationChild.config.name).toBe('AbstractScrollAnimation');
});
- it('should export ScrollAnimationChildWithEase', () => {
+ it('should export ScrollAnimationChildWithEase (deprecated)', () => {
expect(ScrollAnimationChildWithEase).toBeDefined();
expect(ScrollAnimationChildWithEase.config.name).toBe('ScrollAnimationChildWithEase');
});
- it('should export ScrollAnimationParent', () => {
+ it('should export ScrollAnimationParent (deprecated)', () => {
expect(ScrollAnimationParent).toBeDefined();
expect(ScrollAnimationParent.config.name).toBe('ScrollAnimationParent');
});
- it('should export ScrollAnimationWithEase', () => {
+ it('should export ScrollAnimationWithEase (deprecated)', () => {
expect(ScrollAnimationWithEase).toBeDefined();
expect(ScrollAnimationWithEase.config.name).toBe('ScrollAnimationWithEase');
});
- it('should export animationScrollWithEase decorator', () => {
+ it('should export animationScrollWithEase decorator (deprecated)', () => {
expect(animationScrollWithEase).toBeDefined();
expect(typeof animationScrollWithEase).toBe('function');
});
-});
\ No newline at end of file
+});
diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts
index 2f426fb0..151da8b6 100644
--- a/packages/tests/index.spec.ts
+++ b/packages/tests/index.spec.ts
@@ -49,6 +49,8 @@ test('components exports', () => {
"ScrollAnimationChild",
"ScrollAnimationChildWithEase",
"ScrollAnimationParent",
+ "ScrollAnimationTarget",
+ "ScrollAnimationTimeline",
"ScrollAnimationWithEase",
"ScrollReveal",
"Sentinel",
@@ -65,6 +67,7 @@ test('components exports', () => {
"Transition",
"animationScrollWithEase",
"withDeprecation",
+ "withScrollAnimationDebug",
"withTransition",
]
`);
diff --git a/packages/tests/vitest.config.ts b/packages/tests/vitest.config.ts
index 85200df6..c3a3d4d0 100644
--- a/packages/tests/vitest.config.ts
+++ b/packages/tests/vitest.config.ts
@@ -14,6 +14,6 @@ export default defineConfig({
include: ['ui/**/*.ts'],
exclude: ['**/tests/**/*.ts', '**/ui/**/index.ts'],
},
- exclude: ['**/.symfony/vendor/**'],
+ exclude: ['**/.symfony/vendor/**', '**/api/vendor/**'],
},
});
diff --git a/packages/ui/ScrollAnimation/ScrollAnimation.ts b/packages/ui/ScrollAnimation/ScrollAnimation.ts
index 36a45413..80d01a24 100644
--- a/packages/ui/ScrollAnimation/ScrollAnimation.ts
+++ b/packages/ui/ScrollAnimation/ScrollAnimation.ts
@@ -1,5 +1,6 @@
import { withScrolledInView } from '@studiometa/js-toolkit';
import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';
+import { isDev } from '@studiometa/js-toolkit/utils';
import { AbstractScrollAnimation } from './AbstractScrollAnimation.js';
export interface ScrollAnimationProps extends BaseProps {
@@ -10,6 +11,8 @@ export interface ScrollAnimationProps extends BaseProps {
/**
* ScrollAnimation class.
+ *
+ * @deprecated Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.
* @link https://ui.studiometa.dev/components/ScrollAnimation/
*/
export class ScrollAnimation<
@@ -32,4 +35,16 @@ export class ScrollAnimation<
get target(): HTMLElement {
return this.$refs.target;
}
+
+ /**
+ * Display deprecation warning.
+ */
+ mounted() {
+ if (isDev) {
+ console.warn(
+ `The ${this.$options.name} component is deprecated.`,
+ '\nUse `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.',
+ );
+ }
+ }
}
diff --git a/packages/ui/ScrollAnimation/ScrollAnimationChild.ts b/packages/ui/ScrollAnimation/ScrollAnimationChild.ts
index 40f9547f..7aa2ed49 100644
--- a/packages/ui/ScrollAnimation/ScrollAnimationChild.ts
+++ b/packages/ui/ScrollAnimation/ScrollAnimationChild.ts
@@ -4,7 +4,7 @@ import type {
ScrollInViewProps,
WithScrolledInViewProps,
} from '@studiometa/js-toolkit';
-import { damp, clamp01, domScheduler } from '@studiometa/js-toolkit/utils';
+import { damp, clamp01, domScheduler, isDev } from '@studiometa/js-toolkit/utils';
import { AbstractScrollAnimation } from './AbstractScrollAnimation.js';
export interface ScrollAnimationChildProps extends BaseProps {
@@ -32,6 +32,8 @@ function updateProps(
/**
* ScrollAnimationChild class.
+ *
+ * @deprecated Use `ScrollAnimationTarget` instead.
*/
export class ScrollAnimationChild
extends AbstractScrollAnimation<
T & ScrollAnimationChildProps
@@ -71,6 +73,18 @@ export class ScrollAnimationChild extends Abstr
y: 0,
};
+ /**
+ * Display deprecation warning.
+ */
+ mounted() {
+ if (isDev) {
+ console.warn(
+ `The ${this.$options.name} component is deprecated.`,
+ '\nUse `ScrollAnimationTarget` instead.',
+ );
+ }
+ }
+
/**
* Compute local damped progress.
*/
@@ -79,12 +93,14 @@ export class ScrollAnimationChild extends Abstr
const { dampFactor, dampPrecision } = this.$options;
updateProps(this, props, dampFactor, dampPrecision, 'x');
updateProps(this, props, dampFactor, dampPrecision, 'y');
- props.dampedCurrent = this.dampedCurrent;
- props.dampedProgress = this.dampedProgress;
});
domScheduler.write(() => {
- super.scrolledInView(props);
+ super.scrolledInView({
+ ...props,
+ dampedCurrent: this.dampedCurrent,
+ dampedProgress: this.dampedProgress,
+ });
});
}
}
diff --git a/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts b/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts
index 39170b59..00c17ecb 100644
--- a/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts
+++ b/packages/ui/ScrollAnimation/ScrollAnimationChildWithEase.ts
@@ -1,9 +1,12 @@
import type { BaseConfig } from '@studiometa/js-toolkit';
+import { isDev } from '@studiometa/js-toolkit/utils';
import { ScrollAnimationChild } from './ScrollAnimationChild.js';
import { animationScrollWithEase } from './animationScrollWithEase.js';
/**
- * ScrollAnimationChild class.
+ * ScrollAnimationChildWithEase class.
+ *
+ * @deprecated Use `ScrollAnimationTarget` instead.
*/
export class ScrollAnimationChildWithEase extends animationScrollWithEase(ScrollAnimationChild) {
/**
@@ -13,4 +16,16 @@ export class ScrollAnimationChildWithEase extends animationScrollWithEase(Scroll
...ScrollAnimationChild.config,
name: 'ScrollAnimationChildWithEase',
};
+
+ /**
+ * Display deprecation warning.
+ */
+ mounted() {
+ if (isDev) {
+ console.warn(
+ `The ${this.$options.name} component is deprecated.`,
+ '\nUse `ScrollAnimationTarget` instead.',
+ );
+ }
+ }
}
diff --git a/packages/ui/ScrollAnimation/ScrollAnimationParent.ts b/packages/ui/ScrollAnimation/ScrollAnimationParent.ts
index a9885d10..d7cb3fef 100644
--- a/packages/ui/ScrollAnimation/ScrollAnimationParent.ts
+++ b/packages/ui/ScrollAnimation/ScrollAnimationParent.ts
@@ -1,5 +1,6 @@
import { Base, ScrollInViewProps, withScrolledInView } from '@studiometa/js-toolkit';
import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';
+import { isDev } from '@studiometa/js-toolkit/utils';
import { ScrollAnimationChild } from './ScrollAnimationChild.js';
export interface ScrollAnimationParentProps extends BaseProps {
@@ -10,6 +11,8 @@ export interface ScrollAnimationParentProps extends BaseProps {
/**
* ScrollAnimationParent class.
+ *
+ * @deprecated Use `ScrollAnimationTimeline` instead.
*/
export class ScrollAnimationParent extends withScrolledInView(
Base,
@@ -25,6 +28,18 @@ export class ScrollAnimationParent extends with
},
};
+ /**
+ * Display deprecation warning.
+ */
+ mounted() {
+ if (isDev) {
+ console.warn(
+ `The ${this.$options.name} component is deprecated.`,
+ '\nUse `ScrollAnimationTimeline` instead.',
+ );
+ }
+ }
+
/**
* Scrolled in view hook.
*/
diff --git a/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts
new file mode 100644
index 00000000..15d8837e
--- /dev/null
+++ b/packages/ui/ScrollAnimation/ScrollAnimationTarget.ts
@@ -0,0 +1,109 @@
+import type {
+ BaseConfig,
+ BaseProps,
+ ScrollInViewProps,
+ WithScrolledInViewProps,
+} from '@studiometa/js-toolkit';
+import { damp, clamp01, domScheduler } from '@studiometa/js-toolkit/utils';
+import { AbstractScrollAnimation } from './AbstractScrollAnimation.js';
+
+export interface ScrollAnimationTargetProps extends BaseProps {
+ $options: WithScrolledInViewProps['$options'];
+}
+
+function updateProps(
+ // eslint-disable-next-line no-use-before-define
+ that: ScrollAnimationTarget,
+ props: ScrollInViewProps,
+ dampFactor: number,
+ dampPrecision: number,
+ axis: 'x' | 'y' = 'x',
+) {
+ that.dampedCurrent[axis] = damp(
+ props.current[axis],
+ that.dampedCurrent[axis],
+ dampFactor,
+ dampPrecision,
+ );
+ that.dampedProgress[axis] = clamp01(
+ (that.dampedCurrent[axis] - props.start[axis]) / (props.end[axis] - props.start[axis]),
+ );
+}
+
+/**
+ * ScrollAnimationTarget class.
+ *
+ * A component that animates based on scroll progress from a parent `ScrollAnimationTimeline`.
+ * Each target can have its own animation keyframes and play range.
+ *
+ * @example
+ * ```html
+ *
+ *
+ * Animated content
+ *
+ *
+ * ```
+ */
+export class ScrollAnimationTarget extends AbstractScrollAnimation<
+ T & ScrollAnimationTargetProps
+> {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ ...AbstractScrollAnimation.config,
+ name: 'ScrollAnimationTarget',
+ options: {
+ ...AbstractScrollAnimation.config.options,
+ dampFactor: {
+ type: Number,
+ default: 0.1,
+ },
+ dampPrecision: {
+ type: Number,
+ default: 0.001,
+ },
+ },
+ };
+
+ /**
+ * Local damped current values.
+ */
+ dampedCurrent: ScrollInViewProps['dampedCurrent'] = {
+ x: 0,
+ y: 0,
+ };
+
+ /**
+ * Local damped progress.
+ */
+ dampedProgress: ScrollInViewProps['dampedCurrent'] = {
+ x: 0,
+ y: 0,
+ };
+
+ /**
+ * Compute local damped progress.
+ */
+ scrolledInView(props: ScrollInViewProps) {
+ domScheduler.read(() => {
+ const { dampFactor, dampPrecision } = this.$options;
+ updateProps(this, props, dampFactor, dampPrecision, 'x');
+ updateProps(this, props, dampFactor, dampPrecision, 'y');
+ });
+
+ domScheduler.write(() => {
+ super.scrolledInView({
+ ...props,
+ dampedCurrent: this.dampedCurrent,
+ dampedProgress: this.dampedProgress,
+ });
+ });
+ }
+}
diff --git a/packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts b/packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts
new file mode 100644
index 00000000..bfbc017f
--- /dev/null
+++ b/packages/ui/ScrollAnimation/ScrollAnimationTimeline.ts
@@ -0,0 +1,48 @@
+import { Base, ScrollInViewProps, withScrolledInView } from '@studiometa/js-toolkit';
+import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';
+import { ScrollAnimationTarget } from './ScrollAnimationTarget.js';
+
+export interface ScrollAnimationTimelineProps extends BaseProps {
+ $children: {
+ ScrollAnimationTarget: ScrollAnimationTarget[];
+ };
+}
+
+/**
+ * ScrollAnimationTimeline class.
+ *
+ * A component that manages scroll-based animations for its children.
+ * Use with `ScrollAnimationTarget` children components.
+ *
+ * @example
+ * ```html
+ *
+ * ```
+ */
+export class ScrollAnimationTimeline extends withScrolledInView(
+ Base,
+ {},
+) {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ name: 'ScrollAnimationTimeline',
+ components: {
+ ScrollAnimationTarget,
+ },
+ };
+
+ /**
+ * Scrolled in view hook.
+ */
+ scrolledInView(props: ScrollInViewProps) {
+ for (const child of this.$children.ScrollAnimationTarget) {
+ child.scrolledInView(props);
+ }
+ }
+}
diff --git a/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts b/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts
index 076672c3..98b5d62b 100644
--- a/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts
+++ b/packages/ui/ScrollAnimation/ScrollAnimationWithEase.ts
@@ -1,9 +1,12 @@
import { type BaseConfig } from '@studiometa/js-toolkit';
+import { isDev } from '@studiometa/js-toolkit/utils';
import { ScrollAnimation } from './ScrollAnimation.js';
import { animationScrollWithEase } from './animationScrollWithEase.js';
/**
- * ScrollAnimation class.
+ * ScrollAnimationWithEase class.
+ *
+ * @deprecated Use `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.
*/
export class ScrollAnimationWithEase extends animationScrollWithEase(ScrollAnimation) {
/**
@@ -13,4 +16,16 @@ export class ScrollAnimationWithEase extends animationScrollWithEase(ScrollAnima
...ScrollAnimation.config,
name: 'ScrollAnimationWithEase',
};
+
+ /**
+ * Display deprecation warning.
+ */
+ mounted() {
+ if (isDev) {
+ console.warn(
+ `The ${this.$options.name} component is deprecated.`,
+ '\nUse `ScrollAnimationTimeline` with `ScrollAnimationTarget` children instead.',
+ );
+ }
+ }
}
diff --git a/packages/ui/ScrollAnimation/animationScrollWithEase.ts b/packages/ui/ScrollAnimation/animationScrollWithEase.ts
index ba1749c8..3463ca87 100644
--- a/packages/ui/ScrollAnimation/animationScrollWithEase.ts
+++ b/packages/ui/ScrollAnimation/animationScrollWithEase.ts
@@ -1,5 +1,5 @@
import type { BaseConfig, BaseProps, BaseDecorator, BaseInterface } from '@studiometa/js-toolkit';
-import { ease } from '@studiometa/js-toolkit/utils';
+import { ease, isDev } from '@studiometa/js-toolkit/utils';
import type { AbstractScrollAnimation } from './AbstractScrollAnimation.js';
const regex = /ease([A-Z])/;
@@ -20,6 +20,8 @@ export interface AnimationScrollWithEaseInterface extends BaseInterface {}
/**
* Extend a `ScrollAnimation` component to use easings.
+ *
+ * @deprecated This decorator is deprecated. Easing can be applied directly via CSS or animation options.
*/
export function animationScrollWithEase(
ScrollAnimation: typeof AbstractScrollAnimation,
@@ -40,6 +42,18 @@ export function animationScrollWithEase(
},
};
+ /**
+ * Display a deprecation warning.
+ */
+ mounted() {
+ if (isDev) {
+ console.warn(
+ `The animationScrollWithEase decorator is deprecated.`,
+ '\nEasing can be applied directly via CSS or animation options.',
+ );
+ }
+ }
+
/**
* Eases the progress value.
*/
diff --git a/packages/ui/ScrollAnimation/index.ts b/packages/ui/ScrollAnimation/index.ts
index ac1f90bd..2ead62d0 100644
--- a/packages/ui/ScrollAnimation/index.ts
+++ b/packages/ui/ScrollAnimation/index.ts
@@ -1,4 +1,9 @@
export * from './AbstractScrollAnimation.js';
+export * from './ScrollAnimationTimeline.js';
+export * from './ScrollAnimationTarget.js';
+export * from './withScrollAnimationDebug.js';
+
+// Deprecated exports
export * from './animationScrollWithEase.js';
export * from './ScrollAnimation.js';
export * from './ScrollAnimationWithEase.js';
diff --git a/packages/ui/ScrollAnimation/withScrollAnimationDebug.ts b/packages/ui/ScrollAnimation/withScrollAnimationDebug.ts
new file mode 100644
index 00000000..2cc7ecab
--- /dev/null
+++ b/packages/ui/ScrollAnimation/withScrollAnimationDebug.ts
@@ -0,0 +1,455 @@
+import type {
+ Base,
+ BaseProps,
+ BaseConfig,
+ BaseInterface,
+ BaseDecorator,
+ ScrollInViewProps,
+} from '@studiometa/js-toolkit';
+import { createElement } from '@studiometa/js-toolkit/utils';
+
+/**
+ * Debug marker element interface.
+ */
+interface DebugElements {
+ wrapper: HTMLElement;
+ startMarker: HTMLElement;
+ endMarker: HTMLElement;
+ progress: HTMLElement;
+ progressBar: HTMLElement;
+ progressText: HTMLElement;
+}
+
+/**
+ * Debug colors for different timelines.
+ */
+const debugColors = [
+ '#8b5cf6', // violet
+ '#3b82f6', // blue
+ '#10b981', // emerald
+ '#f59e0b', // amber
+ '#ef4444', // red
+ '#ec4899', // pink
+ '#06b6d4', // cyan
+ '#84cc16', // lime
+];
+
+/**
+ * Get debug color for a given index.
+ */
+function getDebugColor(index: number): string {
+ return debugColors[(index - 1) % debugColors.length];
+}
+
+/**
+ * Debug styles.
+ */
+const debugStyles = `
+ .scroll-animation-debug {
+ --debug-color: #8b5cf6;
+ position: fixed;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 80px;
+ pointer-events: none;
+ z-index: 99999;
+ font-family: ui-monospace, monospace;
+ font-size: 10px;
+ }
+ .scroll-animation-debug__marker {
+ position: absolute;
+ right: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 6px;
+ color: white;
+ white-space: nowrap;
+ transform: translateY(-50%);
+ }
+ .scroll-animation-debug__marker::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ right: 100%;
+ width: 100vw;
+ height: 1px;
+ background: var(--debug-color);
+ }
+ .scroll-animation-debug__marker--start {
+ background: var(--debug-color);
+ }
+ .scroll-animation-debug__marker--end {
+ background: var(--debug-color);
+ opacity: 0.7;
+ }
+ .scroll-animation-debug__outline {
+ position: absolute;
+ inset: 0;
+ border: 2px dashed var(--debug-color);
+ pointer-events: none;
+ z-index: 99998;
+ }
+ .scroll-animation-debug-progress-container {
+ position: fixed;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ pointer-events: none;
+ z-index: 99999;
+ font-family: ui-monospace, monospace;
+ font-size: 10px;
+ }
+ .scroll-animation-debug__progress {
+ --debug-color: #8b5cf6;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ }
+ .scroll-animation-debug__progress-bar {
+ width: 4px;
+ height: 60px;
+ background: rgba(139, 92, 246, 0.2);
+ border-radius: 2px;
+ overflow: hidden;
+ }
+ .scroll-animation-debug__progress-fill {
+ width: 100%;
+ background: var(--debug-color);
+ border-radius: 2px;
+ transition: height 0.1s ease-out;
+ }
+ .scroll-animation-debug__progress-text {
+ color: var(--debug-color);
+ font-weight: bold;
+ width: 3ch;
+ text-align: center;
+ }
+`;
+
+/**
+ * Shared style element.
+ */
+let styleElement: HTMLStyleElement | null = null;
+
+/**
+ * Shared progress container.
+ */
+let progressContainer: HTMLElement | null = null;
+
+/**
+ * Debug instance counter.
+ */
+let debugInstanceCount = 0;
+
+/**
+ * Debug ID counter.
+ */
+let debugIdCounter = 0;
+
+/**
+ * Inject debug styles if not already injected.
+ */
+function injectDebugStyles() {
+ if (!styleElement) {
+ styleElement = createElement('style', [debugStyles]) as HTMLStyleElement;
+ document.head.appendChild(styleElement);
+ }
+}
+
+/**
+ * Remove debug styles if no more instances.
+ */
+function removeDebugStyles() {
+ if (styleElement && debugInstanceCount === 0) {
+ styleElement.remove();
+ styleElement = null;
+ }
+}
+
+/**
+ * Get or create the shared progress container.
+ */
+function getProgressContainer(): HTMLElement {
+ if (!progressContainer) {
+ progressContainer = createElement('div', {
+ class: 'scroll-animation-debug-progress-container',
+ });
+ document.body.appendChild(progressContainer);
+ }
+ return progressContainer;
+}
+
+/**
+ * Remove progress container if empty.
+ */
+function cleanupProgressContainer() {
+ if (progressContainer && progressContainer.children.length === 0) {
+ progressContainer.remove();
+ progressContainer = null;
+ }
+}
+
+/**
+ * Named offset values mapping.
+ */
+const namedOffsets: Record = {
+ start: 0,
+ center: 0.5,
+ end: 1,
+};
+
+/**
+ * Parse an offset value to a ratio (0-1).
+ */
+function parseOffsetValue(value: string): number {
+ if (namedOffsets[value] !== undefined) {
+ return namedOffsets[value];
+ }
+ if (value.endsWith('%')) {
+ return Number.parseFloat(value) / 100;
+ }
+ return Number.parseFloat(value) || 0;
+}
+
+/**
+ * Parse the offset option to get viewport positions.
+ * Format: " / "
+ */
+function parseOffset(offset: string): { viewportStart: number; viewportEnd: number } {
+ const parts = offset.split('/').map((part) => part.trim().split(' '));
+ const viewportStart = parseOffsetValue(parts[0]?.[1] || 'end');
+ const viewportEnd = parseOffsetValue(parts[1]?.[1] || 'start');
+ return { viewportStart, viewportEnd };
+}
+
+/**
+ * Create debug marker elements.
+ */
+function createDebugElements(color: string): DebugElements {
+ const startMarker = createElement(
+ 'div',
+ { class: 'scroll-animation-debug__marker scroll-animation-debug__marker--start' },
+ [createElement('span', ['start'])],
+ );
+
+ const endMarker = createElement(
+ 'div',
+ { class: 'scroll-animation-debug__marker scroll-animation-debug__marker--end' },
+ [createElement('span', ['end'])],
+ );
+
+ const wrapper = createElement('div', { class: 'scroll-animation-debug' }, [
+ startMarker,
+ endMarker,
+ ]) as HTMLElement;
+ wrapper.style.setProperty('--debug-color', color);
+
+ const progressBar = createElement('div', { class: 'scroll-animation-debug__progress-fill' });
+ const progressText = createElement('span', { class: 'scroll-animation-debug__progress-text' }, [
+ '0%',
+ ]);
+
+ const progress = createElement('div', { class: 'scroll-animation-debug__progress' }, [
+ createElement('div', { class: 'scroll-animation-debug__progress-bar' }, [progressBar]),
+ progressText,
+ ]) as HTMLElement;
+ progress.style.setProperty('--debug-color', color);
+
+ return {
+ wrapper,
+ startMarker,
+ endMarker,
+ progress,
+ progressBar,
+ progressText,
+ };
+}
+
+/**
+ * Create outline element for the timeline.
+ */
+function createOutlineElement(color: string): HTMLElement {
+ const outline = createElement('div', {
+ class: 'scroll-animation-debug__outline',
+ }) as HTMLElement;
+ outline.style.setProperty('--debug-color', color);
+ return outline;
+}
+
+export interface WithScrollAnimationDebugProps extends BaseProps {
+ $options: {
+ debug: boolean;
+ offset: string;
+ };
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface WithScrollAnimationDebugInterface extends BaseInterface {}
+
+/**
+ * Add debug capabilities to a ScrollAnimationTimeline component.
+ *
+ * @example
+ * ```js
+ * import { ScrollAnimationTimeline } from '@studiometa/ui';
+ * import { withScrollAnimationDebug } from '@studiometa/ui/ScrollAnimation/withScrollAnimationDebug';
+ *
+ * class App extends Base {
+ * static config = {
+ * name: 'App',
+ * components: {
+ * ScrollAnimationTimeline: withScrollAnimationDebug(ScrollAnimationTimeline),
+ * },
+ * };
+ * }
+ * ```
+ */
+export function withScrollAnimationDebug(
+ BaseClass: typeof Base,
+): BaseDecorator {
+ /**
+ * Class.
+ */
+ class WithScrollAnimationDebug extends BaseClass<
+ T & WithScrollAnimationDebugProps
+ > {
+ static config: BaseConfig = {
+ ...BaseClass.config,
+ name: BaseClass.config.name,
+ options: {
+ ...BaseClass.config.options,
+ debug: Boolean,
+ },
+ };
+
+ /**
+ * Debug elements.
+ */
+ private __debugElements: DebugElements | null = null;
+
+ /**
+ * Debug outline element.
+ */
+ private __debugOutline: HTMLElement | null = null;
+
+ /**
+ * Debug ID.
+ */
+ private __debugId: string = '';
+
+ /**
+ * Mounted hook.
+ */
+ mounted() {
+ // @ts-expect-error - Calling parent method
+ if (super.mounted) super.mounted();
+ if (this.$options.debug) {
+ this.__initDebug();
+ }
+ }
+
+ /**
+ * Destroyed hook.
+ */
+ destroyed() {
+ this.__destroyDebug();
+ // @ts-expect-error - Calling parent method
+ if (super.destroyed) super.destroyed();
+ }
+
+ /**
+ * Initialize debug elements.
+ */
+ __initDebug() {
+ debugIdCounter += 1;
+ debugInstanceCount += 1;
+ this.__debugId = `timeline-${debugIdCounter}`;
+
+ const color = getDebugColor(debugIdCounter);
+
+ // Inject styles
+ injectDebugStyles();
+
+ // Create and append debug markers
+ this.__debugElements = createDebugElements(color);
+ document.body.appendChild(this.__debugElements.wrapper);
+
+ // Append progress to shared container
+ const container = getProgressContainer();
+ container.appendChild(this.__debugElements.progress);
+
+ // Position markers based on offset option
+ const { viewportStart, viewportEnd } = parseOffset(this.$options.offset);
+ const viewportHeight = window.innerHeight;
+
+ this.__debugElements.startMarker.style.top = `${viewportStart * viewportHeight}px`;
+ this.__debugElements.endMarker.style.top = `${viewportEnd * viewportHeight}px`;
+
+ // Create and append outline
+ this.__debugOutline = createOutlineElement(color);
+ const position = getComputedStyle(this.$el).position;
+ if (position === 'static') {
+ (this.$el as HTMLElement).style.position = 'relative';
+ }
+ this.$el.appendChild(this.__debugOutline);
+ }
+
+ /**
+ * Destroy debug elements.
+ */
+ __destroyDebug() {
+ if (this.__debugElements) {
+ this.__debugElements.wrapper.remove();
+ this.__debugElements.progress.remove();
+ this.__debugElements = null;
+ }
+
+ if (this.__debugOutline) {
+ this.__debugOutline.remove();
+ this.__debugOutline = null;
+ }
+
+ if (this.$options.debug) {
+ debugInstanceCount -= 1;
+ cleanupProgressContainer();
+ removeDebugStyles();
+ }
+ }
+
+ /**
+ * Update debug progress.
+ */
+ __updateDebug(props: ScrollInViewProps) {
+ if (!this.__debugElements) return;
+
+ const { progressBar, progressText } = this.__debugElements;
+
+ // Update progress
+ const progress = Math.round(props.dampedProgress.y * 100);
+ progressBar.style.height = `${progress}%`;
+ progressText.textContent = `${progress}%`;
+ }
+
+ /**
+ * Scrolled in view hook.
+ */
+ scrolledInView(props: ScrollInViewProps) {
+ if (this.$options.debug) {
+ this.__updateDebug(props);
+ }
+
+ // @ts-expect-error - Calling parent method
+ super.scrolledInView(props);
+ }
+ }
+
+ // @ts-ignore
+ return WithScrollAnimationDebug;
+}