For the previous few months, I’ve been exploring totally different kinetic movement designs with textual content and pictures. The type seems to be very intriguing, so I made a decision to create some actually cool natural animations utilizing pictures and React Three Fiber.
On this article, we’ll discover ways to create the next animation utilizing Canvas2D and React Three Fiber.

Setting Up the View & Digicam
The digital camera’s area of view (FOV) performs an enormous position on this undertaking. Let’s maintain it very low so it seems to be like an orthographic digital camera. You’ll be able to experiment with totally different views later. I favor utilizing a perspective digital camera over an orthographic one as a result of we are able to all the time attempt totally different FOVs. For extra detailed implementation test supply code.
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 (
);
}
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 (
);
}
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 (
);
}
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 (
{Array.from({ length: COUNT }).map((_, index) => [
,
,
])}
);
}
3. Stack them up – We’ll use the index from our loop and the y place to stack our gadgets on prime of one another. Right here’s the way it seems to be to this point:
web page.jsx
'use consumer';
import kinds from './web page.module.scss';
import Billboard from '@/elements/webgl/Billboard/Billboard';
import Banner from '@/elements/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
const COUNT = 10;
const GAP = 3.2;
export default perform Residence() {
return (
{Array.from({ length: COUNT }).map((_, index) => [
,
,
])}
);
}
4. Add some rotation – Let’s rotate issues a bit! First, I’ll hard-code the rotation of our banners to make them extra curved and match properly with the Billboard part. We’ll additionally make the radius a bit greater.
web page.jsx
'use consumer';
import kinds from './web page.module.scss';
import Billboard from '@/elements/webgl/Billboard/Billboard';
import Banner from '@/elements/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
const COUNT = 10;
const GAP = 3.2;
export default perform Residence() {
return (
{Array.from({ length: COUNT }).map((_, index) => [
,
,
])}
);
}
5. Tilt the entire thing – Now let’s rotate our total group to make it appear to be the Leaning Tower of Pisa.
web page.jsx
'use consumer';
import kinds from './web page.module.scss';
import Billboard from '@/elements/webgl/Billboard/Billboard';
import Banner from '@/elements/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
const COUNT = 10;
const GAP = 3.2;
export default perform Residence() {
return (
// <-- rotate the group
{Array.from({ length: COUNT }).map((_, index) => [
,
,
])}
);
}
6. Good! – Our 3D shapes are all arrange. Now we are able to add our pictures to them.

Making a Texture from Our Photos Utilizing Canvas
Right here’s the cool half: we’ll put all our pictures onto a canvas, then use that canvas as a texture on our Billboard form.

To make this simpler, I created some helper features that simplify the entire course of.
getCanvasTexture.js
import * as THREE from 'three';
/**
* Preloads a picture and calculates its dimensions
*/
async perform preloadImage(imageUrl, axis, canvasHeight, canvasWidth) {
const img = new Picture();
img.crossOrigin = 'nameless';
await new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`Did not load picture: ${imageUrl}`));
img.src = imageUrl;
});
const aspectRatio = img.naturalWidth / img.naturalHeight;
let calculatedWidth;
let calculatedHeight;
if (axis === 'x') {
// Horizontal structure: scale to suit canvasHeight
calculatedHeight = canvasHeight;
calculatedWidth = canvasHeight * aspectRatio;
} else {
// Vertical structure: scale to suit canvasWidth
calculatedWidth = canvasWidth;
calculatedHeight = canvasWidth / aspectRatio;
}
return { img, width: calculatedWidth, peak: calculatedHeight };
}
perform calculateCanvasDimensions(imageData, axis, hole, canvasHeight, canvasWidth) {
if (axis === 'x') {
const totalWidth = imageData.cut back(
(sum, knowledge, index) => sum + knowledge.width + (index > 0 ? hole : 0), 0);
return { totalWidth, totalHeight: canvasHeight };
} else {
const totalHeight = imageData.cut back(
(sum, knowledge, index) => sum + knowledge.peak + (index > 0 ? hole : 0), 0);
return { totalWidth: canvasWidth, totalHeight };
}
}
perform setupCanvas(canvasElement, context, dimensions) {
const { totalWidth, totalHeight } = dimensions;
const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
canvasElement.width = totalWidth * devicePixelRatio;
canvasElement.peak = totalHeight * devicePixelRatio;
if (devicePixelRatio !== 1) context.scale(devicePixelRatio, devicePixelRatio);
context.fillStyle = '#ffffff';
context.fillRect(0, 0, totalWidth, totalHeight);
}
perform drawImages(context, imageData, axis, hole) {
let currentX = 0;
let currentY = 0;
context.save();
for (const knowledge of imageData) {
context.drawImage(knowledge.img, currentX, currentY, knowledge.width, knowledge.peak);
if (axis === 'x') currentX += knowledge.width + hole;
else currentY += knowledge.peak + hole;
}
context.restore();
}
perform 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,
peak: dimensions.totalHeight,
aspectRatio: dimensions.totalWidth / dimensions.totalHeight,
},
};
}
export async perform getCanvasTexture({
pictures,
hole = 10,
canvasHeight = 512,
canvasWidth = 512,
canvas,
ctx,
axis = 'x',
}) canvasElement.getContext('second');
if (!context) throw new Error('No context');
// Preload all pictures in parallel
const imageData = await Promise.all(
pictures.map((picture) => preloadImage(picture.url, axis, canvasHeight, canvasWidth))
);
// Calculate whole canvas dimensions
const dimensions = calculateCanvasDimensions(imageData, axis, hole, canvasHeight, canvasWidth);
// Setup canvas
setupCanvas(canvasElement, context, dimensions);
// Draw all pictures
drawImages(context, imageData, axis, hole);
// Create and return texture consequence
return createTextureResult(canvasElement, dimensions)
Then we are able to additionally create a useCollageTexture
hook that we are able to simply use in our elements.
useCollageTexture.jsx
import { useState, useEffect, useCallback } from 'react';
import { getCanvasTexture } from '@/webgl/helpers/getCanvasTexture';
export perform useCollageTexture(pictures, choices = {}) {
const [textureResults, setTextureResults] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const { hole = 0, canvasHeight = 512, canvasWidth = 512, axis = 'x' } = choices;
const createTexture = useCallback(async () => {
attempt {
setIsLoading(true);
setError(null);
const consequence = await getCanvasTexture({
pictures,
hole,
canvasHeight,
canvasWidth,
axis,
});
setTextureResults(consequence);
} catch (err) {
setError(err instanceof Error ? err : new Error('Did not create texture'));
} lastly {
setIsLoading(false);
}
}, [images, gap, canvasHeight, canvasWidth, axis]);
useEffect(() => {
if (pictures.size > 0) createTexture();
}, [images.length, createTexture]);
return null,
dimensions: textureResults?.dimensions ;
}
Including the Canvas to Our Billboard
Now let’s use our useCollageTexture
hook on our web page. We’ll create some easy loading logic. It takes a second to fetch all the photographs and put them onto the canvas. Then we’ll go our texture and dimensions of canvas into the Billboard part.
web page.jsx
'use consumer';
import kinds from './web page.module.scss';
import Billboard from '@/elements/webgl/Billboard/Billboard';
import Banner from '@/elements/webgl/Banner/Banner';
import Loader from '@/elements/ui/modules/Loader/Loader';
import pictures from '@/knowledge/pictures';
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 perform Residence() {
const { texture, dimensions, isLoading } = useCollageTexture(pictures); // <-- getting the feel and dimensions from the useCollageTexture hook
if (isLoading) return ; // <-- displaying the loader when the feel is loading
return (
{Array.from({ length: COUNT }).map((_, index) => [
,
,
])}
);
}
Contained in the Billboard part, we have to correctly map this texture to ensure every part suits accurately. The width of our canvas will match the circumference of the cylinder, and we’ll heart the y place of the feel. This fashion, all the photographs maintain their decision and don’t get squished or stretched.
Billboard.jsx
'use consumer';
import * as THREE from 'three';
import { useRef } from 'react';
perform setupCylinderTextureMapping(texture, dimensions, radius, peak) {
const cylinderCircumference = 2 * Math.PI * radius;
const cylinderHeight = peak;
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;
}
// Middle the feel
texture.offset.y = (1 - texture.repeat.y) / 2;
}
perform Billboard({ texture, dimensions, radius = 5, ...props }) {
const ref = useRef(null);
setupCylinderTextureMapping(texture, dimensions, radius, 2);
return (
);
}
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 (
);
}
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 ',
/* glsl */ `#include
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 (
);
}
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 (
);
}
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 ',
/* glsl */ `#include
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 ',
/* glsl */ `#include
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 (
);
}
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!