Arc Scroll
Cards that travel along a semicircle as you scroll, entering at the bottom right and leaving at the bottom left.
- Scroll
- Cards
- Motion
Uses motion, clsx, tailwind-merge
Scroll inside the box
"use client";
import * as React from "react";
import { ArcScroll } from "@/components/ui/arc-scroll";
const cards = [
{ title: "Ease in", gradient: "from-rose-400 to-orange-300" },
{ title: "Overshoot", gradient: "from-amber-300 to-lime-300" },
{ title: "Settle", gradient: "from-emerald-400 to-teal-300" },
{ title: "Stagger", gradient: "from-cyan-400 to-sky-300" },
{ title: "Spring", gradient: "from-blue-500 to-indigo-400" },
{ title: "Follow", gradient: "from-violet-500 to-fuchsia-400" },
{ title: "Anticipate", gradient: "from-fuchsia-500 to-pink-400" },
{ title: "Release", gradient: "from-zinc-700 to-zinc-500" },
];
export function ArcScrollDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div ref={scrollRef} className="relative h-[var(--demo-height,30rem)] overflow-y-auto overscroll-contain">
<ArcScroll containerRef={scrollRef} stageClassName="h-[var(--demo-height,30rem)]" scrollPerCard={240}>
{cards.map((card, index) => (
<div
key={card.title}
className={`flex h-56 w-40 flex-col justify-between rounded-2xl bg-linear-to-br p-4 text-white shadow-xl shadow-zinc-900/15 ring-1 ring-black/5 dark:shadow-black/50 dark:ring-white/10 ${card.gradient}`}
>
<span className="font-mono text-xs opacity-80">
{String(index + 1).padStart(2, "0")}
</span>
<span className="text-lg font-semibold tracking-tight">{card.title}</span>
</div>
))}
</ArcScroll>
</div>
<p className="pointer-events-none absolute inset-x-0 bottom-4 text-center font-mono text-xs uppercase tracking-widest text-zinc-500">
Scroll inside the box
</p>
</div>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/arc-scroll.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/arc-scrollInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/arc-scroll.tsx"use client"; import * as React from "react"; import { motion, useMotionValueEvent, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * ARC SCROLL STORYBOARD (driven by scroll progress, not time) * * 0% section pins; card 0 at top center, the rest fanned * down the right side of the arc * 0→100% wheel turns counter-clockwise, `spacing`° per card * cards rise from bottom-right → peak at top center → * fall away to bottom-left * at the top: scale 1, upright, highest z-index * near the top: neighbors spread apart so they never * overlap while trading places (apexGap) * toward edges: scale → ARC.edgeScale, tilted along the curve * past visibleArc: fade out over ARC.fadeArc degrees * 100% last card at top center; section unpins * ───────────────────────────────────────────────────────── */ const ARC = { spacing: 18, // degrees between neighboring cards visibleArc: 80, // cards stay fully visible within ±this angle of the top fadeArc: 10, // degrees over which a card fades out past visibleArc edgeScale: 0.85, // scale of a card at the edge of the visible arc apex: 0.4, // center of the top card, as a fraction of the stage height radiusRatio: 0.75, // auto radius, as a fraction of the stage width maxRadius: 900, // cap for the auto radius, in px apexGap: 16, // min px between neighbors as they swap at the top maxSwapRatio: 0.95, // caps the spread when cards are wide for the arc }; const SCROLL_SPRING = { stiffness: 120, damping: 30, mass: 0.4 }; const DEG = Math.PI / 180; export interface ArcScrollProps extends React.HTMLAttributes<HTMLElement> { /** Degrees between neighboring cards along the arc. */ spacing?: number; /** Arc radius in px. Defaults to a fraction of the stage width. */ radius?: number; /** Pixels of scroll it takes to move one card along the arc. */ scrollPerCard?: number; /** Rotate each card to follow the curve. */ tilt?: boolean; /** * Scroll container to track. Defaults to the window. The container must be * positioned (e.g. `relative`), or scroll progress is measured from the page. */ containerRef?: React.RefObject<HTMLElement | null>; /** Classes for the pinned stage. Set its height here. */ stageClassName?: string; /** Classes for the wrapper around every card. */ cardClassName?: string; /** * Minimum gap in px between two cards as they trade places at the top. * Cards spread apart near the top to make room, then settle back to * `spacing` toward the edges. */ apexGap?: number; /** * Content pinned inside the stage, behind the cards: headlines, captions, * progress. Position it with `absolute` classes. */ overlay?: React.ReactNode; /** Called with the index of the card nearest the top whenever it changes. */ onActiveChange?: (index: number) => void; } export function ArcScroll({ spacing = ARC.spacing, radius, scrollPerCard = 320, tilt = true, containerRef, stageClassName, cardClassName, apexGap = ARC.apexGap, overlay, onActiveChange, className, children, ...props }: ArcScrollProps) { const sectionRef = React.useRef<HTMLElement>(null); const stageRef = React.useRef<HTMLDivElement>(null); const stage = useElementSize(stageRef); const reduceMotion = useReducedMotion(); const cards = React.Children.toArray(children); const count = cards.length; const card = useLargestCard(stageRef, count); const { scrollYProgress } = useScroll({ target: sectionRef, container: containerRef, offset: ["start start", "end end"], }); const smoothProgress = useSpring(scrollYProgress, SCROLL_SPRING); const progress = reduceMotion ? scrollYProgress : smoothProgress; // How far the wheel has turned, in degrees. Card i sits at i * spacing // minus this, so 0° is the top and positive angles are on the right. const turn = useTransform(progress, (p) => p * Math.max(count - 1, 0) * spacing); // The card nearest the top. Read from the smoothed turn, so it matches what's // on screen rather than the raw scroll position. const activeRef = React.useRef(0); useMotionValueEvent(turn, "change", (t) => { if (!onActiveChange || spacing <= 0) return; const index = Math.min(Math.max(Math.round(t / spacing), 0), Math.max(count - 1, 0)); if (index === activeRef.current) return; activeRef.current = index; onActiveChange(index); }); const arcRadius = radius ?? Math.min(stage.width * ARC.radiusRatio, ARC.maxRadius); const tilted = tilt && !reduceMotion; const spread = apexSpread({ spacing, radius: arcRadius, card, gap: apexGap, tilt: tilted }); return ( <section ref={sectionRef} className={cn("relative", className)} {...props}> <div ref={stageRef} className={cn("sticky top-0 h-dvh w-full overflow-hidden", stageClassName)} // Hidden until measured, so cards don't flash stacked at the center. style={{ visibility: stage.width ? undefined : "hidden" }} > {overlay} {cards.map((card, index) => ( <ArcCard key={index} baseAngle={index * spacing} spacing={spacing} spread={spread} turn={turn} radius={arcRadius} apexY={stage.height * ARC.apex} tilt={tilted} className={cardClassName} > {card} </ArcCard> ))} </div> {/* Scroll runway: the stage stays pinned while this scrolls past. */} <div aria-hidden style={{ height: Math.max(count - 1, 0) * scrollPerCard }} /> </section> ); } /** * Two neighbors trade places (and z-index) when they're equally far from the * top. If they overlap at that moment, the overlap flips from one card to the * other in a single frame. To avoid it, cards are pushed apart near the top by * `spread * tanh(a / spacing)` degrees: wide enough there that the pair clears, * fading to a constant offset toward the edges so `spacing` holds again. */ function apexSpread({ spacing, radius, card, gap, tilt, }: { spacing: number; radius: number; card: { width: number; height: number }; gap: number; tilt: boolean; }) { if (!card.width || spacing <= 0 || radius <= 0) return 0; // Tilted cards fan inward, so their bottom corners meet first. const reach = tilt ? radius - card.height / 2 : radius; const ratio = reach > 0 ? (card.width + gap) / (2 * reach) : 1; const needed = (2 * Math.asin(Math.min(ratio, ARC.maxSwapRatio))) / DEG; // At the swap each card sits at ±spacing/2, pushed out by spread * tanh(0.5). return Math.max(0, (needed - spacing) / (2 * Math.tanh(0.5))); } function ArcCard({ baseAngle, spacing, spread, turn, radius, apexY, tilt, className, children, }: { baseAngle: number; spacing: number; spread: number; turn: MotionValue<number>; radius: number; apexY: number; tilt: boolean; className?: string; children: React.ReactNode; }) { const angle = useTransform(turn, (t) => { const a = baseAngle - t; return spread ? a + spread * Math.tanh(a / spacing) : a; }); const x = useTransform(angle, (a) => radius * Math.sin(a * DEG)); const y = useTransform(angle, (a) => apexY + radius * (1 - Math.cos(a * DEG))); const rotate = useTransform(angle, (a) => (tilt ? a : 0)); const scale = useTransform(angle, (a) => { const t = Math.min(Math.abs(a) / ARC.visibleArc, 1); return 1 - (1 - ARC.edgeScale) * t; }); const opacity = useTransform(angle, (a) => { const past = Math.abs(a) - ARC.visibleArc; return past <= 0 ? 1 : Math.max(0, 1 - past / ARC.fadeArc); }); const zIndex = useTransform(angle, (a) => Math.round(1000 - Math.abs(a) * 10)); const visibility = useTransform(opacity, (o) => (o === 0 ? "hidden" : "visible")); // A zero-size anchor rides the arc; the card is centered on it, so it // rotates and scales around its own center. return ( <motion.div className="absolute top-0 left-1/2 size-0 will-change-transform" style={{ x, y, rotate, scale, opacity, zIndex, visibility }} > <div data-arc-card className={cn("absolute -translate-x-1/2 -translate-y-1/2", className)} > {children} </div> </motion.div> ); } function useElementSize(ref: React.RefObject<HTMLElement | null>) { const [size, setSize] = React.useState({ width: 0, height: 0 }); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const update = () => setSize({ width: el.clientWidth, height: el.clientHeight }); update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, [ref]); return size; } /** Size of the widest and tallest card, unaffected by the arc's transforms. */ function useLargestCard(stageRef: React.RefObject<HTMLElement | null>, count: number) { const [size, setSize] = React.useState({ width: 0, height: 0 }); React.useLayoutEffect(() => { const stage = stageRef.current; if (!stage) return; const cards = Array.from(stage.querySelectorAll<HTMLElement>("[data-arc-card]")); const update = () => setSize({ width: Math.max(0, ...cards.map((card) => card.offsetWidth)), height: Math.max(0, ...cards.map((card) => card.offsetHeight)), }); update(); const observer = new ResizeObserver(update); cards.forEach((card) => observer.observe(card)); return () => observer.disconnect(); }, [stageRef, count]); return size; }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Arc Scroll. Each one installs whole, with the component included.
Case Studies
A portfolio hero: project cards on the arc, with a pinned headline and a caption for the project at the top.
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArcScroll } from "@/components/ui/arc-scroll";
const projects = [
{ client: "Lumen", kind: "Brand site", year: 2026, domain: "lumen.energy", accent: "from-amber-300 to-orange-500" },
{ client: "Parcel", kind: "Logistics dashboard", year: 2025, domain: "parcel.app", accent: "from-indigo-400 to-blue-600" },
{ client: "Halcyon", kind: "Patient app", year: 2025, domain: "halcyon.health", accent: "from-teal-300 to-emerald-600" },
{ client: "Fieldnote", kind: "Journaling app", year: 2024, domain: "fieldnote.co", accent: "from-lime-300 to-green-600" },
{ client: "Orbit", kind: "Mobile banking", year: 2023, domain: "orbit.bank", accent: "from-violet-400 to-purple-700" },
{ client: "Kiln", kind: "Ceramics store", year: 2022, domain: "kiln.shop", accent: "from-rose-300 to-red-500" },
{ client: "Atlas", kind: "Maps platform", year: 2021, domain: "atlas.dev", accent: "from-sky-300 to-cyan-600" },
];
// For a full-page section, drop the scroll box and `containerRef`: the stage
// defaults to the viewport height and pins while the page scrolls.
export function ArcScrollCaseStudiesDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(0);
const reduceMotion = useReducedMotion();
const project = projects[active];
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
<ArcScroll
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,36rem)]"
spacing={22}
scrollPerCard={260}
onActiveChange={setActive}
overlay={
<>
<div className="absolute inset-x-0 top-8 px-6 text-center">
<p className="font-mono text-xs uppercase tracking-widest text-zinc-500">
Selected work
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
2021–2026
</h2>
</div>
<motion.div
key={project.client}
initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="absolute inset-x-0 bottom-10 px-6 text-center"
>
<p className="font-mono text-xs text-zinc-500 tabular-nums">
{String(active + 1).padStart(2, "0")} /{" "}
{String(projects.length).padStart(2, "0")} · {project.year}
</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{project.client}
</p>
<p className="text-sm text-zinc-500">{project.kind}</p>
</motion.div>
</>
}
>
{projects.map((item) => (
<BrowserCard key={item.client} domain={item.domain} accent={item.accent} />
))}
</ArcScroll>
</div>
</div>
);
}
function BrowserCard({ domain, accent }: { domain: string; accent: string }) {
return (
<div className="w-52 overflow-hidden rounded-xl bg-white shadow-xl ring-1 shadow-zinc-900/15 ring-zinc-900/10 sm:w-64 dark:bg-zinc-900 dark:shadow-black/50 dark:ring-white/10">
<div className="flex items-center gap-1.5 border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
<span className="size-2 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<span className="size-2 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<span className="size-2 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<span className="ml-2 truncate rounded bg-zinc-100 px-2 py-0.5 font-mono text-[10px] text-zinc-500 dark:bg-zinc-800">
{domain}
</span>
</div>
<div className={`flex aspect-[16/10] flex-col justify-end gap-2 bg-linear-to-br p-4 ${accent}`}>
<span className="h-2.5 w-3/4 rounded-full bg-white/80" />
<span className="h-2.5 w-1/2 rounded-full bg-white/60" />
<span className="mt-2 h-5 w-16 rounded-full bg-white/90" />
</div>
</div>
);
}
Timeline
A company history with tighter spacing, the current year highlighted and a year rail that follows along.
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { ArcScroll } from "@/components/ui/arc-scroll";
const milestones = [
{ year: 2019, label: "Founded", detail: "Two designers, a spare bedroom and a prototype." },
{ year: 2020, label: "Beta", detail: "Version 0.1 ships to forty teams who said yes." },
{ year: 2021, label: "Seed", detail: "A seed round grows the team to twelve." },
{ year: 2022, label: "1.0", detail: "A stable API and our first paying customers." },
{ year: 2023, label: "10k teams", detail: "Ten thousand teams ship with us every week." },
{ year: 2024, label: "Global", detail: "New offices in Berlin and Singapore." },
{ year: 2025, label: "Platform", detail: "Plugins open to third-party developers." },
{ year: 2026, label: "Today", detail: "Still small, still shipping every week." },
];
export function ArcScrollTimelineDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(0);
const reduceMotion = useReducedMotion();
const milestone = milestones[active];
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div ref={scrollRef} className="relative h-[var(--demo-height,34rem)] overflow-y-auto overscroll-contain">
<ArcScroll
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,34rem)]"
spacing={12}
scrollPerCard={220}
onActiveChange={setActive}
overlay={
<div className="absolute inset-x-0 bottom-8 flex flex-col items-center gap-6 px-6 text-center">
<motion.p
key={milestone.year}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="max-w-xs text-sm text-zinc-600 dark:text-zinc-400"
>
{milestone.detail}
</motion.p>
<ol className="flex gap-1 font-mono text-[11px] tabular-nums">
{milestones.map((item, index) => (
<li
key={item.year}
className={cn(
"relative px-1.5 py-1 transition-colors duration-200",
index === active
? "text-zinc-900 dark:text-zinc-50"
: "text-zinc-400 dark:text-zinc-600",
)}
>
{index === active ? (
<motion.span
layoutId="arc-timeline-tick"
transition={{ type: "spring", stiffness: 500, damping: 40 }}
className="absolute inset-x-1.5 -bottom-0.5 h-px bg-current"
/>
) : null}
{String(item.year).slice(2)}
</li>
))}
</ol>
</div>
}
>
{milestones.map((item, index) => (
<div
key={item.year}
className={cn(
"flex h-40 w-28 flex-col justify-between rounded-2xl p-3 shadow-lg ring-1 transition-colors duration-300 sm:w-32",
index === active
? "bg-zinc-900 text-zinc-50 shadow-zinc-900/20 ring-zinc-900 dark:bg-zinc-50 dark:text-zinc-900 dark:ring-zinc-50"
: "bg-white text-zinc-900 shadow-zinc-900/5 ring-zinc-200 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-black/40 dark:ring-zinc-800",
)}
>
<span className="font-mono text-[10px] uppercase tracking-widest opacity-60">
{item.label}
</span>
<span className="text-3xl font-semibold tracking-tight tabular-nums">
{item.year}
</span>
</div>
))}
</ArcScroll>
</div>
</div>
);
}
Testimonials
Wide quote cards with tilt turned off so the text stays level, plus page dots.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ArcScroll } from "@/components/ui/arc-scroll";
const testimonials = [
{
quote: "We replaced three libraries with it and our bundle got smaller. The motion feels hand-tuned.",
name: "Maya Okafor",
role: "Design Engineer, Parcel",
avatar: "from-amber-300 to-rose-400",
},
{
quote: "Our landing page finally feels alive. Copy, paste, adjust two props, ship.",
name: "Daniel Reyes",
role: "Founder, Fieldnote",
avatar: "from-emerald-300 to-teal-500",
},
{
quote: "Reduced motion support out of the box saved us a full accessibility pass.",
name: "Priya Shah",
role: "Frontend Lead, Halcyon",
avatar: "from-sky-300 to-indigo-500",
},
{
quote: "It reads like code I'd write myself, which is the highest compliment I have.",
name: "Tom Lindqvist",
role: "Staff Engineer, Orbit",
avatar: "from-violet-300 to-fuchsia-500",
},
{
quote: "Our designers stopped asking for After Effects exports. That alone was worth it.",
name: "Aiko Tanaka",
role: "Product Designer, Lumen",
avatar: "from-lime-300 to-emerald-500",
},
];
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("");
}
export function ArcScrollTestimonialsDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(0);
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div ref={scrollRef} className="relative h-[var(--demo-height,32rem)] overflow-y-auto overscroll-contain">
<ArcScroll
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,32rem)]"
spacing={30}
tilt={false}
scrollPerCard={300}
onActiveChange={setActive}
overlay={
<>
<p className="absolute inset-x-0 top-6 text-center font-mono text-xs uppercase tracking-widest text-zinc-500">
Loved by teams who ship
</p>
<div className="absolute inset-x-0 bottom-8 flex justify-center gap-2">
{testimonials.map((item, index) => (
<span
key={item.name}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
index === active
? "w-5 bg-zinc-900 dark:bg-zinc-50"
: "w-1.5 bg-zinc-300 dark:bg-zinc-700",
)}
/>
))}
</div>
</>
}
>
{testimonials.map((item) => (
<figure
key={item.name}
className="flex h-52 w-64 flex-col justify-between rounded-2xl bg-white p-5 shadow-xl ring-1 shadow-zinc-900/10 ring-zinc-200 sm:w-72 dark:bg-zinc-900 dark:shadow-black/50 dark:ring-zinc-800"
>
<blockquote className="text-[15px] leading-relaxed text-zinc-700 dark:text-zinc-300">
“{item.quote}”
</blockquote>
<figcaption className="flex items-center gap-3">
<span
aria-hidden
className={`flex size-9 items-center justify-center rounded-full bg-linear-to-br text-xs font-semibold text-white ${item.avatar}`}
>
{initials(item.name)}
</span>
<span className="text-sm">
<span className="block font-medium text-zinc-900 dark:text-zinc-50">
{item.name}
</span>
<span className="block text-zinc-500">{item.role}</span>
</span>
</figcaption>
</figure>
))}
</ArcScroll>
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | None | The cards. Each child is placed on the arc. |
spacing | number | 18 | Degrees between neighboring cards along the arc. |
radius | number | None | Arc radius in pixels. Defaults to 75% of the stage width, capped at 900. |
scrollPerCard | number | 320 | Pixels of scroll it takes to move one card along the arc. |
tilt | boolean | true | Rotate each card to follow the curve. Turned off for reduced motion. |
containerRef | React.RefObject<HTMLElement | null> | None | Scroll container to track. Defaults to the window. The container must be positioned (e.g. relative). |
stageClassName | string | "h-dvh" | Classes for the pinned stage. Set its height here. |
cardClassName | string | None | Classes for the wrapper around every card. |
apexGap | number | 16 | Minimum gap in pixels between two cards as they trade places at the top. Cards spread apart near the top to make room, so the overlap never flips suddenly. |
overlay | React.ReactNode | None | Content pinned inside the stage, behind the cards (headlines, captions, progress). Position it with absolute classes. |
onActiveChange | (index: number) => void | None | Called with the index of the card nearest the top whenever it changes. |
className | string | None | Extra classes for the outer section. |
childrenType
React.ReactNodeThe cards. Each child is placed on the arc.
spacingType
numberDefault18Degrees between neighboring cards along the arc.
radiusType
numberArc radius in pixels. Defaults to 75% of the stage width, capped at 900.
scrollPerCardType
numberDefault320Pixels of scroll it takes to move one card along the arc.
tiltType
booleanDefaulttrueRotate each card to follow the curve. Turned off for reduced motion.
containerRefType
React.RefObject<HTMLElement | null>Scroll container to track. Defaults to the window. The container must be positioned (e.g.
relative).stageClassNameType
stringDefault"h-dvh"Classes for the pinned stage. Set its height here.
cardClassNameType
stringClasses for the wrapper around every card.
apexGapType
numberDefault16Minimum gap in pixels between two cards as they trade places at the top. Cards spread apart near the top to make room, so the overlap never flips suddenly.
overlayType
React.ReactNodeContent pinned inside the stage, behind the cards (headlines, captions, progress). Position it with
absoluteclasses.onActiveChangeType
(index: number) => voidCalled with the index of the card nearest the top whenever it changes.
classNameType
stringExtra classes for the outer section.