Spiral Gallery
Installation
npx shadcn@latest add https://uibrik.com/r/spiral-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/spiral-gallery/spiral-gallery.tsx"use client"; import { useEffect, useMemo } 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"; // Spiral gallery. Cards sit on one spiral, seen from above, and flow along it // in an endless loop. Each grows out of a point at the middle, soft and small, // then winds outwards round the empty centre, growing, sharpening and turning // as it comes towards you. Only the last stretch of its journey dissolves it, // whole and in one piece, so it is gone before it reaches the rim. Nothing is // ever cut off, and no card is see-through anywhere else. // // One motion value (how far the cards have flowed, in cards) drives every card. // A click sends the flow the other way. export type SpiralCard = { src: string; alt: string; }; export type SpiralGalleryProps = { cards?: SpiralCard[]; /** Space between neighbouring cards along the spiral, px, measured where a * card is about half size. Negative overlaps them. Sets how many cards fit. */ gap?: number; /** Cards along the spiral. Leave out to fit as many as the gap allows. */ count?: number; /** How many times the spiral winds round on its way in. */ turns?: number; /** Cards that flow along the spiral every second. */ speed?: number; /** Which way the cards flow: out from the middle, or in from the rim. */ direction?: "out" | "in"; /** Blur of a card as it reaches the middle, px. */ blur?: number; /** How far cards lean as they go round, degrees. */ tilt?: number; /** Size of the empty middle, as a share of the rim, %. */ hole?: number; /** Share of a card's journey, at the rim end, spent dissolving, %. */ fade?: number; /** Card width at the rim, px. Height follows at 4:5. */ cardWidth?: number; className?: string; }; const CARDS: SpiralCard[] = [ { 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; const mod = (n: number, m: number) => ((n % m) + m) % m; // A card grows from nothing over this first share of its journey. const BIRTH = 0.08; const smooth = (x: number) => { const t = Math.min(1, Math.max(0, x)); return t * t * (3 - 2 * t); }; // Motion writes numbers into server HTML to six places, so values are rounded // to fewer than that and the client's first render matches what the server sent. const round = (n: number) => Math.round(n * 1e3) / 1e3; type SpiralCardProps = { card: SpiralCard; index: number; count: number; flow: MotionValue<number>; /** Running total of how many cards the spiral holds up to each point of it. */ fill: Float64Array; turns: number; hole: number; dissolve: number; rx: number; ry: number; blur: number; tilt: number; width: number; height: number; }; function Winder({ card, index, count, flow, fill, turns, hole, dissolve, rx, ry, blur, tilt, width, height }: SpiralCardProps) { // How far along the spiral: 0 at the rim, 1 at the middle. Cards are evenly // spaced in "room taken", not in distance, so the big ones out at the rim get // more of the line than the small ones near the middle. const along = useTransform(flow, (f) => { const steps = fill.length - 1; const target = (mod(index + f, count) / count) * fill[steps]; let lo = 0; let hi = steps; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (fill[mid] <= target) lo = mid; else hi = mid; } const span = fill[hi] - fill[lo]; return (lo + (span > 0 ? (target - fill[lo]) / span : 0)) / steps; }); const angle = useTransform(along, (u) => u * turns * 360); const reach = useTransform(along, (u) => 1 - u * (1 - hole)); const x = useTransform([angle, reach], ([a, r]: number[]) => round(Math.sin(a * RAD) * r * rx)); const y = useTransform([angle, reach], ([a, r]: number[]) => round(-Math.cos(a * RAD) * r * ry)); // Size follows how far out a card is, so with a big hole the cards that are // furthest back are still a good size. A short ramp at birth grows each one // out of a point, so it never pops in. const scale = useTransform(along, (u) => { const r = 1 - u * (1 - hole); return round(Math.pow(r, 0.9) * smooth((1 - u) / BIRTH)); }); const rotate = useTransform(angle, (a) => round(Math.sin(a * RAD) * tilt)); // Whole and opaque all the way, until the last stretch before the rim. const opacity = useTransform(along, (u) => round(smooth(u / dissolve))); const filter = useTransform(along, (u) => { const b = blur * Math.pow(u, 1.2); return b < 0.05 ? "none" : `blur(${b.toFixed(2)}px)`; }); const zIndex = useTransform(along, (u) => Math.round((1 - u) * 100)); return ( <motion.div style={{ x, y, scale, rotate, opacity, filter, zIndex, width, height, marginLeft: -width / 2, marginTop: -height / 2, }} className="edge-outer absolute top-1/2 left-1/2 overflow-hidden rounded-[22px] bg-muted/70 p-1 shadow-sm will-change-transform dark:bg-muted/60" > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={card.src} alt={card.alt} draggable={false} className="edge-inner size-full rounded-[18px] object-cover" /> </motion.div> ); } export function SpiralGallery({ cards = CARDS, gap = -14, count: countProp, turns = 2.25, speed = 0.4, direction: directionProp = "out", blur = 3, tilt = 0, hole = 0, fade = 1, cardWidth: cardWidthProp = 92, className, }: SpiralGalleryProps) { const reduce = useReducedMotion(); const [stageRef, { width: stageWidth }] = useMeasure(); const flow = useMotionValue(0); // 1 flows inwards, -1 outwards. Eased, so a click swings round. const direction = useMotionValue(directionProp === "out" ? -1 : 1); const total = cards.length; useAnimationFrame((_, delta) => { if (reduce) return; // A long gap (tab in the background) must not leap the flow forward. flow.set(flow.get() + direction.get() * speed * (Math.min(delta, 64) / 1000)); }); useEffect(() => { animate(direction, directionProp === "out" ? -1 : 1, { duration: 0.8, ease: [0.23, 1, 0.32, 1] }); }, [directionProp, direction]); const reverse = () => { if (reduce) return; animate(direction, direction.get() >= 0 ? -1 : 1, { duration: 1.1, ease: [0.23, 1, 0.32, 1] }); }; const cardWidth = Math.round(Math.min(cardWidthProp, Math.max(56, stageWidth * 0.42))); const cardHeight = Math.round(cardWidth * 1.25); const height = Math.round(stageWidth * 0.86); // A true circle, seen from above, as large as the stage allows with the // biggest card still wholly inside it, so nothing is ever clipped. const rim = Math.max(0, Math.min(stageWidth / 2 - cardWidth * 0.62, height / 2 - cardHeight * 0.62)); const dissolve = Math.min(0.4, Math.max(0.01, fade / 100)); const holeShare = Math.min(0.98, Math.max(0, hole / 100)); // Walk the spiral and add up how much room each stretch takes: its length // over the width of a card there (which shrinks towards the middle) plus the // gap. The total is how many cards fit, and the running total lets a card be // put exactly where its share of the line falls. const { fill, count } = useMemo(() => { const steps = 96; const fill = new Float64Array(steps + 1); const dr = rim * (1 - holeShare); const dPhi = turns * 2 * Math.PI; for (let k = 0; k < steps; k++) { const u = (k + 0.5) / steps; const r = 1 - u * (1 - holeShare); const length = Math.hypot(dr, r * rim * dPhi) / steps; const room = Math.max(10, cardWidth * Math.pow(r, 0.9) + gap); fill[k + 1] = fill[k] + length / room; } const fit = Math.min(40, Math.max(3, Math.round(fill[steps]))); return { fill, count: countProp !== undefined ? Math.max(3, Math.round(countProp)) : fit }; }, [countProp, rim, holeShare, turns, cardWidth, gap]); const rx = rim; const ry = rim; const slots = useMemo(() => Array.from({ length: count }, (_, i) => i), [count]); if (total === 0) return null; return ( <div role="region" aria-roledescription="carousel" aria-label="Image spiral" className={cn("w-full max-w-xl", className)}> <button type="button" ref={stageRef} aria-label="Reverse direction" onClick={reverse} className="relative block w-full cursor-pointer overflow-hidden outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-400 dark:focus-visible:outline-zinc-500" style={{ height, visibility: stageWidth > 0 ? "visible" : "hidden", }} > {slots.map((i) => ( <Winder key={i} card={cards[i % total]} index={i} count={count} flow={flow} fill={fill} turns={turns} hole={holeShare} dissolve={dissolve} rx={rx} ry={ry} blur={blur} tilt={tilt} width={cardWidth} height={cardHeight} /> ))} </button> </div> ); }
Adds the files to
components/uibrik/spiral-gallery and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/spiral-gallery.Props
SpiralGallery- gap
- Type number
- -14
- Space between neighbouring cards along the spiral. Negative overlaps them. More space fits fewer cards.
- turns
- Type number
- 2.25
- How many times the spiral winds round on its way in.
- speed
- Type number
- 0.4
- Cards that flow along the spiral every second.
- direction
- Type "out" | "in"
- "out"
- Which way the cards flow: out from the middle, or in from the rim.
- blur
- Type number
- 3
- Blur of a card at the middle, where it is furthest away.
- tilt
- Type number
- 0
- How far cards lean as they go round.
- hole
- Type number
- 0
- Size of the empty middle, as a share of the rim. Large leaves a thin ring.
- fade
- Type number
- 1
- Share of a card's journey, at the rim end, spent dissolving.
- cardWidth
- Type number
- 92
- Card width at the rim. Height follows at 4:5.
- cards
- Type { src: string; alt: string }[]
- 5 sample cards
- The cards, in order. They repeat to fill the spiral.
- className
- Type string
- –
- Classes for the wrapper.