WebGL carousel animation using React Three Fiber and GSAP

Reference: Building a WebGL Carousel with React Three Fiber and GSAP

  • online demo
  • github source code

The effect is derived from the website alcre.co.kr created by Eum Ray. It is visually captivating and interactive, with a draggable or scrollable carousel and an interesting display of images.

This article will use WebGL, React Three Fiber and GSAP to achieve a similar effect. In this article, learn how to create an interactive 3D carousel using WebGL, React Three Fiber, and GSAP.

ready

First, create the project with createreact app

npx create-react-app webgl-carsouel
cd webgl-carsouel
npm start

Then install the relevant dependencies

npm i @react-three/fiber @react-three/drei gsap leva react-use -S
  • @react-three/fiber: A well-known library for simplifying three.js written in react
  • @react-three/drei: A very useful library in the @react-three/fiber ecosystem, an enhancement to @react-three/fiber
  • gsap: a very famous animation library
  • leva: A library in the @react-three/fiber ecosystem to create GUI controls in seconds
  • react-use: A popular react hooks library

1. Generate 3D plane with texture

First, create a plane of any size, placed at the origin (0, 0, 0) and facing the camera. Then, use the shaderMaterial material to insert the desired image into the material, modifying the UV positions so that the image fills the entire surface of the geometry.

To do this, use a glsl function that takes the scale of the plane and the image as transformation parameters:

/*
--------------------------------
Background Cover UV
--------------------------------
u = basic UV
s = plane size
i = image size
*/
vec2 CoverUV(vec2 u, vec2 s, vec2 i) {
  float rs = s.x / s.y; // aspect plane size
  float ri = i.x / i.y; // aspect image size
  vec2 st = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x); // new st
  vec2 o = (rs < ri ? vec2((st.x - s.x) / 2.0, 0.0) : vec2(0.0, (st.y - s.y) / 2.0)) / st; // offset
  return u * s / st + o;
}

Next, 2 uniforms will be defined: uRes and uImageRes. Whenever the viewport size is changed, these 2 variables will change accordingly. Use uRes to store the size of the facet in pixels and uImageRes to store the size of the image texture.

Here is the code to create the plane and set the shader material:

// Plane.js

import { useEffect, useMemo, useRef } from "react"
import { useThree } from "@react-three/fiber"
import { useTexture } from "@react-three/drei"
import { useControls } from 'leva'

const Plane = () => {
  const $mesh = useRef()
  const { viewport } = useThree()
  const tex = useTexture(
    'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/1.jpg'
  )

  const { width, height } = useControls({
    width: {
      value: 2,
      min: 0.5,
      max: viewport. width,
    },
    height: {
      value: 3,
      min: 0.5,
      max: viewport. height,
    }
  })

  useEffect(() => {
    if ($mesh. current. material) {
      $mesh.current.material.uniforms.uRes.value.x = width
      $mesh.current.material.uniforms.uRes.value.y = height
    }
  }, [viewport, width, height])

  const shaderArgs = useMemo(() => ({
    uniforms: {
      uTex: { value: tex },
      uRes: { value: { x: 1, y: 1 } },
      uImageRes: {
        value: { x: tex.source.data.width, y: tex.source.data.height }
      }
    },
    vertexShader: /* glsl */ `
      varying vec2 vUv;

      void main() {
        vUv = uv;
        vec3 pos = position;
        gl_Position = projectionMatrix * modelViewMatrix * vec4( pos, 1.0 );
      }
    `,
    fragmentShader: /* glsl */ `
      uniform sampler2D uTex;
      uniform vec2 uRes;
      uniform vec2 uImageRes;

      /*
      --------------------------------------
      background cover UV
      --------------------------------------
      u = basic UV
      s = screen size
      i = image size
      */
      vec2 CoverUV(vec2 u, vec2 s, vec2 i) {
        float rs = s.x / s.y; // aspect screen size
        float ri = i.x / i.y; // aspect image size
        vec2 st = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x); // new st
        vec2 o = (rs < ri ? vec2((st.x - s.x) / 2.0, 0.0) : vec2(0.0, (st.y - s.y) / 2.0)) / st; // offset
        return u * s / st + o;
      }

      varying vec2 vUv;

      void main() {
        vec2 uv = CoverUV(vUv, uRes, uImageRes);
        vec3 tex = texture2D(uTex, uv).rgb;
        gl_FragColor = vec4(tex, 1.0);
      }
    `
  }), [tex])

  return (
    <mesh ref={$mesh}>
      <planeGeometry args={[width, height, 30, 30]} />
      <shaderMaterial args={[shaderArgs]} />
    </mesh>
  )
}

