Jump to content
Search Community

Tween targets inside shadowRoot

mikaL test
Moderator Tag

Recommended Posts

I have my animation targets inside a shadowRoot, to prevent e.g. UI styles from leaking into my animated objects. However, in the docs it says:

 

Quote

targets - The object(s) whose properties you want to animate. This can be selector text like ".class", "#id", etc. (GSAP uses document.querySelectorAll() internally) or it can be direct references to elements,  generic objects, or even an array of objects.

 

This is a bit of a problem: document.querySelectorAll doesn't return anything from shadow DOM. I can call shadowRoot.querySelectorAll() instead & provide elements directly as it says above, but I'd prefer the simple GSAP API ... so I wonder if there's any other way around this? Maybe an option as a feature in a future version, in e.g. GSAP.config() ?  

 

Link to comment
Share on other sites

What do you think the API should look like? @GreenSock I think something like this would also be a nice addition, especially for people who use React, Vue, Angular. That way they wouldn't have to create a bunch of refs.

 

But here is how I've handled that in web components. I extend a "BaseElement" class with some extra methods on it. If you're using a library like LitElement, you could do the same thing.

 

class BaseElement extends HTMLElement {
  
  select(selector) {
    return this.shadowRoot.querySelectorAll(selector);
  }  

  createIdCache() {
    this.$ = {};
    for (const node of this.shadowRoot.querySelectorAll("[id]")) {
      this.$[node.id] = node;
    }
  }
}

 

So you would call createIdCache after the DOM is available, like in a connected callback. That will make any elements with an id available on the $ object. Or you could just use the select method.

 

Ex HTML:

<div id="container">
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
</div>

 

Ex JS:

class MyComponenet extends BaseElement {

  connectedCallback() {
    this.createIdCache();
    
    gsap.to(this.$.container, {
      x: 100
    });
    
    gsap.to(this.select(".box"), {
      scale: 0.5
    });
  }
}

 

 

  • Like 1
Link to comment
Share on other sites

A way to do that more like how Vue handles refs. If a ref attribute appears more than once, it gets added to an array.

 

Ex HTML:

<div ref="container">
  <div ref="box"></div>
  <div ref="box"></div>
  <div ref="box"></div>
</div>

 

BaseElement:

class BaseElement extends HTMLElement {
  
  select(selector) {
    return this.shadowRoot.querySelectorAll(selector);
  }  

  createRefs() {
    const $ = this.$ = {};
    for (const node of this.shadowRoot.querySelectorAll("[ref]")) {
      const id = node.getAttribute("ref");
      
      if (!$[id]) {
        $[id] = node;
      } else {
        if (Array.isArray($[id])) {
          $[id].push(node);
        } else {
          const list = [];
          list.push($[id], node);
          $[id] = list;
        }
      }
    }
  }
}

 

Ex JS:

class MyComponenet extends BaseElement {

  connectedCallback() {
    this.createRefs();
    
    gsap.to(this.$.container, {
      x: 100
    });
    
    gsap.to(this.$.box, {
      scale: 0.5
    });
  }
}

 

 

  • Like 2
Link to comment
Share on other sites

Is the goal here to be able to pass in selector strings, but specify a context that'd take the place of "document" in document.querySelectorAll()? Sorta like: 

gsap.to(".class", {context: this.shadowRoot, ...});
// then internally, it'd do: 
targets = tween.vars.context.querySelectorAll(".class"); 

?

Link to comment
Share on other sites

That's what I'm thinking.

 

So like in React, the user could just pass in the parent element of component.

function App() {	
  const container = useRef(null);
  
  useEffect(() => {
    
    gsap.to(".box", {
      context: container.current,
      x: 100
    });
  }, []);
	
  return (
    <div className="container" ref={container}>
        <div className="box"></div>
		<div className="box"></div>
		<div className="box"></div>
		<div className="box"></div>
    </div>
  );
}

 

  • Like 1
Link to comment
Share on other sites

7 hours ago, GreenSock said:

