How to build a draggable horizontal card carousel in React
A draggable horizontal card carousel in React uses Framer Motion's drag API with a bounded motion value. Each card receives the shared x motion value and computes its distance from the viewport edges to derive opacity and scale via useTransform, creating an edge-fade effect as cards leave view.
- Stack: React 18 + Framer Motion 11 + Lucide React + Tailwind v4, ~270 lines, no extra dependencies.
- Core APIs: useMotionValue, useTransform, drag, dragConstraints, dragElastic.
- Progress bar synced to scroll position with a derived scaleX motion value.
- Fully responsive: drag constraints recompute from real DOM width on drag start.
- Touch-native on mobile; no pointer required, works with a single touch gesture.
Services Scroll Cards is a horizontal drag-to-scroll section that presents a set of service cards in a single overflowing row. Cards fade and shrink as they reach the viewport edges, giving the user a visual cue that more content exists beyond the visible area. A minimal progress bar below the track mirrors the scroll position in real time.
Anatomy
The section splits into three areas: a centered header block (eyebrow label, H2 title, subtitle paragraph); a full-width drag track that overflows hidden and contains a flex row of cards; and a progress indicator row below the track. Each card is a fixed-width column (300px) with a 4:3 aspect-ratio icon zone at the top, an optional tag badge, a bold title, and a muted description paragraph. The entire flex row is wrapped in a single motion.div that receives the drag prop.
How it works
A single shared motion value x drives everything. The parent motion.div is set to drag="x" with dragConstraints pointing to the container ref and dragElastic={0.08} for a slight rubber-band at the edges. Each ServiceCard receives dragX (the same x value) and its own index. Inside each card, two useTransform calls read the current x value synchronously: they compute the card's projected screen position (cardOffset + val + center) and measure its distance from both viewport edges. When that distance drops below -80px, opacity decreases from 1 to 0 over a 120px range, and scale shrinks from 1 to a minimum of 0.88 over a 600px range. The progress scaleX is derived the same way: a useTransform callback maps x to the 0..1 range based on the computed maxLeft constraint.
How to build it in React
Create the shared motion value and drag track
Declare a single useMotionValue(0) for x in the parent component. Attach a ref to the overflow-hidden wrapper div, then pass both to the motion.div as drag="x", dragConstraints={trackRef}, and style={{ x }}. Set dragElastic={0.08} so the row resists overscroll slightly.
const x = useMotionValue(0); const trackRef = useRef<HTMLDivElement>(null); <div ref={trackRef} className="w-full overflow-hidden"> <motion.div drag="x" dragConstraints={trackRef} dragElastic={0.08} style={{ x, display: "flex", gap: 24 }}> {cards.map((card, i) => <ServiceCard key={card.id} dragX={x} index={i} totalCards={cards.length} />)} </motion.div> </div>Derive per-card opacity and scale from drag position
Inside each card, compute the card's projected left and right screen positions using its index, CARD_WIDTH, and CARD_GAP. Then call useTransform on dragX to map that distance-from-edge to opacity and scale. Cards fully inside the viewport stay at opacity 1 and scale 1; cards crossing the edge fade and shrink proportionally.
const opacity = useTransform(dragX, (val) => { const pos = index * (CARD_WIDTH + CARD_GAP) + val + center; const distFromEdge = Math.min(pos, viewWidth - pos - CARD_WIDTH); return distFromEdge > -80 ? 1 : Math.max(0, 1 + distFromEdge / 120); }); const scale = useTransform(dragX, (val) => { const pos = index * (CARD_WIDTH + CARD_GAP) + val + center; const distFromEdge = Math.min(pos, viewWidth - pos - CARD_WIDTH); return distFromEdge > -80 ? 1 : Math.max(0.88, 1 + distFromEdge / 600); });Add the progress bar
Below the drag track, render a 1px horizontal line and a child div with origin-left. Compute scaleX via a useTransform callback that maps the current x value to the 0..1 range: divide the negative x offset by maxLeft (the maximum drag distance). Apply it to the child div's style.
const progressScaleX = useTransform(x, () => { const maxLeft = -(totalWidth - trackRef.current!.offsetWidth); if (maxLeft >= 0) return 1; return Math.max(0, Math.min(1, 1 - x.get() / maxLeft)); }); <motion.div className="h-full origin-left" style={{ scaleX: progressScaleX, backgroundColor: "var(--color-accent)" }} />Clamp x on drag start for responsive layouts
The maxLeft constraint depends on the container's actual rendered width, which changes on resize. Read it inside onDragStart from the real DOM via trackRef.current.offsetWidth. If the current x value is already out of the valid range, which can happen after a viewport resize, clamp it back before Framer Motion applies its own constraints.
onDragStart={() => { const viewW = trackRef.current!.offsetWidth; const maxLeft = -(totalWidth - viewW); const cur = x.get(); if (cur > 0) x.set(0); if (cur < maxLeft) x.set(maxLeft); }}
When to use it
Reach for this pattern when you have five or more services, features, or offerings that won't fit in a readable grid at common breakpoints. It works well on agency sites, SaaS feature pages, and product landing pages where each service is distinct enough to deserve its own card. Avoid it when the items are highly comparable and users need to scan all of them side by side, a static grid serves that better. On very large screens where all cards fit, the drag interaction becomes inert; consider hiding the progress bar in that case to avoid a confusing UI.
Used by
- Stripe, Horizontal drag-to-scroll product feature rows on its Payments and products pages.
- Shopify, Scrollable card rows for feature highlights on its features marketing page.
- Notion, Draggable horizontal feature carousels on several product and use-case landing pages.
- Webflow, Touch-draggable card sections presenting product capabilities in a scrollable row.
FAQ
Does it work on touch screens and mobile?
Yes. Framer Motion's drag API handles both mouse and touch events natively, so the same drag gesture works on iOS and Android without any extra code.
Why use useTransform instead of CSS overflow:scroll?
CSS overflow:scroll can't drive per-card opacity and scale based on position without JavaScript. useTransform reads the drag motion value synchronously on every frame, letting each card react to its precise screen position with zero layout recalculation.
How do I update drag constraints after a window resize?
The cleanest approach is to recompute maxLeft inside onDragStart by reading trackRef.current.offsetWidth at that moment, rather than storing it in state. You can also add a ResizeObserver on the track ref to clamp x.set() back into range whenever the container width changes.
Can I add snap-to-card behavior?
Framer Motion doesn't have built-in snap points for drag, but you can implement it with onDragEnd: compute the nearest card index from the final x value, then animate x to that card's offset using x.animation = animate(x, targetOffset, { type: 'spring' }).