export default Plane

2. Add zoom effect to plane

First, set up a new component to wrap to manage the activation and deactivation of the zoom effect.

Resizing a mesh using a shader material shaderMaterial maintains the dimensions of the geometric space. Therefore, after activating the zoom effect, a new transparent plane must be displayed, the size of the viewport, so that the whole image can be clicked back to the initial state.

In addition, the wave effect needs to be implemented in the shader of the plane.

Therefore, add a new field uZoomScale in uniforms to store the values x, y of the zoom plane, so that The location of the vertex shader. The zoom value is calculated as a ratio between the plane size and the viewport size:

$mesh.current.material.uniforms.uZoomScale.value.x = viewport.width / width
$mesh.current.material.uniforms.uZoomScale.value.y = viewport.height / height

Next, add a new field uProgress to uniforms to control the amount of wave effect. By modifying uProgress using GSAP, the animation achieves a smooth easing effect.

To create a wave effect, you can use the sin function in the vertex shader, which adds wave-like motion to the x and y position of the plane.

// CarouselItem.js

import { useEffect, useRef, useState } from "react"
import { useThree } from "@react-three/fiber"
import gsap from "gsap"
import Plane from './Plane'

const CarouselItem = () => {
  const $root = useRef()
  const [hover, setHover] = useState(false)
  const [isActive, setIsActive] = useState(false)
  const { viewport } = useThree()

  useEffect(() => {
    gsap.killTweensOf($root.current.position)
    gsap.to($root.current.position, {
      z: isActive? 0 : -0.01,
      duration: 0.2,
      ease: "power3.out",
      delay: isActive? 0 : 2
    })
  }, [isActive])

  // hover effect
  useEffect(() => {
    const hoverScale = hover & amp; & amp; !isActive ? 1.1 : 1
    gsap.to($root.current.scale, {
      x: hoverScale,
      y: hoverScale,
      duration: 0.5,
      ease: "power3.out"
    })
  }, [hover, isActive])

  const handleClose = (e) => {
    e. stopPropagation()
    if (!isActive) return
    setIsActive(false)
  }

  const textureUrl = 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/1.jpg'


  return (
    <group
      ref={$root}
      onClick={() => setIsActive(true)}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <Plane
        width={1}
        height={2.5}
        texture={textureUrl}
        active={isActive}
      />

      {isActive? (
        <mesh position={[0, 0, 0]} onClick={handleClose}>
          <planeGeometry args={[viewport. width, viewport. height]} />
          <meshBasicMaterial transparent={true} opacity={0} color="red" />
        </mesh>
      ) : null}
    </group>
  )
}

export default CarouselItem

The component also needs to be changed to support parameters and parameter change processing. After the change:

// Plane.js

import { useEffect, useMemo, useRef } from "react"
import { useThree } from "@react-three/fiber"
import { useTexture } from "@react-three/drei"
import gsap from "gsap"

