w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.52k stars 673 forks source link

[web-animations] proposal idea: animating non-element JS objects, `ObjectKeyframeEffect` and `Animator` API, or similar #9974

Open trusktr opened 9 months ago

trusktr commented 9 months ago

One aspect of Web Animations that is missing, and that requires people to abandon the idea of using Web Animations in favor of JavaScript libraries like Tween.js (I'm a maintainer), is that the current design allows animating only DOM element styles (unless cumbersome and non-ideal workarounds are applied).

Current problem:

Suppose we are creating WebGL-powered graphics using JavaScript. The JavaScript objects are not DOM elements, and we want to animate them.

This does not work:

const someObject = {
  position: {x: 0, y: 0, z: 0} // Suppose this represents the position of an object in 3D space.
}

const keyframes = new KeyframeEffect(someObject.position, [{x: 0}, {x: 200}], 3000)
const animation = new Animation(keyframes, document.timeline)

animation.play()

// render the WebGL content while the animation is playing
requestAnimationFrame(function loop() {
  renderScene(someObject, camera) // hypothetically render the scene 
  if (animation.playState !== 'finished') requestAnimationFrame(loop)
})

Besides plain JS objects, Custom Elements are proliferating. Custom Elements can have plain JS properties too, and users may desire to animate these properties. Under the hood, a custom element's properties could map to CSS, WebGL, canvas 2D, plot or chart value, etc. Being able to animate custom element JS properties (as opposed to only styles) would be highly useful.

As a simple real life example, I work on Lume that provides custom elements for 3D, and here's a very basic animation of a number property on a Lume element:

<lume-scene webgl>
  <lume-camera-rig></lume-camera-rig>
  <lume-point-light position="300 300 300"></lume-point-light>
  <lume-box id="myBox" size="10 10 10" opacity="0.9"></lume-box>
</lume-scene>

<script type="module">
    import "lume"

    const box = document.querySelector('#box')

    requestAnimationFrame(function loop() {
        console.log((box.opacity -= 0.005))
        if (box.opacity > 0) requestAnimationFrame(loop)
        else box.opacity = 0
    })
</script>

Live example on CodePen

It would be nice to be able to use the Web Animations API for such use cases not involving style, but still highly relevant to HTML elements (custom elements). Animating the opacity value of the custom <lume-box> element could look like the following:

const box = document.querySelector('#box')
const animator = new Animator(box)
const animation = animator.animate([{opacity: 1}, {opacity: 0}], 3000) // similar to `element.animate()`, more on that below
// done! (no need for manual requestAnimationFrame with Lume elements)

Current workarounds

Here are the current workarounds:

  1. Don't use Web Animations API, instead use Tween.js for example. This workaround is not ideal because we want to embrace what the web gives us, instead of dropping it in favor of additional libraries.

    npm install @tweenjs/tween.js
  2. Animate a hidden DOM element, and read properties from its computed style. The downsides of this are that it is cumbersome and hacky, plus incurs unnecessary performance cost due to cycling the browser's CSS engine and obtaining values from the element's computed styles.

    const someObject = {
     position: {x: 0, y: 0, z: 0} // Suppose this represents the position of an object in 3D space.
    }
    
    const dummy = document.createElement('div')
    dummy.style.display = 'none' // hide it so the browser avoids spending resources rendering it.
    document.body.append(dummy)
    
    const keyframes = new KeyframeEffect(dummy, [{translate: '0px'}, {translate: '200px'}], 3000)
    const animation = new Animation(keyframes, document.timeline)
    
    animation.play()
    
    const dummyStyle = getComputedStyle(dummy)
    
    // render the WebGL content while the animation is playing
    requestAnimationFrame(function loop() {
     someObject.position.x = parseFloat(dummyStyle.translate) || 200
     console.log(someObject.position.x)
     renderScene(someObject, camera) // hypothetically render the scene
     if (animation.playState !== 'finished') requestAnimationFrame(loop)
    })

    Live example on CodePen

Proposal 1, keyframe effects for plain JS objects

Idea 1:

The usage of ObjectKeyframeEffect would look like this:

const someObject = {
  position: {x: 0, y: 0, z: 0} // Suppose this represents the position of an object in 3D space.
}

const keyframes = new ObjectKeyframeEffect(someObject.position, [{x: 0}, {x: 200}], 3000)
const animation = new Animation(keyframes, document.timeline)

animation.play()

// render the WebGL content while the animation is playing
requestAnimationFrame(function loop() {
  renderScene(someObject, camera) // hypothetically render the scene 
  if (animation.playState !== 'finished') requestAnimationFrame(loop)
})

Idea 2:

This would work exactly like in the very first example above.

Being more explicit with specific classes also means that the disctinction can be more clear in non-browser runtimes. For example, a Node.js library, or Node-like JS runtime, could provide ObjectKeyframeEffect but not StyleKeyframeEffect. Alternatively, a non-browser environment can also accept less types of objects if using a single class (for example, accept only JS objects, but not elements because there are no elements).

Proposal 2, Animator

Idea 1

This is a beginning exploration of an idea for animating anything, not just DOM elements.

A new Animator class would allow wrapping any types of objects, and the wrapper instance would have methods like .animate() that are similar to the current Element.animate().

For animating plain JS objects, it could look like the following:

const someObject = {
  position: {x: 0, y: 0, z: 0} // Suppose this represents the position of an object in 3D space.
}

const animator = new ObjectAnimator(someObject)
const animation = animator.animate([{x: 0}, {x: 200}], 3000)
const animation2 = animator.animate([{y: 0}, {y: 400}], 4000)

// render the WebGL content while the animation is playing
requestAnimationFrame(function loop() {
  renderScene(someObject, camera) // hypothetically render the scene 
  if (animation.playState !== 'finished' || animation2.playState !== 'finished') requestAnimationFrame(loop)
})

There'd also be similar ElementAnimator or StyleAnimator classes, where for example StyleAnimator could accept a CSSStyleRule or similar.

Idea 2

Similar to with Proposal 1, maybe instead of having multiple distinct classes for styles, objects, etc, a single Animator class can accept different types of objects and act accordingly.

Being more explicit with specific classes also means that the disctinction can be more clear in non-browser runtimes. For example, a Node.js library, or Node-like JS runtime, could provide ObjectAnimator but not StyleAnimator. Alternatively, a non-browser environment could also accept less types of objects if using a single class (for example, accept only JS objects, but not elements because there are no elements).

Summary

I think I like Idea 2 of each of the two proposals (accepting multiple types of objects). But I'm not sure, maybe separate classes results in semantically cleaner code (include type definitions in TypeScript for example).

In any case, being able to animate plain JS objects would be not only be very useful for DOM elements in a browser, but for other cases like

birtles commented 9 months ago

Web Animations 2 has custom effects for this. At one point we were going to scrap them in favor of something simpler like a callback but I believe WebKit started implementing something like custom effects at one point.

bramus commented 9 months ago

This sounds like Custom Effects indeed:

const someObject = {
  position: {x: 0, y: 0, z: 0}
}

const animation = new Animation();
animation.effect = new CustomEffect((progress) => {
    someObject.position.x = 200 * progress;
}, 3000);

animation.play();

Could maybe be more ergonomic or could use a helper function to allow authors to be more direct and let them pass in something like { someObject.position: [{x: 0}, {x: 200}] }.

(#) I believe WebKit started implementing something like custom effects at one point.

Available in Safari behind a feature flag.

trusktr commented 9 months ago

The CustomEffect idea lacks too much. The idea with KeyframeEffect is you get easing curves between key frames, which is very nice.

If I understand correctly, with CustomEffect, we're entirely on our own, we have to implement key frames and easing in JavaScript.

What we need is the same APIs, but for simply applying to numbers on JS objects instead of style properties on elements. That's the only difference there should be.

CustomEffect might still be useful if, for example, some library like GSAP wants to integrate with Web Animations while providing all of their own readings, etc.

But out of the box a Web Animations user should still be able to do everything they can with element style, just with plain numbers on any object.

trusktr commented 9 months ago

The <sl-animation> custom element from Shoelace, is fantastic!

https://shoelace.style/components/animation

Imagine <sl-animation> being wrappable around any custom element to animate its JS properties, not only CSS properties. 😍 (and with a simple implementation due to the API ideas above)

return html`
  <sl-animation duration="2000" property="position" propertyKeyframes=${[{x: 0}, {x: 100}]}>
    <lume-box size="1 1 1" color="cornflowerblue"></lume-box>
  </sl-animation>
`
graouts commented 5 months ago

The CustomEffect idea lacks too much. The idea with KeyframeEffect is you get easing curves between key frames, which is very nice.

If I understand correctly, with CustomEffect, we're entirely on our own, we have to implement key frames and easing in JavaScript.

The idea of CustomEffect is that you get all of the timing and animation features provided by the Web Animations API for free while using a callback-based approach to applying animation values. So, easing, delay, duration, iterations, playback rate, etc. will be accounted for to produce the progress value provided to the callback. Keyframes however are a feature of KeyframeEffect so would indeed not be available there.

What we need is the same APIs, but for simply applying to numbers on JS objects instead of style properties on elements. That's the only difference there should be.

That's a fair point, I think you should contribute to the CustomEffect issue.

KevinDoughty commented 3 months ago

I’ve experimented with something like an Animator. It used getter and setter accessors to enable declarative, implicit animation for JS objects, similar to CSS Transitions, that were triggered automatically on property value change. This feature is beyond the scope of Custom Effects and deserves more attention.