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

animationstart hook not effective if lazy loaded #260

Open
calinoracation opened this issue Apr 14, 2024 · 12 comments
Open

animationstart hook not effective if lazy loaded #260

calinoracation opened this issue Apr 14, 2024 · 12 comments

Comments

@calinoracation
Copy link
Contributor

We are asynchronously loading, so the start event has already happened when the polyfill loads in Safari.

I tried various workarounds like waiting for the polyfill to load and switching from play state paused to running.. Perhaps completely not applying until then is an option, but that's tricky for authors.

My current workaround is to apply a data attribute to each element and once loaded query them all and call play on the animations. That does seem to work.

@calinoracation
Copy link
Contributor Author

Turns out this is also a problem when we fetch remote stylesheets. The stylesheets themselves are already loaded, but the animationstart event we use to hook into it has already fired after we parse the stylesheets from fetch.

@flackr
Copy link
Owner

flackr commented Apr 16, 2024

We can use document.getAnimations() and search for all running animations that are an instanceof CSSAnimation and upgrade them. This should handle animations which were already in progress when the polyfill sets up the animationstart listener.

@calinoracation
Copy link
Contributor Author

I tested this out and that works except for the condition of them running. Most of them have a default duration of 1ms for it to work in Safari so the play-state was not set to running. Otherwise this works like a charm!

@flackr
Copy link
Owner

flackr commented Apr 17, 2024

Maybe we could temporarily change the fill mode to forwards or both before calling getAnimations() to ensure that even finished animations are still in effect. We could change it back immediately after calling getAnimations to ensure that the change is never visually seen.

@flackr
Copy link
Owner

flackr commented Apr 17, 2024

An alternate option would be to have a very small snippet that is run synchronously which simply collects a list of started animations and then passes them in to be set up by the polyfill after its loaded.

@calinoracation
Copy link
Contributor Author

The syncronous snippet might be hard, we can't collect it immediately if say a framework like React mounts or re-renders and that triggers an animation after the snippet but before the polyfill is loaded (or remote stylesheets fetched and parsed). The forwards / both sounds promising. Would that be more effective over calling .play() on the animations when the polyfill initializes?

@flackr
Copy link
Owner

flackr commented Apr 17, 2024

Would that be more effective over calling .play() on the animations when the polyfill initializes?

Yes! This should avoid any visible side effects. Finished animations will remain finished whereas calling play restarts them.

@flackr
Copy link
Owner

flackr commented Apr 17, 2024

we can't collect it immediately if say a framework like React mounts or re-renders and that triggers an animation after the snippet but before the polyfill is loaded

This wouldn't be a problem, you'd simply keep a list of animations to check later to see if they should be scroll driven, e.g.

let startedAnimations = new Set();
window.addEventListener('animationstart', (evt) => {
  evt.target.getAnimations().filter(anim => anim.animationName === evt.animationName).forEach(anim => {
    startAnimations.add(anim);
  });
});

Then after the polyfill loads you'd process each of startedAnimations as if they had an animationstart event, i.e. calling the code here: https://github.com/flackr/scroll-timeline/blob/master/src/scroll-timeline-css.js#L174

@calinoracation
Copy link
Contributor Author

Let me give that a shot!

@calinoracation
Copy link
Contributor Author

calinoracation commented Apr 24, 2024

It did seem to work, actually. Was also running into another issue where the syntax parser had invalid syntax so stopped processing all animations. That's probably correct, but challenging in a production scenario.

@flackr That's not working so well for us either. I guess since we put the 1ms duration for Firefox it's not firing animationstart even instrumented early. I've tried a few other things but nothing is sticking so far.

@RNEvok
Copy link

RNEvok commented Feb 2, 2025

@calinoracation Hi! Can you share any complete example of making it work? I am trying to use this polyfill in my NextJS project and as far as I understand my problem is similar.

I am also interested in your workaround (calling play on animations)

@RNEvok
Copy link

RNEvok commented Feb 10, 2025

After days of hard work I've managed to "bring back to life" CSS scroll-driven animations for browsers without native support in my NextJS project. Let me share tips that might be useful:

Useful notes about this polyfill usage (and NextJS tips)

How to make it work in NextJS (app router)

  • We need to import it on client side, but it is not enough because polyfill uses global window object, which will throw on server-side during pre-render attempt. Usually, in NextJS we fix such problems using dynamic import (next/dynamic), but this would not work because polyfill cant be loaded asynchronously. So instead we need to import it using async import, wait until its ready and then "upgrade" our animations.
  • Now we have a problem because we've loaded polyfill asynchronously, so it wasn't ready at the moment our animations "began". We need to manually "upgrade" our animations. in this issue there are 3 main ideas how we can make it, but I've only managed to make first one work: mark all animated blocks with data-animated="true", query them all when polyfill loaded, find all animations and "upgrade" them - just restart. And final touch: do it inside of requestAnimationFrame callback, otherwise this won't work.
<div 
    data-animated={"true"} // Mark your animated blocks like so
    className={s.oncomingCont}
/>
  • We should remember not to do this stuff in browsers that already support CSS animation-timeline: scroll(). Yes, polyfill itself does that, but we also don't want to run our queries and do anything with animations in browsers like Chrome. It is possible with simple check CSS.supports("animation-timeline: --works").
  • Logic described above fits very well inside of custom hook, let me share some code:
'use client';

import { useState, useEffect } from 'react';

// These are our debug & logging libraries, you don't need them 
import Bugsnag from '@bugsnag/js';
import { CodeBud } from '@appklaar/codebud';

