javascripthtmlreactjstypescriptembla-carousel

Scaling Embla carousel slides in a consistent manner


I'm using Embla Carousels in a project and want to have a nice slide scaling effect as you scroll through. Slides should get bigger the more they reach the left edge of the carousel container, and scale down relative to their distance to that left edge.

I found this example on their website: https://www.embla-carousel.com/examples/predefined/#scale

The core logic goes like this:

const [embla, setEmbla] = useState<Embla | null>(null);
const [scaleValues, setScaleValues] = useState<number[]>([]);

useEffect(() => {
    if (!embla) return;

    const onScroll = () => {
      const engine = embla.internalEngine();
      const scrollProgress = embla.scrollProgress();

      const styles = embla.scrollSnapList().map((scrollSnap, index) => {
        let diffToTarget = scrollSnap - scrollProgress;

        if (engine.options.loop) {
          engine.slideLooper.loopPoints.forEach(loopItem => {
            const target = loopItem.target().get();
            if (index === loopItem.index && target !== 0) {
              const sign = Math.sign(target);
              if (sign === -1) diffToTarget = scrollSnap - (1 + scrollProgress);
              if (sign === 1) diffToTarget = scrollSnap + (1 - scrollProgress);
            }
          });
        }
        const scaleValue = 1 - Math.abs(diffToTarget * scaleFactor);
        return clamp(scaleValue, 0, 1);
      });
      setScaleValues(styles);
    };

    onScroll();
    const syncScroll = () => flushSync(onScroll);
    embla.on("scroll", syncScroll);
    embla.on("reInit", onScroll);
    return () => {
      embla.off("scroll", syncScroll);
      embla.off("reInit", onScroll);
    };
  }, [embla, scaleFactor]);

scaleValues gets then mapped onto the style property of the slides.

But they are several problems with this:

Is it possible to implement this feature while fixing all of the above?

The scaling difference between two slides should only be a function of their distance in px to the left edge of the Carousel, regardless of the Carousel's width or number of slides.


Solution

  • I've updated the tween examples including the scale example you mention. The following things have been fixed in the new example (see code snippet below):

    import React, { useCallback, useEffect, useRef } from 'react'
    import {
      EmblaCarouselType,
      EmblaEventType,
      EmblaOptionsType
    } from 'embla-carousel'
    import useEmblaCarousel from 'embla-carousel-react'
    
    const TWEEN_FACTOR_BASE = 0.52
    
    const numberWithinRange = (number: number, min: number, max: number): number =>
      Math.min(Math.max(number, min), max)
    
    type PropType = {
      slides: number[]
      options?: EmblaOptionsType
    }
    
    const EmblaCarousel: React.FC<PropType> = (props) => {
      const { slides, options } = props
      const [emblaRef, emblaApi] = useEmblaCarousel(options)
      const tweenFactor = useRef(0)
      const tweenNodes = useRef<HTMLElement[]>([])
    
      const setTweenNodes = useCallback((emblaApi: EmblaCarouselType): void => {
        tweenNodes.current = emblaApi.slideNodes().map((slideNode) => {
          return slideNode.querySelector('.embla__slide__number') as HTMLElement
        })
      }, [])
    
      // Make tween factor slide count agnostic
      const setTweenFactor = useCallback((emblaApi: EmblaCarouselType) => {
        tweenFactor.current = TWEEN_FACTOR_BASE * emblaApi.scrollSnapList().length
      }, [])
    
      const tweenScale = useCallback(
        (emblaApi: EmblaCarouselType, eventName?: EmblaEventType) => {
          const engine = emblaApi.internalEngine()
          const scrollProgress = emblaApi.scrollProgress()
          const slidesInView = emblaApi.slidesInView()
          const isScrollEvent = eventName === 'scroll'
    
          emblaApi.scrollSnapList().forEach((scrollSnap, snapIndex) => {
            let diffToTarget = scrollSnap - scrollProgress
            const slidesInSnap = engine.slideRegistry[snapIndex]
    
            // Include all slides when tweening
            slidesInSnap.forEach((slideIndex) => {
              if (isScrollEvent && !slidesInView.includes(slideIndex)) return
    
              if (engine.options.loop) {
                engine.slideLooper.loopPoints.forEach((loopItem) => {
                  const target = loopItem.target()
    
                  if (slideIndex === loopItem.index && target !== 0) {
                    const sign = Math.sign(target)
    
                    if (sign === -1) {
                      diffToTarget = scrollSnap - (1 + scrollProgress)
                    }
                    if (sign === 1) {
                      diffToTarget = scrollSnap + (1 - scrollProgress)
                    }
                  }
                })
              }
    
              const tweenValue = 1 - Math.abs(diffToTarget * tweenFactor.current)
              const scale = numberWithinRange(tweenValue, 0, 1).toString()
              const tweenNode = tweenNodes.current[slideIndex]
              tweenNode.style.transform = `scale(${scale})`
            })
          })
        },
        []
      )
    
      useEffect(() => {
        if (!emblaApi) return
    
        setTweenNodes(emblaApi)
        setTweenFactor(emblaApi)
        tweenScale(emblaApi)
    
        emblaApi
          .on('reInit', setTweenNodes)
          .on('reInit', setTweenFactor)
          .on('reInit', tweenScale)
          .on('scroll', tweenScale)
      }, [emblaApi, tweenScale])
    
      return (
        <div className="embla">
          <div className="embla__viewport" ref={emblaRef}>
            <div className="embla__container">
              {slides.map((index) => (
                <div className="embla__slide" key={index}>
                  <div className="embla__slide__number">{index + 1}</div>
                </div>
              ))}
            </div>
          </div>
        </div>
      )
    }
    
    export default EmblaCarousel
    

    Here's a link to the updated example in the docs. I hope this helps.

    I'm not sure what you mean regarding this:

    The scaling effect is dependent on the width of the Carousel, and the screen if the Carousel resizes according to it

    Because there's no explicit correlation between them?