A WebGL image gallery with PixiJS
Some UI effects sit just out of reach of HTML, CSS, and vanilla JavaScript. WebGL opens those doors, but working directly against the API is verbose and unforgiving. Libraries like PixiJS wrap that complexity in a friendlier layer while keeping GPU-accelerated rendering. This walkthrough builds an image gallery with a cubic lens distortion effect that responds to pointer input, using PixiJS and custom fragment shaders.
You do not need deep WebGL or PixiJS experience to follow along, but basic ES6 knowledge helps. If shaders are new to you, The Book of Shaders is a solid primer before diving in.
Starting the PixiJS app
The setup is minimal: include PixiJS as a script, create (or reference) a <canvas> element, then initialize the renderer with new PIXI.Application(options). The boilerplate produces a black canvas and a console message confirming the engine version and WebGL context.
const app = new PIXI.Application({
width: window.innerWidth,
height: window.innerHeight,
antialias: true,
backgroundColor: 0x000000
});
document.body.appendChild(app.view);
Drawing a grid with a fragment shader
A visible background grid makes the distortion effect much easier to appreciate. Fragment shaders calculate the color of each pixel, and PixiJS lets you run them through filters on sprites or the entire stage. The shader for the grid is adapted from a Shadertoy demo; it outputs faint blue lines against the dark background.
Because the shader code lives in a separate file, it must be fetched before the app initializes. Once loaded, an empty Sprite is added to the stage and assigned the custom filter that executes the shader.
Adding the cubic lens distortion
The distortion itself comes from another Shadertoy-inspired shader. It relies on two uniforms — variables passed from JavaScript into the shader:
uResolution— an object with{x: width, y: height}used to normalize pixel coordinates to the[0, 1]range.uPointerDown— a float between0and1that scales the distortion intensity during interactions.
A new filter with these uniforms is created and added to the stage itself, meaning the effect applies to everything rendered beneath it, not just one sprite.
Responding to pointer events
PixiJS unifies mouse and touch events through its interactive flag and the event API attached to display objects. Handling pointerdown, pointermove, and pointerup this way means the interaction works on desktop and mobile equally.
At this stage, the event handlers only log to the console. A third uniform, uPointerDiff, is introduced to track drag translation of the scene — it will power the drag-and-drop exploration of the gallery.
Animating the effect
Animation in PixiJS runs through the app.ticker. Adding a function to it executes that function every frame. Inside that loop, the distortion intensity is smoothed toward its target value based on the pointer state, giving the effect a natural ease rather than a hard snap.
The background shader receives a small modification so its grid translates with the stage during drags. When a pointer goes down, the distortion pushes in; during movement, the background shifts alongside the drag interaction.
Generating a random masonry grid
Instead of fixed cells, the gallery uses a randomized masonry layout. An algorithm starts with a list containing a single rectangle and iteratively splits the first rectangle into two smaller ones, provided both resulting pieces meet a minimum size threshold. The process repeats until no rectangle can be split further.
To see this in action before images are involved, the code draws solid rectangles into the stage. The layout is generated five times larger than the viewport and centered, leaving room to drag in any direction. A container holds all the rectangles, and its position is updated each frame based on the pointer delta.
Two details refine the result:
- Drag limits clamp the container position so it cannot move beyond the edges of the oversized grid.
- A background offset of
imagePadding / 2keeps the grid lines continuous relative to the image cells.
Lazy-loading images from Unsplash Source
With the rectangle layout fixed, each cell becomes an empty Sprite. Instead of loading every image up front, the code only fetches an image when its sprite intersects the viewport. That decision keeps network traffic proportional to what the user can actually see.
An AbortController is stored per sprite. When a drag moves a sprite outside the viewport mid-download, the request is aborted. The animation loop continuously checks which rectangles are visible, cancels stale requests, and kicks off fetches for sprites that just entered the frame. The loop also fades sprites in and out with an alpha transition when their visibility changes.
Sprites receive their dimensions and positions from the earlier rectangle generation function, now also producing empty sprite objects. When a fetch resolves, the image is converted to a PixiJS Texture and assigned to that sprite. Requests handled this way require the sprite to be visible again to restart, so only relevant downloads run.
The intersection check itself compares each rectangle's bounds against the viewport's visible area, accounting for the container's current position and the image padding.
Handling viewport resizes
If the browser window changes or a mobile device rotates, the app must adapt. A resize listener tears down the current application with a clean function and reinitializes everything from scratch with the new dimensions.
That cleanup is what makes the restart dependable — it cancels any in-flight image fetches, removes event listeners, and resets the PixiJS state so the second initialization starts fresh.
PixiJS as a WebGL bridge
Working with this stack is far less intimidating than bare WebGL. PixiJS handles the low-level plumbing, leaving the developer to focus on shader logic and scene composition. The complete source is available on GitHub, with demos hosted on CodePen for direct experimentation.



