Stack Flow
Installation
npx shadcn@latest add https://uibrik.com/r/stack-flow.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/stack-flow/stack-flow.tsx"use client"; import { useMemo, useState } from "react"; import { motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // Stack flow. One long row of cards runs on a diagonal, seen from the side so // each card shows only a thin sliver of the one in front. The row is packed // tight everywhere except the middle, where it opens into a gap. Every beat the // whole row moves one place along: a card leaves the first stack, crosses the // gap on its own, and settles on the front of the second. Both ends of the row // run out of the frame and fade, so the loop never shows its seam. // // One motion value (how many places the row has moved) drives every card. export type StackFlowCard = { src: string; alt: string; }; export type StackFlowProps = { cards?: StackFlowCard[]; /** Cards in the row. Leave out to fit as many as the stage needs. */ count?: number; /** Space between neighbouring cards inside a stack, px. */ spacing?: number; /** Length of the open gap between the two stacks, px. */ gap?: number; /** Places the row moves along every second. */ speed?: number; /** Which way the cards cross the gap. */ direction?: "forward" | "back"; /** Card width, px. Height follows the ratio. */ cardWidth?: number; /** Card height against its width. */ ratio?: number; /** How far the row turns away from you, degrees. */ rotation?: number; /** How far the row leans back, degrees. */ tilt?: number; /** Corner radius of a card, px. */ rounding?: number; className?: string; }; const CARDS: StackFlowCard[] = [ { 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 mod = (n: number, m: number) => ((n % m) + m) % m; const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); const smooth = (x: number) => { const t = clamp(x, 0, 1); return t * t * (3 - 2 * t); }; const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); // 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; // The row spends this share of every beat moving, and rests for the rest, so // the card in the gap hangs there a moment before the next one goes. const MOVE = 0.72; // Half the width of the gap, in places along the row. const OPEN = 1.6; const FADE = "linear-gradient(to right, transparent, #000 14%, #000 86%, transparent), linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent)"; // Places at either end of the row spent fading, so the wrap is never seen. const EDGE = 3; type CardProps = { card: StackFlowCard; index: number; count: number; flow: MotionValue<number>; /** Distance along the row of a place, centred on the gap. */ place: (u: number) => number; width: number; height: number; rounding: number; }; function Slab({ card, index, count, flow, place, width, height, rounding }: CardProps) { const u = useTransform(flow, (f) => mod(index + f, count)); // Further along the row is further away, so it recedes up the diagonal. const z = useTransform(u, (v) => round(-place(v))); const opacity = useTransform(u, (v) => round(smooth(v / EDGE) * smooth((count - v) / EDGE))); return ( <motion.div style={{ z, opacity, width, height, marginLeft: -width / 2, marginTop: -height / 2, borderRadius: rounding, }} className="absolute top-1/2 left-1/2 overflow-hidden bg-muted p-0.75 ring-1 ring-black/6 backface-hidden dark:ring-white/10" > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={card.src} alt="" draggable={false} className="size-full object-cover" style={{ borderRadius: Math.max(0, rounding - 3) }} /> </motion.div> ); } export function StackFlow({ cards = CARDS, count: countProp, spacing = 9, gap = 400, speed = 0.6, direction = "forward", cardWidth: cardWidthProp = 208, ratio = 0.66, rotation = 50, tilt = 30, rounding = 10, className, }: StackFlowProps) { const reduce = useReducedMotion(); const [stageRef, { width: stageWidth }] = useMeasure(); const [paused, setPaused] = useState(false); const flow = useMotionValue(0); const total = cards.length; // How far the row has been run, in beats. The step is the whole part and the // eased remainder, so the row moves, rests, moves. `rate` eases to 0 on hover. const beat = useMotionValue(0); const rate = useMotionValue(1); useAnimationFrame((_, delta) => { if (reduce) return; const target = paused ? 0 : 1; // Ease into and out of a hover rather than stopping dead. rate.set(rate.get() + (target - rate.get()) * Math.min(1, delta / 140)); const sign = direction === "forward" ? 1 : -1; // A long gap (tab in the background) must not leap the row forward. beat.set(beat.get() + sign * speed * rate.get() * (Math.min(delta, 64) / 1000)); const whole = Math.floor(beat.get()); flow.set(whole + easeInOut(clamp((beat.get() - whole) / MOVE, 0, 1))); }); const cardWidth = Math.round(clamp(cardWidthProp, 56, Math.max(56, stageWidth * 0.42))); const cardHeight = Math.round(cardWidth * ratio); const height = Math.round(stageWidth * 0.68); const turn = rotation * (Math.PI / 180); const lean = tilt * (Math.PI / 180); // How long one place of the row looks on screen, to know how many fill it. const onScreen = Math.hypot(Math.sin(turn), Math.cos(turn) * Math.sin(lean)); const count = useMemo(() => { if (countProp !== undefined) return Math.max(8, Math.round(countProp)); if (stageWidth === 0) return 24; const length = (stageWidth * 1.5) / Math.max(0.3, onScreen); return clamp(Math.round((length - gap) / Math.max(2, spacing)), 16, 72); }, [countProp, stageWidth, onScreen, gap, spacing]); // Position along the row for a place u. Places are `spacing` apart, except // round the middle, where a smooth bump of extra length opens the gap. const place = useMemo(() => { const centre = count / 2; const extra = gap / OPEN; const opened = (u: number) => { const x = clamp(u - centre, -OPEN, OPEN); return extra * (0.5 * (x + OPEN) + (OPEN / (2 * Math.PI)) * Math.sin((Math.PI * x) / OPEN)); }; const whole = spacing * count + opened(count); return (u: number) => spacing * u + opened(u) - whole / 2; }, [count, spacing, gap]); const slots = useMemo(() => Array.from({ length: count }, (_, i) => i), [count]); if (total === 0) return null; return ( <div role="group" aria-label="Stacks of photos" className={cn("w-full max-w-3xl", className)}> <div ref={stageRef} onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} className="relative w-full overflow-hidden select-none" style={{ // Rows run out of the frame, so every edge dissolves rather than cuts. maskImage: FADE, WebkitMaskImage: FADE, maskComposite: "intersect", WebkitMaskComposite: "source-in", height, perspective: Math.round(stageWidth * 2.4), visibility: stageWidth > 0 ? "visible" : "hidden", }} > <div aria-hidden className="absolute inset-0" style={{ transformStyle: "preserve-3d", transform: `rotateX(${-tilt}deg) rotateY(${-rotation}deg)`, }} > {slots.map((i) => ( <Slab key={i} card={cards[i % total]} index={i} count={count} flow={flow} place={place} width={cardWidth} height={cardHeight} rounding={rounding} /> ))} </div> </div> </div> ); }
Adds the files to
components/uibrik/stack-flow and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/stack-flow.Props
StackFlow- speed
- Type number
- 0.6
- Cards that cross the gap every second. Pauses while the pointer is over the stacks.
- direction
- Type "forward" | "back"
- "forward"
- Which way the cards cross the gap.
- gap
- Type number
- 400
- Length of the open gap between the two stacks.
- spacing
- Type number
- 9
- Space between neighbouring cards inside a stack.
- cardWidth
- Type number
- 208
- Card width.
- ratio
- Type number
- 0.66
- Card height against its width.
- rotation
- Type number
- 50
- How far the row turns away from you.
- tilt
- Type number
- 30
- How far the row leans back, which sets how steeply it climbs.
- rounding
- Type number
- 10
- Corner radius of a card.
- cards
- Type { src: string; alt: string }[]
- 5 sample cards
- The cards, in order. They repeat to fill the row.
- count
- Type number
- –
- Cards in the row. Leave out to fit as many as the stage needs.
- className
- Type string
- –
- Classes for the wrapper.