strblr
strblr

Reputation: 950

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.

Upvotes: 1

Views: 2866

Answers (1)

David
David

Reputation: 577

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?

Upvotes: 1

Related Questions