Dirck Mulder
Components||3 min read

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

bash
npm install ogl
tsx
import { 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:

tsx
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:

glsl
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:

tsx
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:

tsx
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:

tsx
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

tsx
<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 Plane geometry for all images is a significant performance win: OGL creates the vertex buffer once and all Media instances share it.
  • The onCheck debounce snaps the gallery to the nearest image after scrolling stops, so you never land between two images.