Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow developers to incorporate default transitions into their own transitions #117

Open
jakearchibald opened this issue Jan 18, 2022 · 14 comments

Comments

@jakearchibald
Copy link
Collaborator

jakearchibald commented Jan 18, 2022

Use-case: An element appears on the incoming page only, but the developer wants it to appear to slide from being another transition element

Imagine Page-A is a grid of thumbnails, and Page-B contains the full image and description.

A nice transition would be for thumbnail to grow into the full image, while the description slides out from underneath.

For the thumbnail-to-image transition, a bit of JS would be required to tag the appropriate thumbnail, but from there on, the default transition will do a good job.

Things get trickier with the description, as it doesn't have an equivalent on Page-A, so the default will be for it to fade-in in its final place.

One option would be to create a dummy description element in Page-A, and position it behind the thumbnail.

One of the reasons we're creating shared-element-transitions is because messing around with the DOM like this is tricky and full of booby-traps. E.g., if the page comes back from bfcache, will the developer remember to remove this dummy element?


Proposal involving a sort-of custom vars approach, but I'm not sure this solves all the cases I wonder it would be useful in both cases to be able to reference the default start & end styles of another element. As in, a CSS function like `transition-container-outgoing(tag, style-prop)` which returns the outgoing value of `style-prop` for the transition element with tag `tag`.

You could say that the generated style for a container named foo is:

@keyframes foo-container-keyframes {
  from {
    transform: transition-container-outgoing(foo, transform);
    width: transition-container-outgoing(foo, width);
    height: transition-container-outgoing(foo, height);
    opacity: transition-container-outgoing(foo, opacity);
  }
  to {
    transform: transition-container-incoming(foo, transform);
    width: transition-container-incoming(foo, width);
    height: transition-container-incoming(foo, height);
    opacity: transition-container-incoming(foo, opacity);
  }
}

::page-transition-container(foo) {
  animation: 1s linear both foo-container-keyframes;
}

Or, if we had another function which returned the tag name for that particular element, we could massively cut down on the number of dynamic styles that we need, and maybe eliminate them altogether:

@keyframes container-keyframes {
  from {
    transform: transition-container-outgoing(transition-tag(), transform);
    width: transition-container-outgoing(transition-tag(), width);
    height: transition-container-outgoing(transition-tag(), height);
    opacity: transition-container-outgoing(transition-tag(), opacity);
  }
  to {
    transform: transition-container-incoming(transition-tag(), transform);
    width: transition-container-incoming(transition-tag(), width);
    height: transition-container-incoming(transition-tag(), height);
    opacity: transition-container-incoming(transition-tag(), opacity);
  }
}

::page-transition-container(*) {
  animation: 1s linear both container-keyframes;
}

The above could be static styles, as they don't change per transition.

transition-tag() could be replaced with CSS custom properties, but I'm not sure if the browser should be generating those itself.

For the use-case:

@keyframes description-keyframes {
  from {
    transform:
      /* Step 3, slide it behind the thumbnail */
      translateY(-100%)
      /* Step 2, move it below the thumbnail */
      translateY(transition-container-outgoing(thumbnail, height))
      /* Step 1, put the description in the position of the thumbnail */
      transition-container-outgoing(thumbnail, transform);

    width: transition-container-outgoing(thumbnail, width);
    height: auto;
    opacity: 0;
  }
  to {
    transform: transition-container-incoming(transition-tag(), transform);
    width: transition-container-incoming(transition-tag(), width);
    height: transition-container-incoming(transition-tag(), height);
    opacity: 1;
  }
}

::page-transition-container(description) {
  animation: 1s linear both description-keyframes;
}

Using only CSS, the developer would be able to position the description according to the position of the thumbnail from the previous page.

@jakearchibald
Copy link
Collaborator Author

https://kryogenix.org/random/jake-demo.html - here's a similar request from Stuart Langridge. In the case, the developer wants to create an animation along a path, where the start and end points are the same start and end points of the default animation.

I think the problem here is, although the API has an escape hatch to do custom things, it maybe should be more of an off-ramp? Right now, if you define your own animation, you have to start from scratch, we don't give developers any insight into the precalculated stuff. In fact, the "slide-in" animations I've created in demos are only possible because I'm animating the nested elements rather than the container, and that isn't specifically why we created that nesting, so we're just kinda lucky it works.

I think this use-case would be solved if we provided some details about the elements created, and what their default transitions would be. Exposing the keyframes in a WAAPI format might be enough.

