Create a Circular Gallery in React
Arrange images in a rotating circular layout that responds to drag and scroll input.
A flat image grid is functional. A circular gallery is a statement. It signals that you thought carefully about how your work is presented, not just what you are presenting. The implementation is more interesting than you might expect.
The final result
What we are building
An infinite-scrolling, drag-enabled gallery rendered on a WebGL canvas using the OGL library. Images curve along the edge of a virtual cylinder. Dragging or scrolling spins the ring. Each image has a text label rendered as a WebGL texture.
Setting up
npm install oglimport { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl';
import { useEffect, useRef } from 'react';Building the component
The renderer
We create an OGL renderer with alpha and antialiasing, then attach its canvas to our container:
createRenderer() {
this.renderer = new Renderer({
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio || 1, 2),
});
this.gl = this.renderer.gl;
this.gl.clearColor(0, 0, 0, 0);
this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement);
}The vertex shader creates the bend
Each image plane has a vertex shader that displaces Z position as a sine wave based on position and time. The uSpeed uniform controls how strong the warp is during fast scrolling:
void main() {
vUv = uv;
vec3 p = position;
p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5)
* (0.1 + uSpeed * 0.5);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}Geometry with enough vertices
The plane needs enough segments for the vertex shader wave to look smooth:
createGeometry() {
this.planeGeometry = new Plane(this.gl, {
heightSegments: 50,
widthSegments: 100,
});
}Circular wrapping
Each Media object tracks its own extra offset for infinite looping. When it scrolls off one side, we shift it to the other:
if (direction === 'right' && this.isBefore) {
this.extra -= this.widthTotal;
this.isBefore = this.isAfter = false;
}
if (direction === 'left' && this.isAfter) {
this.extra += this.widthTotal;
this.isBefore = this.isAfter = false;
}Scroll lerp
The scroll target is updated immediately on input, but the current position eases toward it:
update() {
this.scroll.current = lerp(
this.scroll.current,
this.scroll.target,
this.scroll.ease
);
const direction = this.scroll.current > this.scroll.last ? 'right' : 'left';
this.medias.forEach(media => media.update(this.scroll, direction));
this.renderer.render({ scene: this.scene, camera: this.camera });
this.scroll.last = this.scroll.current;
this.raf = window.requestAnimationFrame(this.update.bind(this));
}How to use it
<CircularGallery
items={[
{ image: '/photos/one.jpg', text: 'Bridge' },
{ image: '/photos/two.jpg', text: 'Rooftop' },
]}
bend={3}
textColor="#ffffff"
borderRadius={0.05}
scrollSpeed={2}
scrollEase={0.05}
/>| Prop | Default | Description |
|------|---------|-------------|
| bend | 3 | Curvature of the gallery arc |
| textColor | #ffffff | Label color below each image |
| scrollSpeed | 2 | How fast mouse drag spins the gallery |
| scrollEase | 0.05 | Lerp factor for smooth deceleration |
Key takeaways
- The text labels are rendered as WebGL textures using an offscreen 2D canvas, which means they are part of the scene geometry rather than HTML overlays.
- Using a shared
Planegeometry for all images is a significant performance win: OGL creates the vertex buffer once and allMediainstances share it. - The
onCheckdebounce snaps the gallery to the nearest image after scrolling stops, so you never land between two images.