Is the goal here to be able to pass in selector strings, but specify a context that'd take the place of "document" in document.querySelectorAll()? Sorta like: 


gsap.to(".class", {context: this.shadowRoot, ...});
// then internally, it'd do: 
targets = tween.vars.context.querySelectorAll(".class"); 

?

 

Yes, I think this is exactly what I'm looking for. I could even set it sort of globally beforehand, with something like this

gsap.defaults({context: shadowRoot})

, right?

 

And though my use case right now isn't React, that looks useful too (I have yet to dig into animating UI transitions in React...).

 

What I'm trying to achieve here is, hmm, like a plugin system where the animated plugin components live inside a shadow DOM, to encapsulate styles mainly. In the components' rendering methods, I'd like to be able to use GSAP APIs with as little custom trickery as possible. Cleaner and simpler that way, + less documentation for me to write, when I get other people writing those components as well. 

 

Link to comment
Share on other sites

Hm. I dunno - I'm on the fence about this. Is it really worth expanding the API surface area, creating another "protected" property that can't be animated ("context"), adding conditional logic internally for every tween, potentially surprising people when a default is set elsewhere in code, etc. when it's very easy to do this externally like: 

const find = selector => this.shadowRoot.querySelectorAll(selector);

// then in all your tweens:
gsap.to(find(".class"), {...});

?

 

I mean I can definitely see some use cases where it'd be convenient, don't get me wrong. I'm just contemplating whether or not it's really worth the tradeoffs. I welcome input. 

Link to comment
Share on other sites

Well, I think I can live with having that extra step in getting the targets. This is not the first issue with I've had with using shadow DOM when a library uses document.querySelector, or mutation observers, etc. So I may even end up not using shadow DOM at all, though it does have its benefits. As far as I know web components tend to use shadow DOM too. But as I said, it's not a deal breaker for me.

Link to comment
Share on other sites

16 hours ago, mikaL said:

Yes, I think this is exactly what I'm looking for. I could even set it sort of globally beforehand, with something like this


gsap.defaults({context: shadowRoot})

 

I don't even know how that could work as a default. How would it get a reference to this.shadowRoot?

 

Where I see this being the most helpful is with component frameworks like React. People wouldn't have to contemplate the best way to stagger elements.

 

 

Link to comment
Share on other sites

17 minutes ago, OSUblake said:

Where I see this being the most helpful is with component frameworks like React. People wouldn't have to contemplate the best way to stagger elements.

 

Could you put a little meat on the bones, @OSUblake? Like pseudo code with that simple demo or something? It's not totally clear to me what you mean. 

Link to comment
Share on other sites

Well, let's first go over the biggest problem I see with React users, they don't use refs. They assume the selector string is just going to target that component, but if you use that component more than once, it's not going to animate correctly.

 

This is supposed to animate the first box to 100 with a color of red, the second box to 200 with a color of green, and the third box to 300 with a color of yellow, but it doesn't happen.

 

See the Pen 581c7a732f60e8322b5b543f4691a77b by osublake (@osublake) on CodePen

 

Correct behavior using refs.

 

See the Pen b8f05fd1700a1ef0c41ef1c41c9ca0fd by osublake (@osublake) on CodePen

 

 

 

  • Like 2
Link to comment
Share on other sites

Now if a component has a bunch children elements that you want to animate, you're going to have to get a reference to them somehow.

 

Most common is probably with the useRef.

 

function MySvg() {
  
  const svg = useRef();
  const circle1 = useRef();
  const circle2 = useRef();
  const circle3 = useRef();
  const rect1 = useRef();
  const rect2 = useRef();
  const rect3 = useRef();
  
  useEffect(() => {
    gsap.to([circle1.current, circle2.current, circle3.current], {
      x: 100
    });
    
    gsap.to([rect1.current, rect2.current, rect3.current], {
      y: 200
    });
  }, []);
  
  return (
    <svg ref={svg}>
      <circle ref={circle1}></circle>
      <circle ref={circle2}></circle>
      <circle ref={circle3}></circle>
      <rect ref={rect1}></rect>
      <rect ref={rect2}></rect>
      <rect ref={rect3}></rect>
    </svg>
  );
}

 

If we had an option to pass in the context, we could eliminate all those useRefs except for the parent one, and just use query strings, greatly reducing the amount of code.

 

function MySvg() {
  
  const svg = useRef();
  
  useEffect(() => {
    gsap.to("circle", {
      context: svg.current,
      x: 100
    });
    
    gsap.to("rect", {
      context: svg.current,
      y: 200
    });
  }, []);
  
  return (
    <svg ref={svg}>
      <circ></circle>
      <circ></circle>
      <circ></circle>
      <rect></rect>
      <rect></rect>
      <rect></rect>
    </svg>
  );
}

 

  • Like 1
  • Thanks 1
Link to comment
Share on other sites

That's a helpful explanation, thanks!

 

Next question: how does this impact the whole re-rendering thing? My understanding is that one of the challenges of working with React/Angular/whatever (and one of the reasons for using refs) is that they may just decide to re-create a whole element (though it's rare). So let's say we start animating ".box" and then halfway through React decides to trash that element and re-create a new one in its place...now GSAP is animating an old/stale one that's not even on the screen anymore. Again, I know this is highly unlikely in real-world scenarios, but I want to wrap my head around whether or not this whole "context" thing provides a robust solution or if it's a band-aid. 

Link to comment
Share on other sites

59 minutes ago, GreenSock said:

Next question: how does this impact the whole re-rendering thing?

 

I think there's been a lot of confusion over the years, especially with people throwing out terms like virtual DOM. Elements just don't get trashed or recreated. Once it's in the DOM, it's stays in there until you tell React to remove it.  

 

Re-rendering basically just means applying changes made within React, or whatever component framework you're using, to the DOM. In this demo, React is re-rendering the cx attribute, which is being changed within React. GSAP is animating the cy attribute. There would only be an issue if gsap tries to change what React is trying to change.

 

See the Pen bd15dbff73a59bd52de033864b2559d8 by osublake (@osublake) on CodePen

 

 

  • Like 2
Link to comment
Share on other sites

51 minutes ago, GreenSock said:

and one of the reasons for using refs

 

The reason for using refs is to get the correct element. You really don't need refs if you are 100% sure that the document.querySelectorAll will get the correct element, but that's unlikely if a component is used more than once, which is basically the whole point of using components.

 

 

Link to comment
Share on other sites

So if I'm understanding you correctly...

gsap.to("circle", {
  context: svgRef.current,
  x: 100
});
gsap.to("rect", {
  context: svgRef.current,
  y: 200
});

...is really no better/different than: 

const find = selector => svgRef.current.querySelectorAll(selector);

gsap.to(find("circle"), {
  x: 100
});
gsap.to(find("rect"), {
  y: 200
});

Right? 

 

And if we're trying to bake something in to the API, might this be better?:

// everything inside the function uses svgRef.current as the context for .querySelectorAll()
gsap.context(svgRef.current, () => {
  
  gsap.to("circle", {
    x: 100
  });
  gsap.to("rect", {
    y: 100
  });
  
});

That way, you set it once in the block of code and it applies to all the animations created inside of it. Might that be cleaner than having to define it on every animation? 

 

Just spitballing here. 

Link to comment
Share on other sites

You're right about it being no better/different. 

 

Would that API work if you like create an animation after calling context?

 

gsap.context(svgRef.current, () => {
  
  gsap.to("circle", {
    x: 100,
    onComplete() {
      gsap.to("rect", { ... });
    }
  });  
});

 

 

 

 

Link to comment
Share on other sites

6 hours ago, OSUblake said:

 

How are you using shadow DOM without web components? AFAIK that's the only way.

 

 

No, it's not. All you have to do is 

const element = document.createElement('div');
element.attachShadow({mode: 'open'});
Link to comment
Share on other sites

12 hours ago, mikaL said:

No, it's not. All you have to do is 


const element = document.createElement('div');
element.attachShadow({mode: 'open'});

 

Well, I would still consider that part of the web components API. So if you do that way, then how to do you add elements to the shadow root?

 

Link to comment
Share on other sites

4 hours ago, OSUblake said:

Would that API work if you like create an animation after calling context?

 


gsap.context(svgRef.current, () => {
  
  gsap.to("circle", {
    x: 100,
    onComplete() {
      gsap.to("rect", { ... });
    }
  });  
});

 

Nope. 

 

At first, my idea was to let people set a context like:

gsap.context(svgRef.current);
// now everything uses that:
gsap.to(...)
gsap.to(...)

However, it seemed likely that people would forget to set it back to document or whatever, so there's a high risk of context drift. That's why I suggested the wrapper function. But the more I think about it, the less I like it because I think there will be too many edge cases where the context doesn't stick. 

 

I thought of a few other ideas, but they're all much more involved than I'm comfortable with (more code, more kb, more conditional logic, more memory, less performant). I wonder if it's best to just provide a utility function to scope the context, sorta like:

gsap.context(svgRef.current, q => {
  gsap.to(q("circle"), {...});
  gsap.to(q("rect"), {...});  
});

Or it could be used like:

cont q = gsap.context(svgRef.current);
gsap.to(q("circle"), {...});
gsap.to(q("rect"), {...});

In fact, we could add some logic so that you could pass in the ref, and it'll automatically get the ".current" in that case. 

 

I wish there was a cleaner way to just let folks use the "standard" GSAP syntax (without a scoped selector function like this) and magically just have it all scoped, but the tradeoffs seem too significant. Maybe I'm missing something. 

Link to comment
Share on other sites

Any reason against in the vars?

 

2 hours ago, GreenSock said:

Or it could be used like:


cont q = gsap.context(svgRef.current);
gsap.to(q("circle"), {...});
gsap.to(q("rect"), {...});

 

 

But that's probably the most versatile. For the people who miss jQuery...

const $ = gsap.context(); // document

 

2 hours ago, GreenSock said:

In fact, we could add some logic so that you could pass in the ref, and it'll automatically get the ".current" in that case. 

 

And for Angular, it might be ".nativeElement".

 

Link to comment
Share on other sites

35 minutes ago, OSUblake said:

Any reason against in the vars?

Not really. I guess I just thought it was slightly less desirable because:

  1. more typing (seemed annoying to have to add context: svgRef.current to every...single...animation, though I guess you could just set gsap.defaults() up top and then revert at the end.
  2. I wondered if it was clearer to use the scoped selector function because it's less about the tweening and more about the selecting.
  3. In order to accommodate it, I'd need to update various plugins too because, for example, if you set the context on the tween but then you've got a scrollTrigger or motionPath or whatever that uses something like trigger: ".class" or path: "#id", those should use that context as well. So it's just a more sweeping set of changes that touch various other parts of the API and plugins.
  4. The scoped selector function offers a tiny bit more flexibility because it's basically like gsap.utils.toArray(), but with a certain context. So people could use it separately for other things too. Probably not terribly common, though. 

Then again, users are trained to just set various stuff in the vars object so it would be more inline with the current API in a sense. 

 

43 minutes ago, OSUblake said:

But that's probably the most versatile. For the people who miss jQuery...


const $ = gsap.context(); // document

 

Yeah, exactly. I initially was gonna use $ too but then I thought "oh no, people will hate this because it feels 'dirty', like they're using jQuery", or it could conflict with the namespace for those who actually are using jQuery :)

 

44 minutes ago, OSUblake said:

And for Angular, it might be ".nativeElement".

 

Yep, I figured we could accommodate the most common frameworks like that. 

 

I'm still not sure I'm loving this concept. Do ya'll think it'll really get used and provide enough value to expand the API surface area? 

Link to comment
Share on other sites

11 hours ago, OSUblake said:

 

Well, I would still consider that part of the web components API. So if you do that way, then how to do you add elements to the shadow root?

 

That's where it's often used, but it doesn't have to be. ShadowRoot inherits from DocumentFragment --> Node , so it's got append() , appendChild() etc. methods.

https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot

 

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...