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
3. Realize the image carousel that can be scrolled or dragged with the mouse
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!