Free course recommendation: Master JavaScript animation with GSAP through 34 free video lessons, step-by-step projects, and hands-on demos. Enroll now →
For the past few months, I’ve been exploring different kinetic motion designs with text and images. The style looks very intriguing, so I decided to create some really cool organic animations using images and React Three Fiber.
In this article, we’ll learn how to create the following animation using Canvas2D and React Three Fiber.

Setting Up the View & Camera
The camera’s field of view (FOV) plays a huge role in this project. Let’s keep it very low so it looks like an orthographic camera. You can experiment with different perspectives later. I prefer using a perspective camera over an orthographic one because we can always try different FOVs. For more detailed implementation check source code.
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
Setting Up Our 3D Shapes
First, let’s create and position 3D objects that will display our images. For this example, we need to make 2 components:
Billboard.tsx – This is a cylinder that will show our stack of images
'use client';
import { useRef } from 'react';
import * as THREE from 'three';
function Billboard({ radius = 5, ...props }) {
const ref = useRef(null);
return (
<mesh ref={ref} {...props}>
<cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
<meshBasicMaterial color="red" side={THREE.DoubleSide} />
</mesh>
);
}
Banner.tsx – This is another cylinder that will work like a moving banner
'use client';
import * as THREE from 'three';
import { useRef } from 'react';
function Banner({ radius = 1.6, ...props }) {
const ref = useRef(null);
return (
<mesh ref={ref} {...props}>
<cylinderGeometry
args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
/>
<meshBasicMaterial
color="blue"
side={THREE.DoubleSide}
/>
</mesh>
);
}
export default Banner;
Once we have our components ready, we can use them on our page.
Now let’s build the whole shape:
1. Create a wrapper group – We’ll make a group that wraps all our components. This will help us rotate everything together later.
page.jsx
'use client';
import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
export default function Home() {
return (
<div className={styles.page}>
<View className={styles.view} orbit={false}>
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
<group>
</group>
</View>
</div>
);
}
2. Render Billboard and Banner components in the loop – Inside our group, we’ll create a loop to render our Billboards and Banners multiple times.
page.jsx
'use client';
import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
export default function Home() {
return (
<div className={styles.page}>
<View className={styles.view} orbit={false}>
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
<group>
{Array.from({ length: COUNT }).map((_, index) => [
<Billboard
key={`billboard-${index}`}
radius={5}
/>,
<Banner
key={`banner-${index}`}
radius={5}
/>,
])}
</group>
</View>
</div>
);
}
3. Stack them up – We’ll use the index from our loop and the y position to stack our items on top of each other. Here’s how it looks so far:
page.jsx
'use client';
import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
const COUNT = 10;
const GAP = 3.2;
export default function Home() {
return (
<div className={styles.page}>
<View className={styles.view} orbit={false}>
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
<group>
{Array.from({ length: COUNT }).map((_, index) => [
<Billboard
key={`billboard-${index}`}
radius={5}
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
/>,
<Banner
key={`banner-${index}`}
radius={5}
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
/>,
])}
</group>
</View>
</div>
);
}
4. Add some rotation – Let’s rotate things a bit! First, I’ll hard-code the rotation of our banners to make them more curved and fit nicely with the Billboard component. We’ll also make the radius a bit bigger.
page.jsx
'use client';
import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
const COUNT = 10;
const GAP = 3.2;
export default function Home() {
return (
<div className={styles.page}>
<View className={styles.view} orbit={false}>
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
<group>
{Array.from({ length: COUNT }).map((_, index) => [
<Billboard
key={`billboard-${index}`}
radius={5}
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
rotation={[0, index * Math.PI * 0.5, 0]} // <-- rotation of the billboard
/>,
<Banner
key={`banner-${index}`}
radius={5}
rotation={[0, 0, 0.085]} // <-- rotation of the banner
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
/>,
])}
</group>
</View>
</div>
);
}
5. Tilt the whole thing – Now let’s rotate our entire group to make it look like the Leaning Tower of Pisa.
page.jsx
'use client';
import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
const COUNT = 10;
const GAP = 3.2;
export default function Home() {
return (
<div className={styles.page}>
<View className={styles.view} orbit={false}>
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
<group rotation={[-0.15, 0, -0.2]}> // <-- rotate the group
{Array.from({ length: COUNT }).map((_, index) => [
<Billboard
key={`billboard-${index}`}
radius={5}
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
rotation={[0, index * Math.PI * 0.5, 0]}
/>,
<Banner
key={`banner-${index}`}
radius={5}
rotation={[0, 0, 0.085]}
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
/>,
])}
</group>
</View>
</div>
);
}
6. Perfect! – Our 3D shapes are all set up. Now we can add our images to them.