const Plane = ({ texture, width, height, active, ...props}) => {
  const $mesh = useRef()
  const { viewport } = useThree()
  const tex = useTexture(texture)

  useEffect(() => {
    if ($mesh. current. material) {
      // setting
      $mesh.current.material.uniforms.uZoomScale.value.x = viewport.width / width
      $mesh.current.material.uniforms.uZoomScale.value.y = viewport.height / height

      gsap.to($mesh.current.material.uniforms.uProgress, {
        value: active? 1 : 0,
        duration: 2.5,
        ease: 'power3.out'
      })

      gsap.to($mesh.current.material.uniforms.uRes.value, {
        x: active ? viewport. width : width,
        y: active? viewport. height : height,
        duration: 2.5,
        ease: 'power3.out'
      })
    }
  }, [viewport, active]);

  const shaderArgs = useMemo(() => ({
    uniforms: {
      uProgress: { value: 0 },
      uZoomScale: { value: { x: 1, y: 1 } },
      uTex: { value: tex },
      uRes: { value: { x: 1, y: 1 } },
      uImageRes: {
        value: { x: tex.source.data.width, y: tex.source.data.height }
      }
    },
    vertexShader: /* glsl */ `
      varying vec2 vUv;
      uniform float uProgress;
      uniform vec2 uZoomScale;

      void main() {
        vUv = uv;
        vec3 pos = position;
        float angle = uProgress * 3.14159265 / 2.;
        float wave = cos(angle);
        float c = sin(length(uv - .5) * 15. + uProgress * 12.) * .5 + .5;
        pos.x *= mix(1., uZoomScale.x + wave * c, uProgress);
        pos.y *= mix(1., uZoomScale.y + wave * c, uProgress);

        gl_Position = projectionMatrix * modelViewMatrix * vec4( pos, 1.0 );
      }
    `,
    fragmentShader: /* glsl */ `
      uniform sampler2D uTex;
      uniform vec2 uRes;
      // uniform vec2 uZoomScale;
      uniform vec2 uImageRes;

      /*
      --------------------------------------
      background cover UV
      --------------------------------------
      u = basic UV
      s = screen size
      i = image size
      */
      vec2 CoverUV(vec2 u, vec2 s, vec2 i) {
        float rs = s.x / s.y; // aspect screen size
        float ri = i.x / i.y; // aspect image size
        vec2 st = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x); // new st
        vec2 o = (rs < ri ? vec2((st.x - s.x) / 2.0, 0.0) : vec2(0.0, (st.y - s.y) / 2.0)) / st; // offset
        return u * s / st + o;
      }

      varying vec2 vUv;

      void main() {
        vec2 uv = CoverUV(vUv, uRes, uImageRes);
        vec3 tex = texture2D(uTex, uv).rgb;
        gl_FragColor = vec4(tex, 1.0);
      }
    `
  }), [tex])

  return (
    <mesh ref={$mesh} {...props}>
      <planeGeometry args={[width, height, 30, 30]} />
      <shaderMaterial args={[shaderArgs]} />
    </mesh>
  )
}

export default Plane

This part is the most interesting, but also the most complicated, because many things have to be considered.

First, you need to use renderSlider to create a group to contain all the images, which are rendered with .
Then, you need to use renderPlaneEvent() to create a plane to manage events.

The most important part of the carousel is in useFrame(), which needs to calculate the progress of the slider, and use the displayItems() function to set all item positions.

Another important aspect to consider is the z position of the when it becomes active it needs to have its z position Move closer to the camera so that the scaling effect does not conflict with other meshs. That’s why the mesh needs to be small enough to restore the z axis position to 0 when zooming out (see for details). Also why clicking on other meshs is disabled until the zoom effect is disabled.

// data/images.js

const images = [
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/1.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/2.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/3.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/4.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/5.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/6.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/7.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/8.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/9.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/10.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/11.jpg' },
  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/12.jpg' }
]

export default images
// Carousel.js