This would also provide a low level solution to #84, as a developer could use the start and end keyframes to create something similar-but-different.

@stuartlangridge
Copy link

stuartlangridge commented Jun 8, 2022

Clarifying a little, the thing I wanted to do in the above-linked demonstration is not particularly to create an animation along the transition path: that's an example only of the sort of thing that might be possible. In essence, the issue is that if I use the shared-element-transition API to appear to animate a thing from one place to another, and I use page-transition-tag: mytag to do that, then the transition I get for free is one in which size and position are animated for me. This is great: it's essentially as if the s-e-t API automatically creates CSS which would look something like:

::page-transition-outgoing-image(my-tag) { animation-name: kf; }
@keyframes kf {
  to: { transform: translate(X, Y) scale(S); }
} /* to be clear, I'm sure it's more complex than this; this is to illustrate the point */

where those X and Y and S figures are calculated for me. I don't have to work them out; the s-e-t API takes care of it, and this is a big part of why using this API in an SPA environment is a big timesaver. (In an MPA environment it's both essential and a timesaver.)

If I want to alter how that transform happens, though -- maybe I want to make it happen in multiple steps, or for the scale to go very small and then to the destination scale, or to animate along a path with offset-path, or any other alteration from the invisibly created keyframe setup that the s-e-t API does for me, then I have to add my own @keyframes item instead, replacing the invisibly created one. And... I don't know what X and Y and S should be. If I have to calculate them myself... well, if I wanted to do that, and write a load of JavaScript to do it, then I'd already be doing this transition animation with FLIP, probably. In that situation the new s-e-t API loses quite a lot of its compelling nature. (Not all of it; there are still many benefits and edge cases that this API provides, and MPA stuff is impossible without it.)

I don't think there's an obvious solution here. The idea that shared-element-transitions is entirely powered by CSS and I don't need to write lots of JS and do lots of calculations myself is very compelling, and I think that should be preserved. However, I'd also like a way where I can alter the transition that it decides for me without having to wholly replace that translation with one I have to calculate and build myself from scratch.

@jakearchibald
Copy link
Collaborator Author

I think at an absolute minimum, we need to expose the calculated keyframes. Then, you could pop the transform values into a DOMMatrix and figure out what the x & y equivalents are. Using that, you could create your custom path.

I think that would require JS. I don't think there's currently a CSS-only way to build up path strings.

But yeah, the next question is whether we should have some kind of high-level CSS-only way to access the before/after values. I touched on this a bit in the OP if you expand "Proposal involving a sort-of custom vars approach".

The tricky part is the before and after transforms can include rotation, skew, even 3d transforms. So, if we offer a way to get x/y/scale, using just those could result in things jumping around, as you're discarding some of the data. The same would happen with the DOMMatrix approach, but it feels clearer that you're only using a small part of the transform in that case.

MVP here is exposing the values to JS. But I do like the idea of providing custom-property-like things here. If we solved #156, then a small library could create these custom properties.

@khushalsagar
Copy link
Collaborator

Exposing the computed values with a JS API should be pretty trivial. Assuming its a JS getter, we'll need to figure out the right spot in the API where you can query the values. For outgoing elements, the values are available as soon as we run the callback passed to start (or prepare based on #159). For incoming elements, "After the new state is captured" should work (same spot where you'd add code to set up animations).

One edge case is the computed values changing for the incoming elements. The common case would be if there is any layout shifting after the animation starts (maybe because the page was still loading). Our plan is to retarget the default animation in this case (see crbug.com/1336710). But would we want to have a script callback to let the developer know this changed?

@jakearchibald
Copy link
Collaborator Author

The updating thing is interesting. I'll think up an API.

Seems like https://drafts.csswg.org/web-animations-1/#the-keyframeeffect-interface is the correct thing to return.

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Jun 20, 2022

Proposal, building on #159 (comment)

partial interface DocumentTransition {
  readonly attribute FrozenArray<DOMString>? tags;
  Promise<PreparedDocumentTransition> prepare(DocumentTransitionPrepareCallback callback);
}

callback DocumentTransitionPrepareCallback = Promise<any>();

interface PreparedDocumentTransition {
  KeyframeEffect? getDefaultEffect(DOMString tagName, DOMString transitionPseudoName);
}

Used like this:

const transition = document.createDocumentTransition();
// Before the DOM change

// null
console.log(transition.tags);

const preparedTransition = await transition.prepare(async () => {
  // After the current state is captured

  // Frozen array of page-transition-tag identifiers
  // used to create outgoing pseudos.
  console.log(transition.tags);

  await coolFramework.updateDOM();
  // After the DOM is modified
});
// After the new state is captured

// Frozen array of page-transition-tag identifiers
// used to create incoming/outgoing pseudos.
console.log(transition.tags);

// KeyframeEffect or null:
const keyframeEffect = preparedTransition.getDefaultEffect(
  // page-transition-tag name
  'site-header',
  // Pseudo name (without the "page-transition-" suffix)
  'incoming-image'
);

Notes:

  • getDefaultEffect returns a new KeyframeEffect on every call
  • If the DOM changes, which moves the incoming element, that change will be reflected in subsequent calls to getDefaultEffect.
  • The KeyframeEffect must be fully populated. As in, include the correct easing, duration, fill mode etc etc.

@jakearchibald
Copy link
Collaborator Author

Alternative design:

const preparedTransition = await transition.prepare(async (preparingTransition) => {
  console.log(preparingTransition.tags);
});

console.log(preparedTransition.tags);

This avoids the case where .tags is undefined, by only offering that API when the data is available.

@khushalsagar
Copy link
Collaborator

I like the proposal here. The second suggestion which places tags on PreparedDocumentTransition is nice to make it obvious when the data is available. Would preparingTransition be the same IDL as preparedTransition? You should be able to query the computed values (via getDefaultEffect) for outgoing elements.

I also think we could make the API consistent for SPA/MPA with this. For example, PreparedDocumentTransition could be the interface returned for MPA as well here.

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Jun 20, 2022

Would preparingTransition be the same IDL as preparedTransition? You should be able to query the computed values (via getDefaultEffect) for outgoing elements.

But you don't know if something is just outgoing at that point, right? So I'm not sure it makes sense to call getDefaultEffect.

However, it might be nice to have an API that can take a page-transition-tag and return actual DOM element (not the pseudo), but I punted on suggesting that idea since you'd probably need to be strict about when that API would work, to avoid leaks.

I also think we could make the API consistent for SPA/MPA with this. For example, PreparedDocumentTransition could be the interface returned for MPA as well here.

Agreed!

@khushalsagar
Copy link
Collaborator

But you don't know if something is just outgoing at that point, right? So I'm not sure it makes sense to call getDefaultEffect

Fair point. My aim was to provide a hook to get the computed values. For instance, you might offer different elements on the incoming page based on whether an offered element was offscreen on the outgoing page. getDefaultEffect isn't the right hook if you just want the cached computed values for offered elements.

it might be nice to have an API that can take a page-transition-tag and return actual DOM element (not the pseudo)

Which DOM element would this be?

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Jun 20, 2022

it might be nice to have an API that can take a page-transition-tag and return actual DOM element (not the pseudo)

Which DOM element would this be?

I think my idea is even dumber than I originally thought, but here's proof:

Say the page has:

<header style="page-transition-tag: header; contain: paint;"></header>

Then:

const preparedTransition = await transition.prepare(async (preparingTransition) => {
  console.log(preparingTransition.tags); // ['header']

  // This will return the <header>
  const header = preparingTransition.getElement('header');
  // preparingTransition.getElement should throw if called after the callback promise settles.
});

// This will return whichever element was captured as the incoming-image, or none.
const maybeAnElement = preparedTransition.getElement('header');
// preparedTransition.getElement should throw if called after the transition completes.

However, this requires keeping DOM elements in memory longer than I think we'd like. In particular with preparingTransition.getElement, as it'd make the element available after the DOM change, until the callback promise settles. That isn't much longer, but doesn't seem great.

@jakearchibald
Copy link
Collaborator Author

But you don't know if something is just outgoing at that point, right? So I'm not sure it makes sense to call getDefaultEffect

Fair point. My aim was to provide a hook to get the computed values. For instance, you might offer different elements on the incoming page based on whether an offered element was offscreen on the outgoing page.

Would something like this work?

// This is already in a proposal:
const pseudo = document.documentElement.pseudo('::page-transition-outgoing-image(header)');
// Feels like folks would be open to adding this:
console.log(pseudo.getBoundingClientRect());

@khushalsagar
Copy link
Collaborator

The last comment seems much better. We just need to support getBoundingClientRect on CSSPseudoElement right?

@jakearchibald
Copy link
Collaborator Author

And implement element.pseudo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants