Fan Carousel
Installation
npx shadcn@latest add https://uibrik.com/r/fan-carousel.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/fan-carousel/fan-carousel.tsx"use client"; import { useMemo } from "react"; import { motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // Fan carousel. Cards ride an arch and drift around it in a slow, endless // loop. Each card sits tangent to the arch, so the card at the top stands // straight and the ones either side lean away. // // The arch is wider than its container. Cards slide towards both edges, growing // blurrier and fading into the background as they go, so there is no edge to // see: they are gone before they reach it. A card re-enters at the opposite end // while it is still out of sight, so the loop has no seam. // // One motion value (the position of the loop, in cards) drives every card. // Radius, card size and spacing are all worked out from the measured width. export type FanCard = { src: string; alt: string; }; export type FanCarouselProps = { cards?: FanCard[]; /** Half-angle of the arch, degrees. Cards are fully out of view by here. */ arc?: number; /** Cards that pass the top every second. */ speed?: number; /** Blur of a card as it leaves at either end, px. */ blur?: number; /** Share of the width, per side, that dissolves into the background, %. */ fade?: number; /** Share of the width, per side, over which the blur builds up, %. */ blurSpan?: number; /** Card width, px. Height follows at 4:5. */ cardWidth?: number; /** Space between neighbouring cards along the arch, px. Scales with the card. */ gap?: number; className?: string; }; const CARDS: FanCard[] = [ { 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; const smooth = (x: number) => { const t = Math.min(1, Math.max(0, x)); return t * t * (3 - 2 * t); }; type PlacedProps = { card: FanCard; index: number; total: number; /** Where the loop is, in cards. */ loop: MotionValue<number>; step: number; radius: number; half: number; span: number; blur: number; width: number; height: number; }; function Placed({ card, index, total, loop, step, radius, half, span, blur, width, height }: PlacedProps) { // Angle from the top of the arch. Wrapped so that a card passing the far // end reappears at the near end, where it is already invisible. const angle = useTransform(loop, (t) => (mod(index - t + total / 2, total) - total / 2) * step); const x = useTransform(angle, (a) => Math.sin(a * RAD) * radius); const y = useTransform(angle, (a) => (1 - Math.cos(a * RAD)) * radius); const away = useTransform(x, (px) => smooth((Math.abs(px) / half - (1 - span)) / span)); const filter = useTransform(away, (g) => (g * blur < 0.05 ? "none" : `blur(${(g * blur).toFixed(2)}px)`)); return ( <motion.div style={{ x, y, rotate: angle, filter, width, height, marginLeft: -width / 2, transformOrigin: "50% 50%", }} className="edge-outer absolute top-0 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 FanCarousel({ cards = CARDS, arc = 60, speed = 0.3, blur = 8, fade = 25, blurSpan = 25, cardWidth: cardWidthProp = 152, gap = 28, className, }: FanCarouselProps) { const reduce = useReducedMotion(); const [stageRef, { width: stageWidth }] = useMeasure(); const loop = useMotionValue(0); const count = cards.length; const edge = Math.min(0.5, Math.max(0.05, fade / 100)); const span = Math.min(0.5, Math.max(0.05, blurSpan / 100)); const mask = `linear-gradient(to right, transparent 0, #000 ${edge * 100}%, #000 ${100 - edge * 100}%, transparent 100%)`; useAnimationFrame((_, delta) => { // A long gap (tab in the background) must not leap the loop forward. if (!reduce) loop.set(loop.get() + (speed * Math.min(delta, 64)) / 1000); }); // Card size shrinks with the container. The radius is set so that a card at // the end of the arch sits wholly outside the container. const cardWidth = Math.round(Math.min(cardWidthProp, Math.max(72, stageWidth * 0.17))); const cardHeight = Math.round(cardWidth * 1.25); const sin = Math.sin(arc * RAD); const radius = Math.max(0, (stageWidth / 2 + cardWidth) / sin); const spacing = cardWidth + gap * (cardWidth / cardWidthProp); const step = radius > 0 ? Math.min(40, Math.max(6, spacing / radius / RAD)) : 20; // The ends sit a whole or half number of steps from the top, no nearer than // the arc, so the card leaving at one end and the one entering at the other // are always in step and both are out of view when they wrap. const reach = Math.max(1, Math.ceil((arc / step) * 2) / 2); // How far the arch has dropped at the container's edge. const sideAngle = radius > 0 ? Math.asin(Math.min(1, stageWidth / 2 / radius)) : 0; const drop = (1 - Math.cos(sideAngle)) * radius; // Enough slots to cover the whole arch with room to spare so the wrap is out // of sight, rounded to a multiple of the card count so the order is unbroken. const total = useMemo(() => { const need = Math.ceil(2 * reach) + 4; return count === 0 ? 0 : Math.ceil(need / count) * count; }, [reach, count]); if (count === 0) return null; return ( <div role="region" aria-roledescription="carousel" aria-label="Image fan" className={cn("w-full max-w-4xl", className)} > {/* Hidden until measured so the arch never flashes at the wrong size. */} <div ref={stageRef} className="relative w-full overflow-hidden select-none" style={{ height: Math.round(cardHeight + drop + cardWidth * 0.8), visibility: stageWidth > 0 ? "visible" : "hidden", maskImage: mask, WebkitMaskImage: mask, }} > <div className="absolute inset-x-0" style={{ top: cardWidth * 0.4 }}> {Array.from({ length: total }, (_, i) => ( <Placed key={i} card={cards[i % count]} index={i} total={total} loop={loop} step={step} radius={radius} half={stageWidth / 2} span={span} blur={blur} width={cardWidth} height={cardHeight} /> ))} </div> </div> </div> ); }
Adds the files to
components/uibrik/fan-carousel and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/fan-carousel.Props
FanCarousel- arc
- Type number
- 60
- Half-angle of the arch. Cards are fully out of view by here.
- speed
- Type number
- 0.3
- Cards that pass the top every second.
- blur
- Type number
- 8
- Blur of a card as it dissolves at either side.
- fade
- Type number
- 25
- Share of the width, per side, that dissolves into the background.
- blurSpan
- Type number
- 25
- Share of the width, per side, over which the blur builds up.
- cardWidth
- Type number
- 152
- Card width. Height follows at 4:5.
- gap
- Type number
- 28
- Space between neighbouring cards along the arch. Negative overlaps them.
- cards
- Type { src: string; alt: string }[]
- 5 sample cards
- The cards, in order. They repeat to fill the arch.
- className
- Type string
- –
- Classes for the wrapper.