import { useEffect, useRef, useState, useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { usePrevious } from 'react-use'
import gsap from 'gsap'
import CarouselItem from './CarouselItem'
import images from '../data/images'

// Plane settings
const planeSettings = {
  width: 1,
  height: 2.5,
  gap: 0.1
}

// gsap defaults
gsap. defaults({
  duration: 2.5,
  ease: 'power3.out'
})

const Carousel = () => {
  const [$root, setRoot] = useState();

  const [activePlane, setActivePlane] = useState(null);
  const prevActivePlane = usePrevious(activePlane)
  const { viewport } = useThree()

  // vars
  const progress = useRef(0)
  const startX = useRef(0)
  const isDown = useRef(false)
  const speedWheel = 0.02
  const speedDrag = -0.3
  const $items = useMemo(() => {
    if ($root) return $root.children
  }, [$root])

  const displayItems = (item, index, active) => {
    gsap.to(item.position, {
      x: (index - active) * (planeSettings. width + planeSettings. gap),
      y: 0
    })
  }

  useFrame(() => {
    progress.current = Math.max(0, Math.min(progress.current, 100))

    const active = Math. floor((progress. current / 100) * ($items. length - 1))
    $items.forEach((item, index) => displayItems(item, index, active))
  })

  const handleWheel = (e) => {
    if (activePlane !== null) return
    const isVerticalScroll = Math.abs(e.deltaY) > Math.abs(e.deltaX)
    const wheelProgress = isVerticalScroll ? e.deltaY : e.deltaX
    progress.current = progress.current + wheelProgress * speedWheel
  }

  const handleDown = (e) => {
    if (activePlane !== null) return
    isDown. current = true
    startX.current = e.clientX || (e.touches & amp; & amp; e.touches[0].clientX) || 0
  }

  const handleUp = () => {
    isDown. current = false
  }

  const handleMove = (e) => {
    if (activePlane !== null || !isDown.current) return
    const x = e.clientX || (e.touches & amp; & amp; e.touches[0].clientX) || 0
    const mouseProgress = (x - startX. current) * speedDrag
    progress.current = progress.current + mouseProgress
    startX.current = x
  }

  //click
  useEffect(() => {
    if (!$items) return
    if (activePlane !== null & amp; & amp; prevActivePlane === null) {
      progress.current = (activePlane / ($items.length - 1)) * 100
    }
  }, [activePlane, $items]);

  const renderPlaneEvents = () => {
    return (
      <mesh
        position={[0, 0, -0.01]}
        onWheel={handleWheel}
        onPointerDown={handleDown}
        onPointerUp={handleUp}
        onPointerMove = {handleMove}
        onPointerLeave={handleUp}
        onPointerCancel={handleUp}
      >
        <planeGeometry args={[viewport. width, viewport. height]} />
        <meshBasicMaterial transparent={true} opacity={0} />
      </mesh>
    )
  }

  const renderSlider = () => {
    return (
      <group ref={setRoot}>
        {images. map((item, i) => (
          <CarouselItem
            width={planeSettings. width}
            height={planeSettings. height}
            setActivePlane = {setActivePlane}
            activePlane={activePlane}
            key={item. image}
            item={item}
            index={i}
          />
        ))}
      </group>
    )
  }

  return (
    <group>
      {renderPlaneEvents()}
      {renderSlider()}
    </group>
  )
}

export default Carousel

needs to be changed in order to display different images according to the parameters, and other details, after the changes are as follows:

// CarouselItem.js

import { useEffect, useRef, useState } from "react"
import { useThree } from "@react-three/fiber"
import gsap from "gsap"
import Plane from './Plane'

const CarouselItem = ({
  index,
  width,
  height,
  setActivePlane,
  activePlane,
  item
}) => {
  const $root = useRef()
  const [hover, setHover] = useState(false)
  const [isActive, setIsActive] = useState(false)
  const [isCloseActive, setIsCloseActive] = useState(false);
  const { viewport } = useThree()
  const timeoutID = useRef()

  useEffect(() => {
    if (activePlane === index) {
      setIsActive(activePlane === index)
      setIsCloseActive(true)
    } else {
      setIsActive(null)
    }
  }, [activePlane]);
  
  useEffect(() => {
    gsap.killTweensOf($root.current.position)
    gsap.to($root.current.position, {
      z: isActive? 0 : -0.01,
      duration: 0.2,
      ease: "power3.out",
      delay: isActive? 0 : 2
    })
  }, [isActive])

  // hover effect
  useEffect(() => {
    const hoverScale = hover & amp; & amp; !isActive ? 1.1 : 1
    gsap.to($root.current.scale, {
      x: hoverScale,
      y: hoverScale,
      duration: 0.5,
      ease: "power3.out"
    })
  }, [hover, isActive])

  const handleClose = (e) => {
    e. stopPropagation()
    if (!isActive) return
    setActivePlane(null)
    setHover(false)
    clearTimeout(timeoutID. current)
    timeoutID.current = setTimeout(() => {
      setIsCloseActive(false)
    }, 1500);
    // The duration of this timer depends on when the plane closes the animation
  }

  return (
    <group
      ref={$root}
      onClick={() => setActivePlane(index)}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <Plane
        width={width}
        height={height}
        texture={item.image}
        active={isActive}
      />

      {isCloseActive? (
        <mesh position={[0, 0, 0.01]} onClick={handleClose}>
          <planeGeometry args={[viewport. width, viewport. height]} />
          <meshBasicMaterial transparent={true} opacity={0} color="red" />
        </mesh>
      ) : null}
    </group>
  )
}

export default CarouselItem

4. Realize post-processing effect and enhance carousel experience

What really caught my eye and inspired me to replicate the secondary carousel was the effect of stretching pixels at the edges of the viewport.

I’ve done this many times in the past with custom shaders using @react-three/postprocessing. However, lately I’ve been using MeshTransmissionMaterial, so I had an idea to try to cover mesh with this material and adjust the settings to achieve the effect. The effect is almost the same!

The trick is to tie the thickness property of the material to the speed at which the carousel scrolls progress, and that’s it.

// PostProcessing.js

import { forwardRef } from "react";
import { useThree } from "@react-three/fiber";
import { MeshTransmissionMaterial } from "@react-three/drei";
import { Color } from "three";
import { useControls } from 'leva'

const PostProcessing = forwardRef((_, ref) => {
  const { viewport } = useThree()

  const { active, ior } = useControls({
    active: { value: true },
    ior: {
      value: 0.9,
      min: 0.8,
      max: 1.2
    }
  })

  return active? (
    <mesh position={[0, 0, 1]}>
      <planeGeometry args={[viewport. width, viewport. height]} />
      <MeshTransmissionMaterial
        ref={ref}
        background={new Color('white')}
        transmission={0.7}
        roughness={0}
        thickness={0}
        chromaticAberration={0.06}
        anisotropy={0}
        ior={ior}
      />
    </mesh>
  ) : null
})

export default PostProcessing

Because the post-processing acts on the component, it needs to be changed accordingly, and the changes are as follows:

// Carousel.js

import { useEffect, useRef, useState, useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { usePrevious } from 'react-use'
import gsap from 'gsap'
import PostProcessing from "./PostProcessing";
import CarouselItem from './CarouselItem'
import { lerp, getPiramidalIndex } from "../utils";
import images from '../data/images'

// Plane settings
const planeSettings = {
  width: 1,
  height: 2.5,
  gap: 0.1
}

// gsap defaults
gsap. defaults({
  duration: 2.5,
  ease: 'power3.out'
})

const Carousel = () => {
  const [$root, setRoot] = useState();
  const $post = useRef()

  const [activePlane, setActivePlane] = useState(null);
  const prevActivePlane = usePrevious(activePlane)
  const { viewport } = useThree()

  // vars
  const progress = useRef(0)
  const startX = useRef(0)
  const isDown = useRef(false)
  const speedWheel = 0.02
  const speedDrag = -0.3
  const oldProgress = useRef(0)
  const speed = useRef(0)
  const $items = useMemo(() => {
    if ($root) return $root.children
  }, [$root])

  const displayItems = (item, index, active) => {
    const piramidalIndex = getPiramidalIndex($items, active)[index]
    gsap.to(item.position, {
      x: (index - active) * (planeSettings. width + planeSettings. gap),
      y: $items.length * -0.1 + piramidalIndex * 0.1
    })
  }

  useFrame(() => {
    progress.current = Math.max(0, Math.min(progress.current, 100))

    const active = Math. floor((progress. current / 100) * ($items. length - 1))
    $items.forEach((item, index) => displayItems(item, index, active))

    speed.current = lerp(speed.current, Math.abs(oldProgress.current - progress.current), 0.1)

    oldProgress.current = lerp(oldProgress.current, progress.current, 0.1)

    if ($post. current) {
      $post.current.thickness = speed.current
    }
  })

  const handleWheel = (e) => {
    if (activePlane !== null) return
    const isVerticalScroll = Math.abs(e.deltaY) > Math.abs(e.deltaX)
    const wheelProgress = isVerticalScroll ? e.deltaY : e.deltaX
    progress.current = progress.current + wheelProgress * speedWheel
  }

  const handleDown = (e) => {
    if (activePlane !== null) return
    isDown. current = true
    startX.current = e.clientX || (e.touches & amp; & amp; e.touches[0].clientX) || 0
  }

  const handleUp = () => {
    isDown. current = false
  }

  const handleMove = (e) => {
    if (activePlane !== null || !isDown.current) return
    const x = e.clientX || (e.touches & amp; & amp; e.touches[0].clientX) || 0
    const mouseProgress = (x - startX. current) * speedDrag
    progress.current = progress.current + mouseProgress
    startX.current = x
  }

  //click
  useEffect(() => {
    if (!$items) return
    if (activePlane !== null & amp; & amp; prevActivePlane === null) {
      progress.current = (activePlane / ($items.length - 1)) * 100
    }
  }, [activePlane, $items]);

  const renderPlaneEvents = () => {
    return (
      <mesh
        position={[0, 0, -0.01]}
        onWheel={handleWheel}
        onPointerDown={handleDown}
        onPointerUp={handleUp}
        onPointerMove = {handleMove}
        onPointerLeave={handleUp}
        onPointerCancel={handleUp}
      >
        <planeGeometry args={[viewport. width, viewport. height]} />
        <meshBasicMaterial transparent={true} opacity={0} />
      </mesh>
    )
  }

  const renderSlider = () => {
    return (
      <group ref={setRoot}>
        {images. map((item, i) => (
          <CarouselItem
            width={planeSettings. width}
            height={planeSettings. height}
            setActivePlane = {setActivePlane}
            activePlane={activePlane}
            key={item. image}
            item={item}
            index={i}
          />
        ))}
      </group>
    )
  }

  return (
    <group>
      {renderPlaneEvents()}
      {renderSlider()}
      <PostProcessing ref={$post} />
    </group>
  )
}

export default Carousel
// utils/index.js

/**
 * Return a value between v0, v1, which can be calculated according to t
 * Example:
 * lerp(5, 10, 0) // 5
 * lerp(5, 10, 1) // 10
 * lerp(5, 10, 0.2) // 6
 */
export const lerp = (v0, v1, t) => v0 * (1 - t) + v1 * t

/**
 * Returns an array with decreasing indices in the shape of a pyramid, starting at the specified index with the largest value. These indices are often used to create overlapping effects between elements
 * Example: array = [0, 1, 2, 3, 4, 5]
 * getPiramidalIndex(array, 0) // [ 6, 5, 4, 3, 2, 1 ]
 * getPiramidalIndex(array, 1) // [ 5, 6, 5, 4, 3, 2 ]
 * getPiramidalIndex(array, 2) // [ 4, 5, 6, 5, 4, 3 ]
 * getPiramidalIndex(array, 3) // [ 3, 4, 5, 6, 5, 4 ]
 * getPiramidalIndex(array, 4) // [ 2, 3, 4, 5, 6, 5 ]
 * getPiramidalIndex(array, 5) // [ 1, 2, 3, 4, 5, 6 ]
 */
export const getPiramidalIndex = (array, index) => {
  return array. map((_, i) => index === i ? array. length : array. length - Math. abs(index - i))
}

In summary, by using React Three Fiber, GSAP and some creativity, it is possible to create stunning visuals and interactive components in WebGL, like this carousel inspired by alcre.co.kr. Hope this article helps and inspires you for your own projects!