From d9dde3a803a8ad0a424f6cc407d01993969a540a Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 4 Jul 2026 11:47:16 +0530 Subject: [PATCH] feat: add cylinder carousel with concave and convex variants --- components/motion/cylinder-carousel.tsx | 454 ++++++++++++++++++ components/previews/index.tsx | 5 + .../motion/cylinder-carousel.preview.tsx | 96 ++++ lib/registry.ts | 15 + 4 files changed, 570 insertions(+) create mode 100644 components/motion/cylinder-carousel.tsx create mode 100644 components/previews/motion/cylinder-carousel.preview.tsx diff --git a/components/motion/cylinder-carousel.tsx b/components/motion/cylinder-carousel.tsx new file mode 100644 index 0000000..c8e7ad0 --- /dev/null +++ b/components/motion/cylinder-carousel.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { + animate, + type AnimationPlaybackControls, + motion, + type MotionValue, + useMotionValue, + useReducedMotion, + useTransform, +} from "motion/react"; +import { + Children, + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, + type WheelEvent as ReactWheelEvent, +} from "react"; +import { cn } from "@/lib/utils"; + +// Carousel-specific: a soft spring that receives the release velocity, so a +// flick keeps rolling freely, drifts past the snap point and eases back. +const GLIDE_SPRING = { stiffness: 40, damping: 20, mass: 3 }; + +// How far a flick keeps rolling: projected items = release velocity * momentum. +const FLICK_MOMENTUM = 0.45; +const MAX_FLICK_ITEMS = 6; + +export interface CylinderCarouselProps { + children: ReactNode; + /** Max item box size in px (square) at full size, i.e. at the container + * edge. Balls shrink below this automatically so the row keeps breathing + * room in narrow containers. */ + itemSize?: number; + /** How many item slots span the container width. */ + visibleItems?: number; + /** "concave" (default): inside of the cylinder — center ball smallest and + * dipped, growing toward the edges. "convex": outside of the cylinder — + * center ball biggest and raised, shrinking toward the edges. */ + variant?: "concave" | "convex"; + /** Scale of the smallest ball (center for concave, edges for convex); + * the biggest reaches 1. */ + minScale?: number; + /** Items rolled per item-width dragged — above 1 the wall outruns the + * pointer, which reads as a lighter, freer roll. */ + dragSpeed?: number; + /** Curve depth in px: for concave, how far the edge balls ride above the + * center one (valley); for convex, how far below (arch). 0 = flat line. + * Defaults to 35% of the item size. */ + arc?: number; + /** Snap to the nearest item when the roll settles. */ + snap?: boolean; + /** Roll on its own until interacted with. */ + autoRotate?: boolean; + /** Auto-roll speed in items per second. */ + autoRotateSpeed?: number; + defaultIndex?: number; + onIndexChange?: (index: number) => void; + /** Stage height in px. Defaults to `itemSize`. */ + height?: number; + className?: string; +} + +// The frame edge sits at this wall angle; how far the wall curves in frame. +const THETA_EDGE = (72 * Math.PI) / 180; +// Wall angle past which a ball is parked far off-stage (always hidden there). +const THETA_CLAMP = (95 * Math.PI) / 180; + +/** + * One ball on the inside wall of the cylinder, rendered through a single + * perspective projection: the ball sits at wall angle θ, the camera slightly + * above the ball line, so horizontal position, size and height all share one + * 1/(cosθ + k) depth term. Center = far wall: smallest, highest, moving + * slowest; edges = nearest: full size, level, moving fastest, sliding off to + * be clipped. Nothing overlaps, fades or reorders — the edge is the exit. + */ +function CarouselBall({ + scroll, + index, + count, + alpha, + k, + projection, + gap, + edgeOffset, + minScale, + convex, + arc, + halfWidth, + itemSize, + children, +}: { + scroll: MotionValue; + index: number; + count: number; + /** Wall angle per item step, in radians. */ + alpha: number; + /** Camera distance term for the horizontal projection. */ + k: number; + /** Projection strength: maps sinθ/(cosθ+k) to px so θE lands on the edge. */ + projection: number; + /** Uniform slot width in px — convex spacing. */ + gap: number; + /** Offset at which a ball's center sits on the container edge. */ + edgeOffset: number; + minScale: number; + convex: boolean; + /** Curve depth in px between the center ball and the edge balls. */ + arc: number; + halfWidth: number; + itemSize: number; + children: ReactNode; +}) { + // Nearest wrapped offset so items loop around continuously. + const offset = useTransform(scroll, (s) => { + let o = index - s; + o -= Math.round(o / count) * count; + return o; + }); + // Concave spacing follows the interior perspective (slow, tight center); + // convex pairs its big center balls with uniform spacing — the interior + // projection would collapse them into each other. + const x = useTransform(offset, (o) => { + if (convex) return o * gap; + const th = Math.max(-THETA_CLAMP, Math.min(THETA_CLAMP, o * alpha)); + return (projection * Math.sin(th)) / (Math.cos(th) + k); + }); + // Linear in wall angle, not in depth (the depth curve is near-flat around + // the center, which made the middle three read as equal): every step is + // visibly bigger than the last — growing outward (concave) or inward + // (convex). + const scale = useTransform(offset, (o) => { + const t = Math.min(Math.abs(o) / edgeOffset, THETA_CLAMP / THETA_EDGE); + return convex + ? 1 - (1 - minScale) * t + : minScale + (1 - minScale) * t; + }); + // Parabola centered on the stage — valley for concave (center ball dips + // arc/2 below the midline, edges rise arc/2 above), arch for convex — and + // deliberately unclamped: a ball keeps following the same curve as it + // crosses the edge, so entries never pop. + const y = useTransform(x, (px) => { + const t = px / halfWidth; + const valley = arc * (0.5 - t * t); + return convex ? -valley : valley; + }); + // Fully off-stage balls stop painting (matters for canvas/shader children). + const visibility = useTransform(x, (px) => + Math.abs(px) > halfWidth + itemSize ? "hidden" : "visible", + ); + + return ( + + {children} + + ); +} + +export function CylinderCarousel({ + children, + itemSize = 200, + visibleItems = 5, + variant = "concave", + minScale = 0.55, + dragSpeed = 1.5, + arc: arcProp, + snap = true, + autoRotate = false, + autoRotateSpeed = 0.4, + defaultIndex = 0, + onIndexChange, + height, + className, +}: CylinderCarouselProps) { + const reduce = useReducedMotion() ?? false; + const items = Children.toArray(children); + const count = items.length; + + const stageRef = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const el = stageRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setWidth(entry.contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + // Sized so `visibleItems` balls sit fully in frame and the next one out on + // each side straddles the container edge, half visible. + const stageWidth = width || 800; + const halfWidth = stageWidth / 2; + const edgeOffset = (visibleItems + 1) / 2; + + // Fit: the resting row's diameters may take at most ~66% of the stage — the + // rest is air between balls plus the half-visible ball on each edge. + // `itemSize` only caps the result. + const convex = variant === "convex"; + let scaleSum = 0; + for (let i = 0; i < visibleItems; i++) { + const t = Math.abs(i - (visibleItems - 1) / 2) / edgeOffset; + scaleSum += convex + ? 1 - (1 - minScale) * t + : minScale + (1 - minScale) * t; + } + const size = Math.min(itemSize, (stageWidth * 0.65) / scaleSum); + + const gap = stageWidth / (visibleItems + 1); + const arc = arcProp ?? size * 0.35; + + // Perspective constants. The ball one slot past the frame edge sits at wall + // angle THETA_EDGE with its center right on the container edge — scale 1, + // half of it in view; k falls out of the requested minScale (clamped so the + // projection stays monotonic up to THETA_CLAMP). + const alpha = THETA_EDGE / edgeOffset; + const k = Math.max(0.2, (minScale - Math.cos(THETA_EDGE)) / (1 - minScale)); + const projection = + (halfWidth * (Math.cos(THETA_EDGE) + k)) / Math.sin(THETA_EDGE); + + // scroll is in item units (continuous); item i sits at x = (i - scroll) * gap. + // Drags write it 1:1 so the wall sticks to the pointer; releases hand the + // pointer velocity to a soft spring so the roll glides on and settles free. + const scroll = useMotionValue(defaultIndex); + const indexRef = useRef(defaultIndex); + const [, setActiveIndex] = useState(defaultIndex); + const glideRef = useRef(null); + const draggingRef = useRef(false); + const hoverRef = useRef(false); + + useEffect(() => { + if (count === 0) return; + const unsub = scroll.on("change", (v) => { + const idx = ((Math.round(v) % count) + count) % count; + if (idx !== indexRef.current) { + indexRef.current = idx; + setActiveIndex(idx); + onIndexChange?.(idx); + } + }); + return unsub; + }, [scroll, count, onIndexChange]); + + const stopGlide = useCallback(() => { + glideRef.current?.stop(); + glideRef.current = null; + }, []); + + // Spring toward `to`, carrying `velocity` (items/s) through so motion never + // steps — the roll leaves the finger at finger speed. + const glideTo = useCallback( + (to: number, velocity: number) => { + stopGlide(); + if (reduce) { + scroll.set(to); + return; + } + glideRef.current = animate(scroll, to, { + type: "spring", + ...GLIDE_SPRING, + velocity, + restDelta: 0.001, + restSpeed: 0.005, + }); + }, + [scroll, stopGlide, reduce], + ); + + const settle = useCallback( + (velocity: number) => { + const projected = + scroll.get() + + Math.max( + -MAX_FLICK_ITEMS, + Math.min(MAX_FLICK_ITEMS, velocity * FLICK_MOMENTUM), + ); + glideTo(snap ? Math.round(projected) : projected, velocity); + }, + [scroll, snap, glideTo], + ); + + const drag = useRef({ + startX: 0, + startScroll: 0, + lastX: 0, + lastT: 0, + prevX: 0, + prevT: 0, + }); + + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + stopGlide(); + draggingRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + const now = performance.now(); + drag.current = { + startX: e.clientX, + startScroll: scroll.get(), + lastX: e.clientX, + lastT: now, + prevX: e.clientX, + prevT: now, + }; + }, + [scroll, stopGlide], + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent) => { + if (!draggingRef.current) return; + const d = drag.current; + scroll.set(d.startScroll - ((e.clientX - d.startX) * dragSpeed) / gap); + d.prevX = d.lastX; + d.prevT = d.lastT; + d.lastX = e.clientX; + d.lastT = performance.now(); + }, + [scroll, gap, dragSpeed], + ); + + const onPointerUp = useCallback( + (e: ReactPointerEvent) => { + if (!draggingRef.current) return; + draggingRef.current = false; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + const d = drag.current; + const dt = d.lastT - d.prevT; + const vpx = dt > 0 ? (d.lastX - d.prevX) / dt : 0; // px per ms + settle((-vpx * dragSpeed * 1000) / gap); // items per second + }, + [settle, gap, dragSpeed], + ); + + const rollBy = useCallback( + (dir: number) => { + glideTo(Math.round(scroll.get()) + dir, scroll.getVelocity()); + }, + [scroll, glideTo], + ); + + const wheelSettleRef = useRef(undefined); + const onWheel = useCallback( + (e: ReactWheelEvent) => { + stopGlide(); + const delta = + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + scroll.set(scroll.get() + delta / gap); + if (wheelSettleRef.current) window.clearTimeout(wheelSettleRef.current); + wheelSettleRef.current = window.setTimeout( + () => settle(scroll.getVelocity()), + 140, + ); + }, + [scroll, gap, settle, stopGlide], + ); + + useEffect(() => { + if (!autoRotate || reduce || count === 0) return; + let raf = 0; + let last = performance.now(); + const tick = (now: number) => { + const dt = (now - last) / 1000; + last = now; + if (!draggingRef.current && !hoverRef.current && !glideRef.current) { + scroll.set(scroll.get() + autoRotateSpeed * dt); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [autoRotate, autoRotateSpeed, reduce, count, scroll]); + + const stageHeight = height ?? size; + + return ( + <> + {/* biome-ignore lint/a11y/noStaticElementInteractions: custom draggable + keyboard carousel */} +
{ + if (e.key === "ArrowRight") { + e.preventDefault(); + rollBy(1); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + rollBy(-1); + } + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + onWheel={onWheel} + onPointerEnter={() => { + hoverRef.current = true; + }} + onPointerLeave={() => { + hoverRef.current = false; + }} + className={cn( + // clip-path, not overflow: it also clips the GPU-composited balls + "relative w-full touch-none select-none outline-none [clip-path:inset(0)]", + "cursor-grab active:cursor-grabbing", + "focus-visible:ring-2 focus-visible:ring-foreground/20", + className, + )} + style={{ height: stageHeight }} + > + {items.map((item, i) => ( + + {item} + + ))} +
+ + ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 40e5708..840d37b 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -198,6 +198,11 @@ export const previews: Record = { (m) => m.ShaderBackgroundPreview, ), ), + "motion/cylinder-carousel": dynamic(() => + import("./motion/cylinder-carousel.preview").then( + (m) => m.CylinderCarouselPreview, + ), + ), }; export function getPreview(category: string, slug: string) { diff --git a/components/previews/motion/cylinder-carousel.preview.tsx b/components/previews/motion/cylinder-carousel.preview.tsx new file mode 100644 index 0000000..1ff11b1 --- /dev/null +++ b/components/previews/motion/cylinder-carousel.preview.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { type ComponentType, useState } from "react"; +import { CylinderCarousel } from "@/components/motion/cylinder-carousel"; +import { + ShaderBackground, + type ShaderBackgroundVariant, +} from "@/components/motion/shader-background"; +import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs"; + +// Each variant has its own prop shape; the slides only spread their own preset. +const Background = ShaderBackground as ComponentType< + { variant: ShaderBackgroundVariant; className?: string } & Record< + string, + unknown + > +>; + +const SLIDES: { variant: ShaderBackgroundVariant; props: Record }[] = [ + { + variant: "dithering", + props: { colorBack: "#1a1030", colorFront: "#b98cff", speed: 0.3 }, + }, + { + variant: "metaballs", + props: { colors: ["#e8e8ef", "#8a8a9a", "#1a1a22"], colorBack: "#c9b9a8", speed: 0.4 }, + }, + { + variant: "warp", + props: { colors: ["#c8ff00", "#3a5a00", "#c8ff00", "#88bb00"], speed: 0.4 }, + }, + { + variant: "god-rays", + props: { colors: ["#6a7bff", "#00114d"], colorBack: "#000000", speed: 0.5 }, + }, + { + variant: "swirl", + props: { colorBack: "#1a0000", colors: ["#ffd1a8", "#ff6a3d", "#b31a57"], speed: 0.3 }, + }, + { + variant: "mesh-gradient", + props: { colors: ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"], speed: 0.3 }, + }, + { + variant: "voronoi", + props: { colors: ["#ff8247", "#ffe53d"], speed: 0.3 }, + }, + { + variant: "neuro-noise", + props: { colorFront: "#ffffff", colorMid: "#47a6ff", colorBack: "#000000", speed: 0.4 }, + }, +]; + +export function CylinderCarouselPreview() { + const [variant, setVariant] = useState<"concave" | "convex">("concave"); + + return ( +
+ setVariant(v as "concave" | "convex")} + variant="segment" + > + + Concave + Convex + + + {/* clip-path (not overflow) so the rounded corner also clips the GPU-composited balls */} +
+ + {SLIDES.map((slide) => ( +
+ +
+ ))} +
+
+

+ Drag, scroll or use arrow keys to roll +

+
+ ); +} diff --git a/lib/registry.ts b/lib/registry.ts index 896b34e..03fe5eb 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -459,6 +459,21 @@ export const registry: CategoryEntry[] = [ "gradient background component", ], }, + { + slug: "cylinder-carousel", + name: "Cylinder Carousel", + description: + "A carousel whose items line the inside of a cylinder, receding into the center and growing toward the edges. Drag, scroll or arrow-key to roll it, with a springy glide and snap. Reduced-motion drops the glide.", + file: "components/motion/cylinder-carousel.tsx", + badge: "new", + keywords: [ + "3d carousel react", + "cylinder carousel", + "coverflow react", + "rolling carousel", + "draggable carousel react", + ], + }, ], }, {