Skip to content

Transitions/animations brain dump #1431

@Rich-Harris

Description

@Rich-Harris

Fair warning: this will be a long rambly issue. I just want to get this stuff out of my head and into a place where the missing pieces will hopefully be a bit clearer.

Svelte's transition system is powerful, but only applies to a fairly narrow range of problems. (There are also some outstanding issues that need to be resolved one way or another — #544, #545, #547 and #1211, which is related to #547, spring readily to mind.)

We don't have a good way to achieve certain effects that are rightly gaining in popularity among web developers. Most of what I'm about to describe is almost certainly possible already, but I don't think it would be at all easy or declarative.

I urge anyone who is interested in this stuff to watch Living Animation by Edward Faulkner, which was recorded at EmberConf recently. I'm going to describe three particular scenarios based on examples from Edward's talk, and suggest the primitives that we need to add to Svelte in order to make them easy to do.

Scenario 1 — FLIPping a list

We have a list, which can shuffle. Items may enter or leave the list. We want to move elements smoothly to their new position using a FLIP-like mechanism. (See react-flip-move and Vue for prior art.)

But we don't want a fixed duration; we might want duration to be dependent on distance to travel, or on bounding box size (to simulate 'mass'). Maybe we don't want to travel in a straight line; maybe we want to change scale along the way — in other words we need fine-grained parameterisation and programmatic control, not just CSS transitions. (But we want them to be powered by CSS animations under the hood for the perf benefits etc.)

Stretch goal: we want to preserve momentum, or simulate springiness. Stretch goal 2: z-index should preserve the apparent z relationships between elements if a reshuffle happens during an animation.

What's missing

We have a concept of transitions, but we don't have a concept of animating something from one place on the page to another. We do, however, have a primitive that lets us reorder things on the page — the keyed each block. Vue appears to have reached the same conclusion — FLIP animations can only happen inside <transition-group>, and each element must have a :key.

Proposal: we add an animate directive. Whenever a keyed each block updates, the following sequence of events happens:

  1. The bounding box of every element inside the block with the animate directive is measured
  2. The list is updated
  3. We measure bounding boxes again
  4. Any items that have been removed from the list need to be transitioned out, if a transition was provided. For that to work visually, we need to set position: absolute on those elements and use transform to make it look as though they haven't moved, and only then run the outro
  5. We create an animation object for each element that moved, containing layout information (we can probably get away with just the change in x and y value, plus maybe some other stuff like actual distance moved). The animation function — flip, in the example below — is responsible for using that animation object to calculate CSS. It returns the same kind of object that transition functions do — i.e. if it returns a css function, that will be used to create a CSS keyframe animation that smoothly animates the element to its home position. delay, duration and easing would also be respected, just like with transitions.
  6. Any new elements will be introed, if an intro was specified.

As an aside, css: t => {...} is all well and good but you often find yourself doing 1 - t inside those functions. It would certainly make my life easier if that value was supplied, so I suggest we change the signature to css: (t, u) => {...} ('time', 'until').