export type UseScrollTimelinePolyfillResult = 'polyfillNotNeeded' | 'polyfillApplied' | 'polyfillApplyingFailed';

/**
 * A custom hook that loads the ScrollTimeline polyfill and upgrades animations
 * on elements with the `data-animated="true"` attribute.
 * @returns {UseScrollTimelinePolyfillResult} Result status.
 *
 * @remarks
 * - Hook applies the polyfill only if 
 * - The polyfill is dynamically imported from the `scroll-timeline-polyfill` package.
 * - The hook uses `requestAnimationFrame` to ensure animations are upgraded in the next frame.
 * - Animations that are in the `finished` or `idle` state are reset and played again.
 */
export const useScrollTimelinePolyfill = (): UseScrollTimelinePolyfillResult => {
  const [status, setStatus] = useState<UseScrollTimelinePolyfillResult>('polyfillNotNeeded');

  useEffect(() => {
    const loadPolyfill = async () => {
      try {
        // Don't enable polyfill if browser claims support
        if (CSS.supports("animation-timeline: --works"))
          return;

        // Relative path is the only way scroll-timeline-polyfill/dist/scroll-timeline.js import works for me. Anyway, make sure that you pass correct path.
        await import('./../../node_modules/scroll-timeline-polyfill/dist/scroll-timeline.js' as any); // no TS declaration file, so as any
    
        // Regular NextJS check that we're not on server-side
        if (typeof window === 'undefined')
          return;

        if ('ScrollTimeline' in window) { // If polyfill applied
          console.log('ScrollTimeline is now available. Upgrading animations...');

          // Find all elements marked with data attribute "data-animated"
          const elements = document.querySelectorAll<HTMLElement>('[data-animated="true"]');
          // console.log('elements found:', elements.length);

          let animationsToUpgrade: Animation[] = [];

          elements.forEach((el, k) => {
            // Get all animations related to element
            const animations = el.getAnimations?.() || [];
            // console.log(`animations found(${k}):`, animations.length);
            animationsToUpgrade = animationsToUpgrade.concat(animations);
          });

          if (animationsToUpgrade.length > 0) {
            requestAnimationFrame(() => {
              animationsToUpgrade.forEach((anim) => {
                // console.log('Upgrading animation:', anim);
                if (anim.playState === 'finished' || anim.playState === 'idle') {
                  anim.cancel(); // Reset the animation
                } 
                anim.play();
              });
            });
          }

          setStatus('polyfillApplied');
          return;
        } else {
          throw new Error('Failed to import the scroll-timeline-polyfill: window.ScrollTimeline is still not available');
        }
      } catch (error) {
        CodeBud.captureEvent('Failed to load the ScrollTimeline polyfill', { error, where: 'useScrollTimelinePolyfill hook' });
        Bugsnag.leaveBreadcrumb('Failed to load the ScrollTimeline polyfill', { error });
        Bugsnag.notify('useScrollTimelinePolyfill failure');
        console.error('Failed to load the ScrollTimeline polyfill:', error);
        setStatus('polyfillApplyingFailed');
        return;
      }
    };
    
    loadPolyfill();
  }, []);

  return status;
};
  • You may (and most likely will) want to apply polyfill inside of specific page / layout that would be server-side. In order to do this I recommend you simple Headless client-side component:
'use client';

import { useScrollTimelinePolyfill } from '~/hooks/useScrollTimelinePolyfill';

export type HeadlessEnableScrollTimelinePolyfillProps = {};

const HeadlessEnableScrollTimelinePolyfill: React.FC<HeadlessEnableScrollTimelinePolyfillProps> = ({}) => {
  useScrollTimelinePolyfill();

  return null;
};

export { HeadlessEnableScrollTimelinePolyfill };

And use it inside your page / layout like so:

import { HeadlessEnableScrollTimelinePolyfill } from '~/components/Utils/HeadlessEnableScrollTimelinePolyfill';

export default async function Home({ params }: HomeProps) {
  return (
    <div className={s.container}>
      <HeadlessEnableScrollTimelinePolyfill /> 
    </div>
  );
}

Useful tips

  • scroll-timeline-polyfill does not yet support animation-range in viewport units (like vh), so you must use percents (%). If it hurts, I suggest using CSS @supports rule and changing animation-range to percents for browsers that do not support animation-timeline: scroll() only.
  • I didn't manage to make this polyfill work with style prop in React, so scroll-driven-animation related rules should be described in CSS only.
  • Make sure to describe all scroll-driven-animation related CSS rules inside a single CSS selector. This does not mean that all your animated blocks must be styled with single classname. Just make sure that animation related rules are listed inside of single selector.
  • It seems that currently this polyfill can't parse CSS @media and @supports (and probably others) at-rules. So if you need, for example, change animation-range from vh to % for Safari only, I suggest you to pass value in % as default one, and then change in to vh for browsers that support animation-timeline: scroll(), like so:
.slide0AnimationPreset {
  animation-timeline: scroll(root block);
  animation-fill-mode: both;
  animation-duration: auto;
  animation-timing-function: linear;
  animation-range: 4% 90%; /* example value */
  animation-name: slideLeftChangeWithoutAppearingAnimation;
}

@supports (animation-timeline: scroll()) {
  .slide0AnimationPreset {
    animation-range-start: 10vh; /* example value */
    animation-range-end: 110vh; /* example value */
  }
}
  • polyfill seems to know only animation-range rule, animation-range-start and animation-range-end did not work for me when polyfill was applied.

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