1

I am trying to build an image carousel with a progress bar showing the remaining time at the bottom of the image. The carousel is able to switch to the next image after the given delay, but I am unable to pause the animation on hover.

https://codesandbox.io/s/unruffled-sun-qz5d89?file=/src/Slider.tsx

import {Fragment, useEffect, useState, useCallback, useRef} from "react";
import './Slider.css';

type Props = {
    images: string[];
    slideShowDelay?: number;
}
export default function Slider({images, slideShowDelay=5}: Props): JSX.Element {
    const [currentSlide, setCurrentSlide] = useState<number>(0);
    const [timer, setTimer] = useState<NodeJS.Timer>();
    const progressRef = useRef<HTMLDivElement | null>(null);
    const [animationState, setAnimationState] = useState('animate');
    const prevButtonHandler = () => {
        setCurrentSlide(prev => {
            const result = prev-1;
            if (result < 0) {
                return images.length - 1;
            }
            return result;
        });
    }
    const nextButtonHandler = useCallback(() => {
        setCurrentSlide(prev => {
            const result = prev+1;
            if (result >= images.length) {
                return 0;
            }
            return result;
        });
    }, [images.length]);

    useEffect(() => {
        if (progressRef.current) {
            progressRef.current.style.transition = "none";
            progressRef.current.style.width = "100%";
            setTimeout(() => {
                if (progressRef.current) {
                    progressRef.current.style.width = "0%";
                    progressRef.current.style.transition = `width ${slideShowDelay}s`;
                }
            }, 50);
        }
    }, [currentSlide, slideShowDelay]);
 

    const slides = images.map((image, i) => {
        const styles = {
            transform: `translateX(${(i - currentSlide) * 100}%)`
        }
        return (<Fragment key={i}>
            <div className="slide" style={styles}>
                <img src={image} alt="random"/>
            </div>
        </Fragment>)
    });
    const mouseHandler = () => {
        setAnimationState('paused');
    }
    const mouseHandlerLeave = () => {
        setAnimationState('animate');
    }
    const transitionEndHandler = nextButtonHandler;
    const classes = `progress-bar ${animationState}`;
    const containerClasses = `slider-container ${animationState}`;
    return (
        <>
            <div className="container">
                <div className={containerClasses} onMouseEnter={mouseHandler} onMouseLeave={mouseHandlerLeave}>
                    {slides}
                    <button className="btn btn-prev" onClick={prevButtonHandler}> {"<"} </button>
                    <button className="btn btn-next" onClick={nextButtonHandler}> {">"} </button>
                    <div onTransitionEnd={transitionEndHandler} ref={progressRef} role={"progressbar"} className={classes} />
                </div>
            </div>
        </>
    )
}

What am I doing wrong here? Any help is appreciated.

Vivek
  • 4,526
  • 17
  • 56
  • 69
  • Interestingly when you add an alert before `setAnimationState('paused');` the animation does pause so it looks like its executed once then overwritten – Simeon Oct 16 '22 at 22:00
  • Any questions, please comment so I can edit my answer, I wish you success on your journey :) – Lucas Paixão Oct 16 '22 at 23:58

1 Answers1

4

The problem


1. There is a difference between transition and animation in CSS concepts, between them, there is the definition of "pause" (so to speak) is only available in animations

It is noticed that it is possible to imitate this functionality (pause an transition) using javascript, but if we depend only on CSS, this is impossible.

Reference: How do I pause CSS transition?

2. You are using a CSS property that is meant for animations, and there no transitions, which is: animation-play-state

Importante note


It is important to note that it is possible to remove a css transition, simply removing the transition property, or overwriting it, example: transition: none !important, but when removing a transition, it is not possible to resume it and the "animation" is lost in this process.

The solution


1. Create a function capable of restarting an animation, note that this is importante because at the end of the first animation (onAnimationEnd) it is necessary to restart the count of x seconds for the progress bar, otherwise, it would only work once.

Note: The key to restarting a CSS animation is to set the animation-name of an animation to 'none' and then setting it back to the original animation

Example:

const startAnimation = () => {
    if (!progressRef.current) return;

    const progress = progressRef.current;
    progress.style.animationName = "none";
    progress.style.width = "100%";
    
    setTimeout(() => {
        progress.style.animationName = "width";
        progress.style.animationDuration = `${slideShowDelay}s`;
    }, 50);
}

2. Call this function on every slide change, whether it's forward or previous, example:

First option:

const prevButtonHandler = () => {
    startAnimation(); // <<- HERE
    setCurrentSlide((prev) => {
        const result = prev - 1;
        if (result < 0) {
            return images.length - 1;
        }
        return result;
    });
};

const nextButtonHandler = useCallback(() => {
    startAnimation(); // <<- HERE
    setCurrentSlide((prev) => {
    const result = prev + 1;
        if (result >= images.length) {
            return 0;
        }
        return result;
    });
}, [images.length]);

OR

Second option:

Or better yet, we can put the startAnimation function inside useEffect which will capture every slide change, so no matter the slide is coming back or forward, the application will work normally.

Example:

useEffect(() => {
    startAnimation();
}, [currentSlide, slideShowDelay]);

3. Create the animation frame in CSS:

@-webkit-keyframes width {
    from {
        width: 100%;
    }
    to {
        width: 0%;
    }
}

@keyframes width {
    from {
        width: 100%;
    }
    to {
        width: 0%;
    }
}

In the example above we have specified when the style will change by using the keywords "from" and "to" (which represents 0% (start) and 100% (complete)).

4. Change using onTransitionEnd to onAnimationEnd, example:

<div
   onAnimationEnd={animationEndHandler}
   ref={progressRef}
   role={"progressbar"}
   className={classes}
/>

The code

Typescript
import { Fragment, useEffect, useState, useCallback, useRef } from "react";
import "./Slider.css";

type Props = {
  images: string[];
  slideShowDelay?: number;
};
export default function Slider({
  images,
  slideShowDelay = 5
}: Props): JSX.Element {
  const [currentSlide, setCurrentSlide] = useState<number>(0);
  const progressRef = useRef<HTMLDivElement | null>(null);
  const [animationState, setAnimationState] = useState("animate");
  
  const startAnimation = () => {
    if (!progressRef.current) return;
    const progress = progressRef.current;
    progress.style.animationName = "none";
    progress.style.width = "100%";
    setTimeout(() => {
      progress.style.animationName = "width";
      progress.style.animationDuration = `${slideShowDelay}s`;
    }, 50);
  }
  
  const prevButtonHandler = () => {
    setCurrentSlide((prev) => {
      const result = prev - 1;
      if (result < 0) {
        return images.length - 1;
      }
      return result;
    });
  };
  const nextButtonHandler = useCallback(() => {
    setCurrentSlide((prev) => {
      const result = prev + 1;
      if (result >= images.length) {
        return 0;
      }
      return result;
    });
  }, [images.length]);

  useEffect(() => {
    startAnimation();
  }, [currentSlide, slideShowDelay]);

  const slides = images.map((image, i) => {
    const styles = {
      transform: `translateX(${(i - currentSlide) * 100}%)`
    };
    return (
      <Fragment key={i}>
        <div className="slide" style={styles}>
          <img src={image} alt="random" />
        </div>
      </Fragment>
    );
  });
  const mouseHandler = () => {
    setAnimationState("paused");
  };
  const mouseHandlerLeave = () => {
    setAnimationState("animate");
  };
  const animationEndHandler = nextButtonHandler;
  const classes = `progress-bar ${animationState}`;
  const containerClasses = `slider-container ${animationState}`;
  return (
    <>
      <div className="container">
        <div
          className={containerClasses}
          onMouseEnter={mouseHandler}
          onMouseLeave={mouseHandlerLeave}
        >
          {slides}
          <button className="btn btn-prev" onClick={prevButtonHandler}>
            {" "}
            {"<"}{" "}
          </button>
          <button className="btn btn-next" onClick={nextButtonHandler}>
            {" "}
            {">"}{" "}
          </button>
          <div
            onAnimationEnd={animationEndHandler}
            ref={progressRef}
            role={"progressbar"}
            className={classes}
          />
        </div>
      </div>
    </>
  );
}

CSS
/* Safari 4.0 - 8.0 */
@-webkit-keyframes width {
  from {
    width: 100%;
  }
  to {
    width: 0%;
  }
}

@keyframes width {
  from {
    width: 100%;
  }
  to {
    width: 0%;
  }
}

.container {
  width: 100vw;
  display: grid;
  place-items: center;
}
.container .slider-container {
  width: 100%;
  max-width: 600px;
  height: 400px;
  position: relative;
  overflow: hidden;
}
.container .slider-container .slide {
  position: absolute;
  width: 100%;
  max-width: 600px;
  height: 400px;
  transition: all 0.5s;
}
.container .slider-container .slide:hover {
  animation-play-state: paused;
}
.container .slider-container .slide img {
  object-fit: cover;
  width: 600px;
  height: 400px;
}
.container .slider-container .progress-bar {
  position: absolute;
  height: 10px;
  bottom: 0;
  left: 0;
  width: 100%;
  background-color: #61dafb;
}
.container .slider-container .progress-bar.animate {
  animation-play-state: running;
}
.container .slider-container .progress-bar.paused {
  animation-play-state: paused;
}
.container .slider-container .btn {
  height: 40px;
  width: 40px;
  position: absolute;
  border-radius: 50%;
  top: calc(50% - 20px);
  z-index: 1;
  background-color: #ffffff;
  font-size: 18px;
}
.container .slider-container .btn:hover {
  cursor: pointer;
  transform: scale(1.1);
}
.container .slider-container .btn.btn-prev {
  left: 10px;
}
.container .slider-container .btn.btn-next {
  right: 10px;
}

Try this:

Demo URL:
https://codesandbox.io/s/nifty-meninsky-pkijf0

Remarks


If this answer was not satisfactory, or confusing, or did not answer what was asked, please comment so I can edit it.

It's worth mentioning that I'm using google translator to answer, I apologize for any inconvenience
Lucas Paixão
  • 862
  • 5
  • 11
  • 2
    Hi @lucas-paixão Thank you for the detailed answer. It works! I would like to highlight a minor suggestion where "startAnimation()" need not be called in the previous and next button handlers since the useEffect takes care of it. `useEffect(() => { startAnimation(); }, [currentSlide, slideShowDelay]);` – Vivek Oct 17 '22 at 07:14
  • 2
    @Vivek Thanks for the suggestion, it really is better to do it this way (in useEffect), it has gone unnoticed :), I updated the answer. – Lucas Paixão Oct 17 '22 at 11:20