From 0e8e5edebd6e4c7ce15d4d9e9262ae7c4cbe57b2 Mon Sep 17 00:00:00 2001 From: leazi99 Date: Fri, 24 Apr 2026 14:14:38 +0545 Subject: [PATCH 1/2] feat(carousel): add swipe deck with snap, indicators, and parallax --- animata/carousel/swipe-deck.stories.tsx | 59 +++++ animata/carousel/swipe-deck.tsx | 328 ++++++++++++++++++++++++ content/docs/carousel/swipe-deck.mdx | 39 +++ 3 files changed, 426 insertions(+) create mode 100644 animata/carousel/swipe-deck.stories.tsx create mode 100644 animata/carousel/swipe-deck.tsx create mode 100644 content/docs/carousel/swipe-deck.mdx diff --git a/animata/carousel/swipe-deck.stories.tsx b/animata/carousel/swipe-deck.stories.tsx new file mode 100644 index 00000000..b8747cbf --- /dev/null +++ b/animata/carousel/swipe-deck.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import SwipeDeck from "@/animata/carousel/swipe-deck"; + +const meta = { + title: "Carousel/Swipe Deck", + component: SwipeDeck, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + className: "w-full min-w-72 storybook-fix", + items: [ + { + id: "onboarding", + badge: "Onboarding", + title: "Welcome flow that keeps people moving", + description: + "Deliver setup tips and key actions in a swipe deck that feels natural on both touch and mouse.", + image: + "https://images.unsplash.com/photo-1517048676732-d65bc937f952?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "featured-post", + badge: "Featured", + title: "Highlight posts with high visual impact", + description: + "Snap cards into focus while users browse stories, updates, and curated editor picks.", + image: + "https://images.unsplash.com/photo-1483058712412-4245e9b90334?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "product-highlight", + badge: "Product", + title: "Showcase product benefits in sequence", + description: + "Each swipe reveals the next value prop with smooth indicator and arrow navigation.", + image: + "https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "recommendations", + badge: "For You", + title: "Personal recommendations, one card at a time", + description: + "Use gesture-friendly cards for picks based on activity, preferences, and intent.", + image: + "https://images.unsplash.com/photo-1551281044-8b4a2f5f6f2d?q=80&w=1400&auto=format&fit=crop", + }, + ], + }, +}; diff --git a/animata/carousel/swipe-deck.tsx b/animata/carousel/swipe-deck.tsx new file mode 100644 index 00000000..92bcc479 --- /dev/null +++ b/animata/carousel/swipe-deck.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { type HTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +export interface SwipeDeckItem { + readonly id: string; + readonly title: string; + readonly description: string; + readonly image: string; + readonly badge?: string; +} + +interface SwipeDeckProps extends HTMLAttributes { + readonly items?: ReadonlyArray; + readonly showArrows?: boolean; + readonly showIndicators?: boolean; + readonly parallaxStrength?: number; +} + +const defaultItems: SwipeDeckItem[] = [ + { + id: "welcome", + badge: "Onboarding", + title: "Get set up in under 2 minutes", + description: "Guided steps, quick permissions, and smart defaults to get your team moving.", + image: + "https://images.unsplash.com/photo-1517048676732-d65bc937f952?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "featured", + badge: "Featured", + title: "This week's top product highlights", + description: "Explore fresh launches and editor picks picked for speed, utility, and polish.", + image: + "https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=1400&auto=format&fit=crop", + }, + { + id: "recommendations", + badge: "For You", + title: "Recommendations shaped by your flow", + description: "Personalized cards adapt as you browse, save, and interact with content.", + image: + "https://images.unsplash.com/photo-1551281044-8b4a2f5f6f2d?q=80&w=1400&auto=format&fit=crop", + }, +]; + +export default function SwipeDeck({ + items = defaultItems, + showArrows = true, + showIndicators = true, + parallaxStrength = 36, + className, + ...props +}: Readonly) { + const deckRef = useRef(null); + const cardRefs = useRef>([]); + const rafRef = useRef(null); + + const dragState = useRef({ + pointerId: -1, + startX: 0, + startScrollLeft: 0, + moved: false, + }); + + const [activeIndex, setActiveIndex] = useState(0); + const [isDragging, setIsDragging] = useState(false); + + const hasMultipleCards = items.length > 1; + + const nearestIndex = useCallback((container: HTMLDivElement) => { + const viewportCenter = container.scrollLeft + container.clientWidth / 2; + let closestIndex = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + for (const [index, card] of cardRefs.current.entries()) { + if (!card) { + continue; + } + + const cardCenter = card.offsetLeft + card.offsetWidth / 2; + const distance = Math.abs(cardCenter - viewportCenter); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = index; + } + } + + return closestIndex; + }, []); + + const updateParallax = useCallback(() => { + const container = deckRef.current; + if (!container) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const viewportCenter = containerRect.left + containerRect.width / 2; + + for (const card of cardRefs.current) { + if (!card) { + continue; + } + + const media = card.querySelector("[data-parallax-layer]"); + if (!media) { + continue; + } + + const cardRect = card.getBoundingClientRect(); + const cardCenter = cardRect.left + cardRect.width / 2; + const offset = (cardCenter - viewportCenter) / containerRect.width; + media.style.transform = `translateX(${offset * -parallaxStrength}px) scale(1.08)`; + } + }, [parallaxStrength]); + + const scrollToIndex = useCallback((index: number) => { + const container = deckRef.current; + const card = cardRefs.current[index]; + + if (!container || !card) { + return; + } + + const targetLeft = card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; + container.scrollTo({ left: targetLeft, behavior: "smooth" }); + }, []); + + const onScroll = useCallback(() => { + const container = deckRef.current; + if (!container) { + return; + } + + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + + rafRef.current = requestAnimationFrame(() => { + setActiveIndex(nearestIndex(container)); + updateParallax(); + rafRef.current = null; + }); + }, [nearestIndex, updateParallax]); + + useEffect(() => { + updateParallax(); + + const container = deckRef.current; + if (!container) { + return; + } + + container.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + + return () => { + container.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [onScroll, updateParallax]); + + useEffect(() => { + cardRefs.current = cardRefs.current.slice(0, items.length); + updateParallax(); + }, [items.length, updateParallax]); + + const cards = useMemo( + () => + items.map((item, index) => ( +
{ + cardRefs.current[index] = node; + }} + className="relative w-[82%] shrink-0 snap-center overflow-hidden rounded-2xl border border-black/10 bg-stone-100 shadow-[0_12px_30px_-16px_rgba(0,0,0,0.45)] sm:w-[72%] lg:w-[64%]" + > +
+
+
+ {item.badge && ( + + {item.badge} + + )} +
+ +
+

+ {item.title} +

+

+ {item.description} +

+
+
+ )), + [items], + ); + + return ( +
+
+
{ + if (event.pointerType !== "mouse") { + return; + } + + const container = deckRef.current; + if (!container) { + return; + } + + dragState.current.pointerId = event.pointerId; + dragState.current.startX = event.clientX; + dragState.current.startScrollLeft = container.scrollLeft; + dragState.current.moved = false; + + container.setPointerCapture(event.pointerId); + setIsDragging(true); + }} + onPointerMove={(event) => { + const container = deckRef.current; + if (!container) { + return; + } + + if (!isDragging || dragState.current.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - dragState.current.startX; + if (Math.abs(deltaX) > 3) { + dragState.current.moved = true; + } + + container.scrollLeft = dragState.current.startScrollLeft - deltaX; + }} + onPointerUp={(event) => { + const container = deckRef.current; + if (!container || dragState.current.pointerId !== event.pointerId) { + return; + } + + container.releasePointerCapture(event.pointerId); + setIsDragging(false); + scrollToIndex(nearestIndex(container)); + }} + onPointerCancel={(event) => { + const container = deckRef.current; + if (!container || dragState.current.pointerId !== event.pointerId) { + return; + } + + container.releasePointerCapture(event.pointerId); + setIsDragging(false); + scrollToIndex(nearestIndex(container)); + }} + > + {cards} +
+ + {showArrows && hasMultipleCards && ( + <> + + + + + )} +
+ + {showIndicators && hasMultipleCards && ( +
+ {items.map((item, index) => ( +
+ )} +
+ ); +} diff --git a/content/docs/carousel/swipe-deck.mdx b/content/docs/carousel/swipe-deck.mdx new file mode 100644 index 00000000..11a3da4f --- /dev/null +++ b/content/docs/carousel/swipe-deck.mdx @@ -0,0 +1,39 @@ +--- +title: Swipe Deck +description: A horizontal swipeable card deck with snap scrolling, indicators, arrows, and parallax motion. +author: copilot +labels: ["requires interaction", "touch", "drag", "swipe"] +published: true +--- + + + +## Installation + + +Install dependencies + +```bash +npm install lucide-react +``` + +Run the following command + +It will create a new file called `swipe-deck.tsx` inside the `components/animata/carousel` directory. + +```bash +mkdir -p components/animata/carousel && touch components/animata/carousel/swipe-deck.tsx +``` + +Paste the code + +Open the newly created file and paste the following code: + +```tsx file=/animata/carousel/swipe-deck.tsx +``` + + + +## Credits + +Built by [GitHub Copilot](https://github.com/features/copilot) From d8821da7520934f4f5da88c789ad0b493b4aa4b8 Mon Sep 17 00:00:00 2001 From: leazi99 Date: Fri, 24 Apr 2026 14:43:53 +0545 Subject: [PATCH 2/2] fix(carousel): relax touch action for swipe deck --- animata/carousel/swipe-deck.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/animata/carousel/swipe-deck.tsx b/animata/carousel/swipe-deck.tsx index 92bcc479..16daefc5 100644 --- a/animata/carousel/swipe-deck.tsx +++ b/animata/carousel/swipe-deck.tsx @@ -217,12 +217,12 @@ export default function SwipeDeck({ ref={deckRef} className={cn( "flex snap-x snap-mandatory gap-4 overflow-x-auto px-[9%] pb-2 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", - "touch-pan-y cursor-grab active:cursor-grabbing", + "touch-pan-x cursor-grab active:cursor-grabbing", { "select-none": isDragging, }, )} - style={{ touchAction: "pan-y pinch-zoom" }} + style={{ touchAction: "auto" }} onPointerDown={(event) => { if (event.pointerType !== "mouse") { return;