Creating a Texture from Our Images Using Canvas
Here’s the cool part: we’ll put all our images onto a canvas, then use that canvas as a texture on our Billboard shape.

To make this easier, I created some helper functions that simplify the whole process.
getCanvasTexture.js
import * as THREE from 'three';
/**
* Preloads an image and calculates its dimensions
*/
async function preloadImage(imageUrl, axis, canvasHeight, canvasWidth) {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
img.src = imageUrl;
});
const aspectRatio = img.naturalWidth / img.naturalHeight;
let calculatedWidth;
let calculatedHeight;
if (axis === 'x') {
// Horizontal layout: scale to fit canvasHeight
calculatedHeight = canvasHeight;
calculatedWidth = canvasHeight * aspectRatio;
} else {
// Vertical layout: scale to fit canvasWidth
calculatedWidth = canvasWidth;
calculatedHeight = canvasWidth / aspectRatio;
}
return { img, width: calculatedWidth, height: calculatedHeight };
}
function calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth) {
if (axis === 'x') {
const totalWidth = imageData.reduce(
(sum, data, index) => sum + data.width + (index > 0 ? gap : 0), 0);
return { totalWidth, totalHeight: canvasHeight };
} else {
const totalHeight = imageData.reduce(
(sum, data, index) => sum + data.height + (index > 0 ? gap : 0), 0);
return { totalWidth: canvasWidth, totalHeight };
}
}
function setupCanvas(canvasElement, context, dimensions) {
const { totalWidth, totalHeight } = dimensions;
const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
canvasElement.width = totalWidth * devicePixelRatio;
canvasElement.height = totalHeight * devicePixelRatio;
if (devicePixelRatio !== 1) context.scale(devicePixelRatio, devicePixelRatio);
context.fillStyle = '#ffffff';
context.fillRect(0, 0, totalWidth, totalHeight);
}
function drawImages(context, imageData, axis, gap) {
let currentX = 0;
let currentY = 0;
context.save();
for (const data of imageData) {
context.drawImage(data.img, currentX, currentY, data.width, data.height);
if (axis === 'x') currentX += data.width + gap;
else currentY += data.height + gap;
}
context.restore();
}
function createTextureResult(canvasElement, dimensions) {
const texture = new THREE.CanvasTexture(canvasElement);
texture.needsUpdate = true;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
return {
texture,
dimensions: {
width: dimensions.totalWidth,
height: dimensions.totalHeight,
aspectRatio: dimensions.totalWidth / dimensions.totalHeight,
},
};
}
export async function getCanvasTexture({
images,
gap = 10,
canvasHeight = 512,
canvasWidth = 512,
canvas,
ctx,
axis = 'x',
}) {
if (!images.length) throw new Error('No images');
// Create canvas and context if not provided
const canvasElement = canvas || document.createElement('canvas');
const context = ctx || canvasElement.getContext('2d');
if (!context) throw new Error('No context');
// Preload all images in parallel
const imageData = await Promise.all(
images.map((image) => preloadImage(image.url, axis, canvasHeight, canvasWidth))
);
// Calculate total canvas dimensions
const dimensions = calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth);
// Setup canvas
setupCanvas(canvasElement, context, dimensions);
// Draw all images
drawImages(context, imageData, axis, gap);
// Create and return texture result
return createTextureResult(canvasElement, dimensions)
}
Then we can also create a useCollageTexture
hook that we can easily use in our components.
useCollageTexture.jsx
import { useState, useEffect, useCallback } from 'react';
import { getCanvasTexture } from '@/webgl/helpers/getCanvasTexture';
export function useCollageTexture(images, options = {}) {
const [textureResults, setTextureResults] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const { gap = 0, canvasHeight = 512, canvasWidth = 512, axis = 'x' } = options;
const createTexture = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const result = await getCanvasTexture({
images,
gap,
canvasHeight,
canvasWidth,
axis,
});
setTextureResults(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to create texture'));
} finally {
setIsLoading(false);
}
}, [images, gap, canvasHeight, canvasWidth, axis]);
useEffect(() => {
if (images.length > 0) createTexture();
}, [images.length, createTexture]);
return {
texture: textureResults?.texture || null,
dimensions: textureResults?.dimensions || null,
isLoading,
error,
};
}
Adding the Canvas to Our Billboard
Now let’s use our useCollageTexture
hook on our page. We’ll create some simple loading logic. It takes a second to fetch all the images and put them onto the canvas. Then we’ll pass our texture and dimensions of canvas into the Billboard component.
page.jsx
'use client';
import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import Loader from '@/components/ui/modules/Loader/Loader';
import images from '@/data/images';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
import { useCollageTexture } from '@/hooks/useCollageTexture';
const COUNT = 10;
const GAP = 3.2;
export default function Home() {
const { texture, dimensions, isLoading } = useCollageTexture(images); // <-- getting the texture and dimensions from the useCollageTexture hook
if (isLoading) return <Loader />; // <-- showing the loader when the texture is loading
return (
<div className={styles.page}>
<View className={styles.view} orbit={false}>
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 100]} near={0.01} far={100000} />
<group rotation={[-0.15, 0, -0.2]}>
{Array.from({ length: COUNT }).map((_, index) => [
<Billboard
key={`billboard-${index}`}
radius={5}
rotation={[0, index * Math.PI * 0.5, 0]}
position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
texture={texture} // <--passing the texture to the billboard
dimensions={dimensions} // <--passing the dimensions to the billboard
/>,
<Banner
key={`banner-${index}`}
radius={5.035}
rotation={[0, 0, 0.085]}
position={[
0,
(index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5,
0,
]}
/>,
])}
</group>
</View>
</div>
);
}
Inside the Billboard component, we need to properly map this texture to make sure everything fits correctly. The width of our canvas will match the circumference of the cylinder, and we’ll center the y position of the texture. This way, all the images keep their resolution and don’t get squished or stretched.
Billboard.jsx
'use client';
import * as THREE from 'three';
import { useRef } from 'react';
function setupCylinderTextureMapping(texture, dimensions, radius, height) {
const cylinderCircumference = 2 * Math.PI * radius;
const cylinderHeight = height;
const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
if (dimensions.aspectRatio > cylinderAspectRatio) {
// Canvas is wider than cylinder proportionally
texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
texture.repeat.y = 1;
texture.offset.x = (1 - texture.repeat.x) / 2;
} else {
// Canvas is taller than cylinder proportionally
texture.repeat.x = 1;
texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
}
// Center the texture
texture.offset.y = (1 - texture.repeat.y) / 2;
}
function Billboard({ texture, dimensions, radius = 5, ...props }) {
const ref = useRef(null);
setupCylinderTextureMapping(texture, dimensions, radius, 2);
return (
<mesh ref={ref} {...props}>
<cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
<meshBasicMaterial map={texture} side={THREE.DoubleSide} />
</mesh>
);
}
export default Billboard;
Now let’s animate them using the useFrame
hook. The trick to animating these images is to just move the X offset of the texture. This gives us the effect of a rotating mesh, when really we’re just moving the texture offset.
Billboard.jsx
'use client';
import * as THREE from 'three';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
function setupCylinderTextureMapping(texture, dimensions, radius, height) {
const cylinderCircumference = 2 * Math.PI * radius;
const cylinderHeight = height;
const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
if (dimensions.aspectRatio > cylinderAspectRatio) {
// Canvas is wider than cylinder proportionally
texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
texture.repeat.y = 1;
texture.offset.x = (1 - texture.repeat.x) / 2;
} else {
// Canvas is taller than cylinder proportionally
texture.repeat.x = 1;
texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
}
// Center the texture
texture.offset.y = (1 - texture.repeat.y) / 2;
}
function Billboard({ texture, dimensions, radius = 5, ...props }) {
const ref = useRef(null);
setupCylinderTextureMapping(texture, dimensions, radius, 2);
useFrame((state, delta) => {
if (texture) texture.offset.x += delta * 0.001;
});
return (
<mesh ref={ref} {...props}>
<cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
<meshBasicMaterial map={texture} side={THREE.DoubleSide} />
</mesh>
);
}
export default Billboard;
I think it would look even better if we made the back of the images a little darker. To do this, I created MeshImageMaterial
– it’s just an extension of MeshBasicMaterial
that makes our backface a bit darker.
MeshImageMaterial.js
import * as THREE from 'three';
import { extend } from '@react-three/fiber';
export class MeshImageMaterial extends THREE.MeshBasicMaterial {
constructor(parameters = {}) {
super(parameters);
this.setValues(parameters);
}
onBeforeCompile = (shader) => {
shader.fragmentShader = shader.fragmentShader.replace(
'#include <color_fragment>',
/* glsl */ `#include <color_fragment>
if (!gl_FrontFacing) {
vec3 blackCol = vec3(0.0);
diffuseColor.rgb = mix(diffuseColor.rgb, blackCol, 0.7);
}
`
);
};
}
extend({ MeshImageMaterial });
Billboard.jsx
'use client';
import * as THREE from 'three';
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import '@/webgl/materials/MeshImageMaterial';
function setupCylinderTextureMapping(texture, dimensions, radius, height) {
const cylinderCircumference = 2 * Math.PI * radius;
const cylinderHeight = height;
const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
if (dimensions.aspectRatio > cylinderAspectRatio) {
// Canvas is wider than cylinder proportionally
texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
texture.repeat.y = 1;
texture.offset.x = (1 - texture.repeat.x) / 2;
} else {
// Canvas is taller than cylinder proportionally
texture.repeat.x = 1;
texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
}
// Center the texture
texture.offset.y = (1 - texture.repeat.y) / 2;
}
function Billboard({ texture, dimensions, radius = 5, ...props }) {
const ref = useRef(null);
setupCylinderTextureMapping(texture, dimensions, radius, 2);
useFrame((state, delta) => {
if (texture) texture.offset.x += delta * 0.001;
});
return (
<mesh ref={ref} {...props}>
<cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
<meshImageMaterial map={texture} side={THREE.DoubleSide} toneMapped={false} />
</mesh>
);
}
export default Billboard;
And now we have our images moving around cylinders. Next, we’ll focus on banners (or marquees, whatever you prefer).