{#each things as thing (thing.id)}
  <div animate:flip transition:fly>{thing.name}</div>
{/each}

<script>
  import * as eases from 'eases-jsnext';
  import { fly } from 'svelte-transitions';
  
  export default {
    animations: {
      flip(animation, params) {
        return {
          duration: Math.sqrt(animation.d) * 100,
          easing: eases.easeOut,
          css: (t, u) => {
            const dx = u * animation.dx;
            const dy = u * animation.dy;
  
            return `transform(${dx}px, ${dy}px);`
          }
        };
      }
    },
  
    transitions: { fly }
  };
</script>

A challenge: How do we ensure that content below the animated list doesn't jump around?

Scenario 2 — transferring items between lists

Now suppose we have two lists. Clicking on an item in either sends it to the opposite list. As before, we need to consider how items move within the list, but we now also need to handle 'sent' and 'received' items.

What's missing

I think we're actually almost there. We could create send and receive transitions using the existing API, and those could talk to each other — the sent nodes could be used as the basis for the received node's transition functions (using something like the technique from this sadly abandoned library: https://github.com/Rich-Harris/ramjet).

But there's no guarantee that we'd be able to register nodes as having been sent before they were 'claimed' by the receiver. I think the way around this is to allow transitions to return a function:

transitions: {
  foo(node, params) {
    doSomeImmediateSetup();

    return () => {
      doSomeStuffThatCantHappenImmediately();
      return {
        // standard transition object
        css: t => {...}
      };
    };
  }
}

The transition manager would run all the transition functions, and if any of them returned new functions, it would resolve a promise (easy way to wait for work to complete without waiting for a new turn of the event loop, which would result in visual glitchiness) then call those functions.

An example of how that might work is shown below. I wouldn't expect people to actually write that code; we would have helper functions that did the heavy lifting, which I'll show in scenario 3.

<div class='left'>
  {#each left_things as thing (thing.id)}
    <div
      animate:flip
      in:receive="{key: thing.id}"
      out:send="{key: thing.id}"
    >{thing.name}</div>
  {/each}
</div>

<div class='right'>
  {#each right_things as thing (thing.id)}
    <div
      animate:flip
      in:receive="{key: thing.id}"
      out:send="{key: thing.id}"
    >{thing.name}</div>
  {/each}
</div>

<script>
  import { flip } from 'svelte-animations';
  
  let requested = new Map();
  let provided = new Map();
  
  export default {
    animations: { flip },
  
    transitions: {
      send(node, params) {
        provided.set(params.key, {
          node,
          style: getComputedStyle(node),
          rect: node.getBoundingClientRect()
        });
  
        return () => {
          if (requested.has(params.key)) {
            requested.delete(params.key);
            // for this transition, we don't need the outroing
            // node, so we'll just delete it. in some cases we
            // might want to e.g. crossfade
            return { delay: 0 };
          }
  
          // if the node is disappearing altogether
          // (i.e. wasn't claimed by the other list)
          // then we need to supply an outro
          provided.delete(params.key);
          return {
            css: t => `opacity: ${t}`
          };
        };
      },
  
      receive(node, params) {
        requested.add(params.key);
  
        return () => {
          if (provided.has(params.key)) {
            provided.delete(params.key);
  
            const { rect } = provided.get(params.key);
  
            const thisRect = node.getBoundingClientRect();
            const dx = thisRect.left - rect.left;
            const dy = thisRect.top - left.top;
  
            const { transform } = getComputedStyle(node);
  
            return {
              t => `transform: ${transform} translate(${t * dx}px,${t * dy}px)`
            };
          }
  
          requested.delete(params.key);
          return {
            css: t => `opacity: ${t}`
          };
        };
      }
    }
  };
</script>

Scenario 3 — cross-route transitions

This is actually pretty similar to the previous example, except that we don't have any animations in this one — just transitions. This time, we'll use an imaginary helper function to handle all the book-keeping and matching.

We have a list of items on one page (could be keyed, could be unkeyed) — let's say it's the HTTP status dogs. Clicking on one takes you to a dedicated page. The selected image moves smoothly (and enlarges) to its new home; the others fly off to the left or something. Navigating back reverses the transition (even if it happens in-flight).

<!-- routes/index.html -->
{#each statusDogs as dog}
  <a href="status/{dog.status}">
    <img
      alt="{dog.status} status dog"
      src="images/{dog.status}.jpg"
      in:receive="{key: dog.status}"
      out:send="{key: dog.status}"
    >
  </a>
{/each}

<script>
  const { send, receive } = notSureWhatToCallThisFunction({
    send(match, params) {
      // return nothing to signal the node should be removed
      // (or just omit this option, I guess)
    },
  
    receive(match, params) {
      // this function will be called for one of the lucky
      // dogs if we're navigating back from e.g. /status/418
      return {
        // create the CSS that smoothly animates the dog
        // back to its spot in the list
        css: t => {...}
      };
    },
  
    intro(node, params) {
      // this will be called for all the other dogs
      // when we land on this page. fade them in or something
      return {...};
    },
  
    outro(node, params) {
      // this will be called for all the other dogs
      // when we *leave* this page
      return {...};
    }
  
    // in many cases intro and outro will be the same...
    // maybe this function should accept a 'transition' option
  });
  
  export default {
    transitions: {
      send, receive
    }
  };
</script>
  
<!-- routes/status/[code].html -->
<header>
  <h1>{params.code}</h1>
  <img
    alt="{dog.status} status dog"
    class="large"
    in:receive="{key: dog.status}"
    out:send="{key: dog.status}"
  >
</header>

<p>{getDescription(params.code)}</p>

<script>
  const { send, receive } = youKnowTheDrillByNow({...});
  
  export default {
    transitions: { send, receive }  
  };
</script>

If anyone made it this far, kudos. I would love to know if there are parts to this that I've overlooked, or any trickier use cases to test the ideas against, or if you have improvements to the above proposals.

To recap, the bits I think we should add:

  • animate directive that works inside keyed each blocks
  • css: (t, u) => {...}
  • allow transition functions to return a function instead of a transition object

One thing I haven't considered here is the Web Animations API; I've been assuming that we'd use CSS animations which I think are probably more suitable. But I'm open to persuasion if you think I've got that wrong.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions