Art Gallery
Art Gallery
A lensed photo grid you can drag through, with barrel distortion and infinite tiled studies.
Drag inside the preview to pan the gallery.
Component preview

Install using CLI
npx shadcn@latest add "https://www.obsidianui.dev/r/art-gallery.json"Usage
Code
import { ArtGallery } from "@/components/block/art-gallery";
export default function Demo() {
return <ArtGallery className="h-[400px]" />;
}Install Manually
1
Install dependencies
npm install clsx tailwind-merge three
2
Copy the source code
Copy into components/block/art-gallery.jsx
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { WebGLSurface, useEffectReducedMotion } from "@/lib/effects/shared/webgl-surface";
import { cn } from "@/lib/utils";
import { RipplePulseLoader } from "@/components/ui/ripple-pulse-loader";
const defaultConfig = {
cellSize: 0.75,
zoomLevel: 1.25,
lerpFactor: 0.075,
borderColor: "rgba(255, 255, 255, 0.15)",
backgroundColor: "rgba(0, 0, 0, 1)",
textColor: "rgba(128, 128, 128, 1)",
hoverColor: "rgba(255, 255, 255, 0)",
};
const defaultItems = [
{ title: "Motion Study", year: 2024 },
{ title: "Idle Form", year: 2023 },
{ title: "Blur Signal", year: 2024 },
{ title: "Still Drift", year: 2023 },
{ title: "Tidewalk", year: 2024 },
{ title: "Core Motion", year: 2022 },
{ title: "White Bloom", year: 2024 },
{ title: "Backrun", year: 2023 },
{ title: "Rushline", year: 2024 },
{ title: "Afterimage", year: 2023 },
{ title: "Shadowhead", year: 2022 },
{ title: "Opal Lace", year: 2024 },
{ title: "Glassprint", year: 2024 },
{ title: "Redshift", year: 2023 },
{ title: "White Noise", year: 2023 },
{ title: "Twin Field", year: 2024 },
{ title: "Petalloop", year: 2023 },
{ title: "Ghostwalk", year: 2024 },
{ title: "Heatwave", year: 2023 },
{ title: "Sky Drift", year: 2024 },
{ title: "Spindle", year: 2022 },
{ title: "Pacer", year: 2023 },
{ title: "Stride", year: 2024 },
{ title: "Cryo Pulse", year: 2022 },
{ title: "Velvet Blur", year: 2024 },
];
const defaultImages = [
"https://cdn-new.obsidianui.dev/imagess/1.png",
"https://cdn-new.obsidianui.dev/imagess/2.png",
"https://cdn-new.obsidianui.dev/imagess/3.png",
"https://cdn-new.obsidianui.dev/imagess/4.png",
"https://cdn-new.obsidianui.dev/imagess/5.png",
"https://cdn-new.obsidianui.dev/imagess/6.png",
"https://cdn-new.obsidianui.dev/imagess/7.png",
"https://cdn-new.obsidianui.dev/imagess/8.png",
"https://cdn-new.obsidianui.dev/imagess/9.png",
"https://cdn-new.obsidianui.dev/imagess/10.png",
"https://cdn-new.obsidianui.dev/imagess/11.png",
"https://cdn-new.obsidianui.dev/imagess/12.png",
"https://cdn-new.obsidianui.dev/imagess/13.png",
"https://cdn-new.obsidianui.dev/imagess/14.png",
"https://cdn-new.obsidianui.dev/imagess/15.png",
"https://cdn-new.obsidianui.dev/imagess/16.png",
"https://cdn-new.obsidianui.dev/imagess/17.png",
"https://cdn-new.obsidianui.dev/imagess/18.png",
"https://cdn-new.obsidianui.dev/imagess/19.png",
"https://cdn-new.obsidianui.dev/imagess/20.jpg",
"https://cdn-new.obsidianui.dev/imagess/21.jpg",
"https://cdn-new.obsidianui.dev/imagess/22.jpg",
"https://cdn-new.obsidianui.dev/imagess/23.jpg",
"https://cdn-new.obsidianui.dev/imagess/24.jpg",
"https://cdn-new.obsidianui.dev/imagess/25.jpg",
];
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform vec2 uOffset;
uniform vec2 uResolution;
uniform vec4 uBorderColor;
uniform vec4 uHoverColor;
uniform vec4 uBackgroundColor;
uniform vec2 uMousePos;
uniform float uZoom;
uniform float uCellSize;
uniform float uTextureCount;
uniform sampler2D uImageAtlas;
uniform sampler2D uTextAtlas;
varying vec2 vUv;
void main() {
vec2 screenUV = (vUv - 0.5) * 2.0;
float radius = length(screenUV);
float distortion = 1.0 - 0.08 * radius * radius;
vec2 distortedUV = screenUV * distortion;
vec2 aspectRatio = vec2(uResolution.x / uResolution.y, 1.0);
vec2 worldCoord = distortedUV * aspectRatio;
worldCoord *= uZoom;
worldCoord += uOffset;
vec2 cellPos = worldCoord / uCellSize;
vec2 cellId = floor(cellPos);
vec2 cellUV = fract(cellPos);
vec2 mouseScreenUV = (uMousePos / uResolution) * 2.0 - 1.0;
mouseScreenUV.y = -mouseScreenUV.y;
float mouseRadius = length(mouseScreenUV);
float mouseDistortion = 1.0 - 0.08 * mouseRadius * mouseRadius;
vec2 mouseDistortedUV = mouseScreenUV * mouseDistortion;
vec2 mouseWorldCoord = mouseDistortedUV * aspectRatio;
mouseWorldCoord *= uZoom;
mouseWorldCoord += uOffset;
vec2 mouseCellPos = mouseWorldCoord / uCellSize;
vec2 mouseCellId = floor(mouseCellPos);
vec2 cellCenter = cellId + 0.5;
vec2 mouseCellCenter = mouseCellId + 0.5;
float cellDistance = length(cellCenter - mouseCellCenter);
float hoverIntensity = 1.0 - smoothstep(0.4, 0.7, cellDistance);
bool isHovered = hoverIntensity > 0.0 && uMousePos.x >= 0.0;
vec3 backgroundColor = uBackgroundColor.rgb;
if (isHovered) {
backgroundColor = mix(uBackgroundColor.rgb, uHoverColor.rgb, hoverIntensity * uHoverColor.a);
}
float lineWidth = 0.005;
float gridX = smoothstep(0.0, lineWidth, cellUV.x) * smoothstep(0.0, lineWidth, 1.0 - cellUV.x);
float gridY = smoothstep(0.0, lineWidth, cellUV.y) * smoothstep(0.0, lineWidth, 1.0 - cellUV.y);
float gridMask = gridX * gridY;
float imageSize = 0.6;
float imageBorder = (1.0 - imageSize) * 0.5;
vec2 imageUV = (cellUV - imageBorder) / imageSize;
float edgeSmooth = 0.01;
vec2 imageMask = smoothstep(-edgeSmooth, edgeSmooth, imageUV) *
smoothstep(-edgeSmooth, edgeSmooth, 1.0 - imageUV);
float imageAlpha = imageMask.x * imageMask.y;
bool inImageArea = imageUV.x >= 0.0 && imageUV.x <= 1.0 && imageUV.y >= 0.0 && imageUV.y <= 1.0;
float textHeight = 0.08;
float textY = 0.88;
bool inTextArea = cellUV.x >= 0.05 && cellUV.x <= 0.95 && cellUV.y >= textY && cellUV.y <= (textY + textHeight);
float texIndex = mod(cellId.x + cellId.y * 3.0, uTextureCount);
vec3 color = backgroundColor;
if (inImageArea && imageAlpha > 0.0) {
float atlasSize = ceil(sqrt(uTextureCount));
vec2 atlasPos = vec2(mod(texIndex, atlasSize), floor(texIndex / atlasSize));
vec2 atlasUV = (atlasPos + imageUV) / atlasSize;
atlasUV.y = 1.0 - atlasUV.y;
vec3 imageColor = texture2D(uImageAtlas, atlasUV).rgb;
color = mix(color, imageColor, imageAlpha);
}
if (inTextArea) {
vec2 textCoord = vec2((cellUV.x - 0.05) / 0.9, (cellUV.y - textY) / textHeight);
textCoord.y = 1.0 - textCoord.y;
float atlasSize = ceil(sqrt(uTextureCount));
vec2 atlasPos = vec2(mod(texIndex, atlasSize), floor(texIndex / atlasSize));
vec2 atlasUV = (atlasPos + textCoord) / atlasSize;
vec4 textColor = texture2D(uTextAtlas, atlasUV);
color = mix(backgroundColor, textColor.rgb, textColor.a);
}
vec3 borderRGB = uBorderColor.rgb;
float borderAlpha = uBorderColor.a;
color = mix(color, borderRGB, (1.0 - gridMask) * borderAlpha);
float fade = 1.0 - smoothstep(1.2, 1.8, radius);
gl_FragColor = vec4(color * fade, 1.0);
}
`;
function rgbaToArray(rgba) {
const match = rgba.match(/rgba?\(([^)]+)\)/);
if (!match) return [1, 1, 1, 1];
const parts = match[1].split(",");
return [
parseFloat(parts[0]) / 255,
parseFloat(parts[1]) / 255,
parseFloat(parts[2]) / 255,
parseFloat(parts[3] ?? "1"),
];
}
function createTextTexture(title, year, textColor) {
const canvas = document.createElement("canvas");
canvas.width = 2048;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, 2048, 256);
ctx.font = "80px monospace";
ctx.fillStyle = textColor;
ctx.textBaseline = "middle";
ctx.imageSmoothingEnabled = false;
ctx.textAlign = "left";
ctx.fillText(String(title).toUpperCase(), 30, 128);
ctx.textAlign = "right";
ctx.fillText(String(year), 2048 - 30, 128);
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.minFilter = THREE.NearestFilter;
texture.magFilter = THREE.NearestFilter;
texture.flipY = false;
texture.generateMipmaps = false;
return texture;
}
function blankTexture() {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 512;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, 512, 512);
}
return new THREE.CanvasTexture(canvas);
}
function loadImageTexture(src) {
return new Promise((resolve) => {
const image = new Image();
if (/^https?:\/\//.test(src)) image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = async () => {
try { await image.decode(); } catch {}
const texture = new THREE.Texture(image);
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.flipY = false;
texture.needsUpdate = true;
resolve(texture);
};
image.onerror = () => resolve(blankTexture());
image.src = src;
});
}
function createTextureAtlas(textures, isText = false) {
const atlasSize = Math.ceil(Math.sqrt(textures.length));
const textureSize = 512;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = atlasSize * textureSize;
const ctx = canvas.getContext("2d");
if (ctx) {
if (isText) ctx.clearRect(0, 0, canvas.width, canvas.height);
else {
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
textures.forEach((texture, index) => {
const x = (index % atlasSize) * textureSize;
const y = Math.floor(index / atlasSize) * textureSize;
const src = texture.source?.data ?? texture.image;
if (!src) return;
try { ctx.drawImage(src, x, y, textureSize, textureSize); } catch {}
});
}
const atlasTexture = new THREE.CanvasTexture(canvas);
atlasTexture.wrapS = THREE.ClampToEdgeWrapping;
atlasTexture.wrapT = THREE.ClampToEdgeWrapping;
atlasTexture.minFilter = THREE.LinearFilter;
atlasTexture.magFilter = THREE.LinearFilter;
atlasTexture.flipY = false;
return atlasTexture;
}
function ArtGalleryScene({ images, items, cellSize, zoomLevel, showHint, reducedMotion }) {
const containerRef = useRef(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setReady(false);
let cancelled = false;
let animFrameId = 0;
let renderer;
let plane;
let geometry;
let material;
let imageAtlas;
let textAtlas;
const loadedTextures = [];
const state = {
isDragging: false,
previousPointer: { x: 0, y: 0 },
offset: { x: 0, y: 0 },
targetOffset: { x: 0, y: 0 },
mousePosition: { x: -1, y: -1 },
zoom: 1,
targetZoom: 1,
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
camera.position.z = 1;
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const bgColor = rgbaToArray(defaultConfig.backgroundColor);
renderer.setClearColor(new THREE.Color(bgColor[0], bgColor[1], bgColor[2]), bgColor[3]);
renderer.domElement.style.display = "block";
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
renderer.domElement.style.touchAction = "none";
container.appendChild(renderer.domElement);
const lerpFactor = reducedMotion ? 1 : defaultConfig.lerpFactor;
const dragZoom = reducedMotion ? 1 : zoomLevel;
const animate = () => {
animFrameId = requestAnimationFrame(animate);
state.offset.x += (state.targetOffset.x - state.offset.x) * lerpFactor;
state.offset.y += (state.targetOffset.y - state.offset.y) * lerpFactor;
state.zoom += (state.targetZoom - state.zoom) * lerpFactor;
if (plane?.material.uniforms) {
plane.material.uniforms.uOffset.value.set(state.offset.x, state.offset.y);
plane.material.uniforms.uZoom.value = state.zoom;
}
renderer.render(scene, camera);
};
const updateMousePosition = (event) => {
const rect = renderer.domElement.getBoundingClientRect();
state.mousePosition.x = event.clientX - rect.left;
state.mousePosition.y = event.clientY - rect.top;
plane?.material.uniforms.uMousePos.value.set(state.mousePosition.x, state.mousePosition.y);
};
const startDrag = (x, y) => {
state.isDragging = true;
state.previousPointer.x = x;
state.previousPointer.y = y;
};
const handleMove = (x, y) => {
if (!state.isDragging) return;
const deltaX = x - state.previousPointer.x;
const deltaY = y - state.previousPointer.y;
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
if (state.targetZoom === 1) state.targetZoom = dragZoom;
}
state.targetOffset.x -= deltaX * 0.003;
state.targetOffset.y += deltaY * 0.003;
state.previousPointer.x = x;
state.previousPointer.y = y;
};
const endDrag = () => {
state.isDragging = false;
state.targetZoom = 1;
};
const onPointerDown = (event) => {
event.preventDefault();
container.setPointerCapture?.(event.pointerId);
startDrag(event.clientX, event.clientY);
};
const onPointerMove = (event) => {
updateMousePosition(event);
handleMove(event.clientX, event.clientY);
};
const onPointerUp = (event) => {
if (container.hasPointerCapture?.(event.pointerId)) container.releasePointerCapture(event.pointerId);
endDrag();
};
const onPointerLeave = () => {
state.mousePosition.x = state.mousePosition.y = -1;
plane?.material.uniforms.uMousePos.value.set(-1, -1);
endDrag();
};
const onResize = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) return;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
plane?.material.uniforms.uResolution.value.set(width, height);
};
const init = async () => {
const imageTiles = await Promise.all(images.map((src) => loadImageTexture(src)));
if (cancelled) {
imageTiles.forEach((texture) => texture.dispose());
return;
}
loadedTextures.push(...imageTiles);
const textTextures = items.map((item) => createTextTexture(item.title, item.year, defaultConfig.textColor));
loadedTextures.push(...textTextures);
imageAtlas = createTextureAtlas(imageTiles, false);
textAtlas = createTextureAtlas(textTextures, true);
if (cancelled) return;
const uniforms = {
uOffset: { value: new THREE.Vector2(0, 0) },
uResolution: { value: new THREE.Vector2(container.clientWidth, container.clientHeight) },
uBorderColor: { value: new THREE.Vector4(...rgbaToArray(defaultConfig.borderColor)) },
uHoverColor: { value: new THREE.Vector4(...rgbaToArray(defaultConfig.hoverColor)) },
uBackgroundColor: { value: new THREE.Vector4(...rgbaToArray(defaultConfig.backgroundColor)) },
uMousePos: { value: new THREE.Vector2(-1, -1) },
uZoom: { value: 1 },
uCellSize: { value: cellSize },
uTextureCount: { value: images.length },
uImageAtlas: { value: imageAtlas },
uTextAtlas: { value: textAtlas },
};
geometry = new THREE.PlaneGeometry(2, 2);
material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
plane = new THREE.Mesh(geometry, material);
scene.add(plane);
container.addEventListener("pointerdown", onPointerDown);
container.addEventListener("pointermove", onPointerMove);
container.addEventListener("pointerup", onPointerUp);
container.addEventListener("pointercancel", onPointerUp);
container.addEventListener("pointerleave", onPointerLeave);
window.addEventListener("resize", onResize);
animate();
setReady(true);
};
init();
return () => {
cancelled = true;
cancelAnimationFrame(animFrameId);
container.removeEventListener("pointerdown", onPointerDown);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerup", onPointerUp);
container.removeEventListener("pointercancel", onPointerUp);
container.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("resize", onResize);
loadedTextures.forEach((texture) => texture.dispose());
imageAtlas?.dispose();
textAtlas?.dispose();
geometry?.dispose();
material?.dispose();
renderer?.dispose();
if (renderer?.domElement?.parentNode === container) container.removeChild(renderer.domElement);
};
}, [images, items, cellSize, zoomLevel, reducedMotion]);
return (
<div className="absolute inset-0 cursor-grab active:cursor-grabbing" style={{ touchAction: "none" }}>
<div ref={containerRef} className="absolute inset-0" style={{ opacity: ready ? 1 : 0 }} />
{!ready ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black text-white" role="status" aria-live="polite">
<RipplePulseLoader size={150} />
</div>
) : null}
{ready && showHint ? (
<div className="pointer-events-none absolute bottom-6 left-1/2 z-10 -translate-x-1/2 font-mono text-[0.6rem] uppercase tracking-[0.2em] text-white/20">
drag to explore
</div>
) : null}
</div>
);
}
/** @param {{ images?: string[], items?: { title: string, year: string | number }[], cellSize?: number, zoomLevel?: number, showHint?: boolean, className?: string, style?: import("react").CSSProperties }} props */
export function ArtGallery({
images = defaultImages,
items = defaultItems,
cellSize = defaultConfig.cellSize,
zoomLevel = defaultConfig.zoomLevel,
showHint = true,
className,
style,
} = {}) {
const reducedMotion = useEffectReducedMotion();
const tiles = images.length ? images : defaultImages;
const captions = tiles.map((_, index) => items[index % items.length] ?? { title: `Study ${index + 1}`, year: "2024" });
return (
<WebGLSurface className={cn("bg-black", className)} style={style} label="ObsidianUI Art Gallery">
<ArtGalleryScene
images={tiles}
items={captions}
cellSize={cellSize}
zoomLevel={zoomLevel}
showHint={showHint}
reducedMotion={reducedMotion}
/>
</WebGLSurface>
);
}3
Add the supporting file
Copy into components/ui/ripple-pulse-loader.tsx
"use client";
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
const logoSrc = "https://cdn-new.obsidianui.dev/logo/bg-less.png?v=3";
type RipplePulseLoaderProps = {
className?: string;
size?: number;
};
export function RipplePulseLoader({ className, size = 150 }: RipplePulseLoaderProps) {
return (
<div
className={cn("ripple-pulse-loader", className)}
style={{ "--ripple-size": `${size}px` } as CSSProperties}
role="status"
aria-label="Loading"
>
<div className="box">
<div className="logo">
<img src={logoSrc} alt="" className="svg" />
</div>
</div>
<div className="box" />
<div className="box" />
<div className="box" />
<div className="box" />
</div>
);
}
export default RipplePulseLoader;4
Add the supporting file
Copy into lib/effects/shared/webgl-surface.jsx
"use client";
import { Component, useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
const subscribeMotion = (notify) => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
};
export function useEffectReducedMotion() {
return useSyncExternalStore(subscribeMotion, () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, () => true);
}
let webglAvailable;
function supportsWebGL() {
if (webglAvailable !== undefined) return webglAvailable;
try {
const canvas = document.createElement("canvas");
const context = canvas.getContext("webgl2");
webglAvailable = Boolean(context);
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webglAvailable = false;
}
return webglAvailable;
}
const subscribeAvailability = () => () => {};
class SurfaceBoundary extends Component {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
render() { return this.state.failed ? this.props.fallback : this.props.children; }
}
/** @param {{ children?: import("react").ReactNode, className?: string, style?: import("react").CSSProperties, imageSrc?: string, label?: string }} props */
export function WebGLSurface({ children, className, style, imageSrc, label = "ObsidianUI visual effect" }) {
const supported = useSyncExternalStore(subscribeAvailability, supportsWebGL, () => false);
const fallback = <div role="img" aria-label={label} className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: imageSrc ? `url(${JSON.stringify(imageSrc)})` : undefined }} />;
return (
<div className={cn("relative isolate h-[28rem] w-full overflow-hidden bg-black", className)} style={{ containerType: "size", ...style }}>
{fallback}
{supported && <SurfaceBoundary fallback={fallback}>{children}</SurfaceBoundary>}
</div>
);
}5
Add the supporting file
Copy into lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}Preview behavior
Drag inside the preview to pan the gallery. The effect stays inside its container and respects reduced motion preferences.
Last updated on