Search code examples
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:

  • embla.scrollSnapList() is the list of "snap" anchors the carousel has, not the list of slides. Its length is the length of the list of slides only if the carousel is small enough to only show one full slide at a time. Otherwise it's smaller than the list of slides. So depending on how many slides can fit into the view, some slides towards the end may not get any scaling at all.
  • The scaling effect is dependent on the width of the Carousel, and the screen if the Carousel resizes according to it
  • The scaling effect is dependent on the number of slides. The smaller the number of slides, the more dramatic the scaling effect.

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):

    • The scaling effect is NOT dependent on the number of slides anymore.
    • It takes all slides into account.
    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?