Adding Texture to the Banner
The last thing we need to fix is our Banner component. I wrapped it with this texture. Feel free to take it and edit it however you want, but remember to keep the proper dimensions of the texture.

We simply import our texture using the useTexture
hook, map it onto our material, and animate the texture offset just like we did in our Billboard component.
Billboard.jsx
'use client';
import * as THREE from 'three';
import bannerTexture from '@/assets/images/banner.jpg';
import { useTexture } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function Banner({ radius = 1.6, ...props }) {
const ref = useRef(null);
const texture = useTexture(bannerTexture.src);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
useFrame((state, delta) => {
if (!ref.current) return;
const material = ref.current.material;
if (material.map) material.map.offset.x += delta / 30;
});
return (
<mesh ref={ref} {...props}>
<cylinderGeometry
args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
/>
<meshBasicMaterial
map={texture}
map-anisotropy={16}
map-repeat={[15, 1]}
side={THREE.DoubleSide}
toneMapped={false}
backfaceRepeatX={3}
/>
</mesh>
);
}
export default Banner;
Nice! Now we have something cool, but I think it would look even cooler if we replaced the backface with something different. Maybe a gradient? For this, I created another extension of MeshBasicMaterial
called MeshBannerMaterial
. As you probably guessed, we just put a gradient on the backface. That’s it! Let’s use it in our Banner component.

