Curved Gallery
Installation
npx shadcn@latest add https://uibrik.com/r/curved-gallery.jsonInstall the packages
npm install motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/curved-gallery/curved-gallery.tsx"use client"; import { useMemo, useRef } from "react"; import { animate, motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // Curved gallery. Cards line the inside of a cylinder and you look in at it // from the front: the middle ones sit far back and small, the ones towards // either side come round towards you, larger and turned to face the centre. // The wall turns slowly and endlessly. Click a card to bring it to the middle. // // One motion value (where the wall is, in cards) drives every card. Each card // is placed with a single 3D transform: pushed back by the radius, turned // round the axis, and pulled forward again. The wall dissolves into the // background at both sides, so cards come and go without an edge. export type CurvedCard = { src: string; alt: string; }; export type CurvedGalleryProps = { cards?: CurvedCard[]; /** Cards that pass the middle every second. 0 holds still. */ speed?: number; /** Space between neighbouring cards at the middle, px. Scales with the card. */ gap?: number; /** Share of the width, per side, that dissolves into the background, %. */ fade?: number; /** Card width, px. */ cardWidth?: number; /** Card height against its width. */ ratio?: number; /** How tightly the wall bends, 0 to 100. Low is nearly flat, high is a tight drum. */ curvature?: number; /** How strong the sense of depth is, as a multiple of the drum's radius. Lower is stronger. */ perspective?: number; /** How far round the drum cards show before they are hidden, degrees. */ reach?: number; /** How much the cards turn to face the centre, %. 0 keeps them flat on. */ tilt?: number; /** Corner radius of a card, px. */ rounding?: number; /** Which way the wall turns. */ direction?: "left" | "right"; /** Settle while the pointer is over the gallery. */ pauseOnHover?: boolean; /** Clicking a card turns it to the middle. */ clickToFocus?: boolean; className?: string; }; const CARDS: CurvedCard[] = [ { src: "https://uibrik.com/images/1.jpg", alt: "Morning light" }, { src: "https://uibrik.com/images/2.jpg", alt: "Sunlit clearing" }, { src: "https://uibrik.com/images/3.jpg", alt: "Wildflowers" }, { src: "https://uibrik.com/images/4.jpg", alt: "Quiet shore" }, { src: "https://uibrik.com/images/5.jpg", alt: "River and pines" }, ]; const RAD = Math.PI / 180; // Cards start to disappear this far into the reach, as a share of it. const FADE_FROM = 0.77; const mod = (n: number, m: number) => ((n % m) + m) % m; type PanelProps = { card: CurvedCard; index: number; total: number; wall: MotionValue<number>; step: number; radius: number; width: number; height: number; reach: number; tilt: number; rounding: number; interactive: boolean; onPick: (offset: number) => void; }; function Panel({ card, index, total, wall, step, radius, width, height, reach, tilt, rounding, interactive, onPick }: PanelProps) { // Signed angle from the middle, wrapped so a card leaving at one side comes // back at the other while it is hidden. const angle = useTransform(wall, (w) => (mod(index - w + total / 2, total) - total / 2) * step); const transform = useTransform( angle, (a) => `translateZ(${radius}px) rotateY(${-a}deg) translateZ(${-radius}px) rotateY(${a * (1 - tilt)}deg)`, ); const opacity = useTransform(angle, (a) => { const d = Math.abs(a); const from = reach * FADE_FROM; return d <= from ? 1 : d >= reach ? 0 : 1 - (d - from) / (reach - from); }); const zIndex = useTransform(angle, (a) => Math.round(Math.abs(a))); const pointerEvents = useTransform(opacity, (o) => (o < 0.4 ? "none" : "auto")); const Tag = interactive ? motion.button : motion.div; return ( <Tag {...(interactive ? { type: "button" as const, "aria-label": card.alt, onClick: () => onPick(angle.get() / step) } : {})} style={{ transform, opacity, zIndex, pointerEvents: interactive ? pointerEvents : "none", width, height, marginLeft: -width / 2, marginTop: -height / 2, borderRadius: rounding, }} className={cn( "edge-outer absolute top-1/2 left-1/2 overflow-hidden bg-muted/70 p-1 shadow-sm outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-400 dark:bg-muted/60 dark:focus-visible:outline-zinc-500", interactive && "cursor-pointer", )} > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={card.src} alt={interactive ? "" : card.alt} draggable={false} className="edge-inner size-full object-cover" style={{ borderRadius: Math.max(0, rounding - 4) }} /> </Tag> ); } export function CurvedGallery({ cards = CARDS, speed = 0.25, gap = 18, fade = 14, cardWidth: cardWidthProp = 220, ratio = 1.33, curvature = 70, perspective: perspectiveProp = 1.7, reach: reachProp = 78, tilt: tiltProp = 100, rounding = 22, direction = "left", pauseOnHover = true, clickToFocus = true, className, }: CurvedGalleryProps) { const reduce = useReducedMotion(); const [stageRef, { width: stageWidth }] = useMeasure(); const wall = useMotionValue(0); const count = cards.length; // Eased towards 0 while the pointer is over the gallery, so it settles // rather than stopping dead. const pace = useMotionValue(1); const picking = useRef(false); useAnimationFrame((_, delta) => { if (reduce || picking.current) return; const sign = direction === "right" ? -1 : 1; wall.set(wall.get() + sign * pace.get() * speed * (Math.min(delta, 64) / 1000)); }); // The cylinder's radius follows the width, and the card size follows the // width too, so the wall looks the same at any size. More curvature is a // smaller drum. const bend = Math.min(100, Math.max(0, curvature)); const radius = stageWidth * (1.5 - 0.0126 * bend); const cardWidth = Math.round(Math.min(cardWidthProp, Math.max(56, stageWidth * 0.24))); const cardHeight = Math.round(cardWidth * Math.max(0.5, ratio)); const reach = Math.min(88, Math.max(30, reachProp)); const tilt = Math.min(1, Math.max(0, tiltProp / 100)); const spacing = cardWidth + gap * (cardWidth / cardWidthProp); const step = radius > 0 ? Math.min(40, Math.max(8, spacing / radius / RAD)) : 20; const perspective = radius * Math.max(1.05, perspectiveProp); // Enough cards to go all the way round, in a multiple of the card count so // the order is unbroken. const total = useMemo(() => { if (count === 0) return 0; return Math.ceil(Math.ceil(360 / step) / count) * count; }, [step, count]); const pick = (offset: number) => { if (Math.abs(offset) < 0.02) return; picking.current = true; animate(wall, wall.get() + offset, { type: "spring", duration: 0.8, bounce: 0 }).then(() => { picking.current = false; }); }; if (count === 0) return null; const edge = Math.min(0.4, Math.max(0, fade / 100)); const mask = `linear-gradient(to right, transparent 0, #000 ${edge * 100}%, #000 ${100 - edge * 100}%, transparent 100%)`; return ( <div role="region" aria-roledescription="carousel" aria-label="Image wall" className={cn("w-full max-w-5xl", className)}> <div ref={stageRef} onPointerEnter={() => pauseOnHover && animate(pace, 0, { duration: 0.6 })} onPointerLeave={() => pauseOnHover && animate(pace, 1, { duration: 0.9 })} className="relative w-full overflow-hidden select-none" style={{ height: Math.round(cardHeight * 1.5), perspective, maskImage: edge > 0 ? mask : undefined, WebkitMaskImage: edge > 0 ? mask : undefined, visibility: stageWidth > 0 ? "visible" : "hidden", }} > {Array.from({ length: total }, (_, i) => ( <Panel key={i} card={cards[i % count]} index={i} total={total} wall={wall} step={step} radius={radius} width={cardWidth} height={cardHeight} reach={reach} tilt={tilt} rounding={rounding} interactive={clickToFocus} onPick={pick} /> ))} </div> </div> ); }
Adds the files to
components/uibrik/curved-gallery and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/curved-gallery.Props
CurvedGallery- speed
- Type number
- 0.25
- Cards that pass the middle every second. Pauses while the pointer is over the gallery.
- gap
- Type number
- 18
- Space between neighbouring cards at the middle.
- fade
- Type number
- 14
- Share of the width, per side, that dissolves into the background.
- cardWidth
- Type number
- 220
- Card width.
- ratio
- Type number
- 1.33
- Card height against its width.
- curvature
- Type number
- 70
- How tightly the wall bends. Low is nearly flat, high is a tight drum.
- perspective
- Type number
- 1.7
- How strong the sense of depth is, as a multiple of the drum's radius. Lower is stronger.
- reach
- Type number
- 78
- How far round the drum cards show before they are hidden.
- tilt
- Type number
- 100
- How much the cards turn to face the centre. 0 keeps them flat on.
- rounding
- Type number
- 22
- Corner radius of a card.
- direction
- Type "left" | "right"
- "left"
- Which way the wall turns.
- pauseOnHover
- Type boolean
- true
- Settle while the pointer is over the gallery.
- clickToFocus
- Type boolean
- true
- Clicking a card turns it to the middle.
- cards
- Type { src: string; alt: string }[]
- 5 sample cards
- The cards, in order. They repeat to go all the way round.
- className
- Type string
- –
- Classes for the wrapper.