We replace the MeshBasicMaterial
with MeshBannerMaterial
and now it looks like this!
MeshBannerMaterial.js
import * as THREE from 'three';
import { extend } from '@react-three/fiber';
export class MeshBannerMaterial extends THREE.MeshBasicMaterial {
constructor(parameters = {}) {
super(parameters);
this.setValues(parameters);
this.backfaceRepeatX = 1.0;
if (parameters.backfaceRepeatX !== undefined)
this.backfaceRepeatX = parameters.backfaceRepeatX;
}
onBeforeCompile = (shader) => {
shader.uniforms.repeatX = { value: this.backfaceRepeatX * 0.1 };
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
/* glsl */ `#include <common>
uniform float repeatX;
vec3 pal( in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d ) {
return a + b*cos( 6.28318*(c*t+d) );
}
`
)
.replace(
'#include <color_fragment>',
/* glsl */ `#include <color_fragment>
if (!gl_FrontFacing) {
diffuseColor.rgb = pal(vMapUv.x * repeatX, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,1.0),vec3(0.0,0.10,0.20) );
}
`
);
};
}
extend({ MeshBannerMaterial });
Banner.jsx
'use client';
import * as THREE from 'three';
import bannerTexture from '@/assets/images/banner.jpg';
import { useTexture } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import '@/webgl/materials/MeshBannerMaterial';
function Banner({ radius = 1.6, ...props }) {
const ref = useRef(null);
const texture = useTexture(bannerTexture.src);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
useFrame((state, delta) => {
if (!ref.current) return;
const material = ref.current.material;
if (material.map) material.map.offset.x += delta / 30;
});
return (
<mesh ref={ref} {...props}>
<cylinderGeometry
args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
/>
<meshBannerMaterial
map={texture}
map-anisotropy={16}
map-repeat={[15, 1]}
side={THREE.DoubleSide}
toneMapped={false}
backfaceRepeatX={3}
/>
</mesh>
);
}
export default Banner;
And now we have it ✨

You can experiment with this method in lots of ways. For example, I created 2 more examples with shapes I made in Blender, and mapped canvas textures on them. You can check them out here:
Final Words
Check out the final versions of all demos:
I hope you enjoyed this tutorial and learned something new!
Feel free to check out the source code for more details!