Apple Cards Carousel
Apple-style scrollable cards carousel with smooth animations and modal overlays. Perfect for React applications requiring elegant content presentation with Next.js integration and TypeScript support.
Powered by
"use client";import React from "react";import { Carousel, Card } from "@/components/ui/shadcn-io/apple-cards-carousel";export default function AppleCardsCarouselDemo() { const cards = data.map((card, index) => ( <Card key={card.src} card={card} index={index} /> )); return ( <div className="w-full h-full py-8"> <h2 className="max-w-7xl pl-4 mx-auto text-lg md:text-2xl font-bold text-neutral-800 dark:text-neutral-200 font-sans"> Get to know your shadcn.io. </h2> <Carousel items={cards} /> </div> );}const DummyContent = () => { return ( <> {[...new Array(3).fill(1)].map((_, index) => { return ( <div key={"dummy-content" + index} className="bg-[#F5F5F7] dark:bg-neutral-800 p-8 md:p-14 rounded-3xl mb-4" > <p className="text-neutral-600 dark:text-neutral-400 text-base md:text-2xl font-sans max-w-3xl mx-auto"> <span className="font-bold text-neutral-700 dark:text-neutral-200"> The first rule of shadcn club is that you boast about shadcn club. </span>{" "} Keep a journal, quickly jot down a grocery list, and take amazing class notes. Want to convert those notes to text? No problem. shadcn/ui is ready to capture every thought. </p> <img src="https://assets.aceternity.com/macbook.png" alt="Macbook mockup from Aceternity UI" height="500" width="500" className="md:w-1/2 md:h-1/2 h-full w-full mx-auto object-contain" /> </div> ); })} </> );};const data = [ { category: "Artificial Intelligence", title: "You can do more with AI.", src: "https://images.unsplash.com/photo-1593508512255-86ab42a8e620?q=80&w=3556&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, }, { category: "Productivity", title: "Enhance your productivity.", src: "https://images.unsplash.com/photo-1531554694128-c4c6665f59c2?q=80&w=3387&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, }, { category: "Product", title: "Launching the new shadcn/ui.", src: "https://images.unsplash.com/photo-1713869791518-a770879e60dc?q=80&w=2333&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, }, { category: "iOS", title: "Photography just got better.", src: "https://images.unsplash.com/photo-1602081957921-9137a5d6eaee?q=80&w=2793&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, },];
"use client";import React, { useEffect, useRef, useState, createContext, useContext,} from "react";import { IconArrowNarrowLeft, IconArrowNarrowRight, IconX,} from "@tabler/icons-react";import { cn } from "@/lib/utils";import { AnimatePresence, motion } from "motion/react";import { useOnClickOutside } from "@/components/ui/kibo-ui/use-on-click-outside";export interface CarouselProps { items: React.JSX.Element[]; initialScroll?: number;}export type Card = { src: string; title: string; category: string; content: React.ReactNode;};export const CarouselContext = createContext<{ onCardClose: (index: number) => void; currentIndex: number;}>({ onCardClose: () => {}, currentIndex: 0,});export const Carousel = ({ items, initialScroll = 0 }: CarouselProps) => { const carouselRef = React.useRef<HTMLDivElement>(null); const [canScrollLeft, setCanScrollLeft] = React.useState(false); const [canScrollRight, setCanScrollRight] = React.useState(true); const [currentIndex, setCurrentIndex] = useState(0); useEffect(() => { if (carouselRef.current) { carouselRef.current.scrollLeft = initialScroll; checkScrollability(); } }, [initialScroll]); const checkScrollability = () => { if (carouselRef.current) { const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current; setCanScrollLeft(scrollLeft > 0); setCanScrollRight(scrollLeft < scrollWidth - clientWidth); } }; const scrollLeft = () => { if (carouselRef.current) { carouselRef.current.scrollBy({ left: -300, behavior: "smooth" }); } }; const scrollRight = () => { if (carouselRef.current) { carouselRef.current.scrollBy({ left: 300, behavior: "smooth" }); } }; const handleCardClose = (index: number) => { if (carouselRef.current) { const cardWidth = isMobile() ? 160 : 224; // (w-40 = 160px, md:w-56 = 224px) const gap = isMobile() ? 4 : 8; const scrollPosition = (cardWidth + gap) * (index + 1); carouselRef.current.scrollTo({ left: scrollPosition, behavior: "smooth", }); setCurrentIndex(index); } }; const isMobile = () => { return window && window.innerWidth < 768; }; return ( <CarouselContext.Provider value={{ onCardClose: handleCardClose, currentIndex }} > <div className="relative w-full"> <div className="flex w-full overflow-x-scroll overscroll-x-auto scroll-smooth py-6 [scrollbar-width:none] md:py-10" ref={carouselRef} onScroll={checkScrollability} > <div className={cn( "absolute right-0 z-[1000] h-auto w-[5%] overflow-hidden bg-gradient-to-l", )} ></div> <div className={cn( "flex flex-row justify-start gap-4 pl-4", "mx-auto max-w-7xl", // remove max-w-4xl if you want the carousel to span the full width of its container )} > {items.map((item, index) => ( <motion.div initial={{ opacity: 0, y: 20, }} animate={{ opacity: 1, y: 0, transition: { duration: 0.5, delay: 0.2 * index, ease: "easeOut", once: true, }, }} key={"card" + index} className="rounded-3xl last:pr-[5%] md:last:pr-[33%]" > {item} </motion.div> ))} </div> </div> <div className="mr-10 flex justify-end gap-2"> <button className="relative z-40 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 disabled:opacity-50" onClick={scrollLeft} disabled={!canScrollLeft} > <IconArrowNarrowLeft className="h-6 w-6 text-gray-500" /> </button> <button className="relative z-40 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 disabled:opacity-50" onClick={scrollRight} disabled={!canScrollRight} > <IconArrowNarrowRight className="h-6 w-6 text-gray-500" /> </button> </div> </div> </CarouselContext.Provider> );};export type CardProps = { card: Card; index: number; layout?: boolean;};export const Card = ({ card, index, layout = false,}: CardProps) => { const [open, setOpen] = useState(false); const containerRef = useRef<HTMLDivElement>(null); const { onCardClose, currentIndex } = useContext(CarouselContext); useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { handleClose(); } } if (open) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = "auto"; } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [open]); useOnClickOutside(containerRef as React.RefObject<HTMLElement>, () => handleClose()); const handleOpen = () => { setOpen(true); }; const handleClose = () => { setOpen(false); onCardClose(index); }; return ( <> <AnimatePresence> {open && ( <div className="fixed inset-0 z-50 h-screen overflow-auto"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 h-full w-full bg-black/80 backdrop-blur-lg" /> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} ref={containerRef} layoutId={layout ? `card-${card.title}` : undefined} className="relative z-[60] mx-auto my-10 h-fit max-w-5xl rounded-3xl bg-white p-4 font-sans md:p-10 dark:bg-neutral-900" > <button className="sticky top-4 right-0 ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-black dark:bg-white" onClick={handleClose} > <IconX className="h-6 w-6 text-neutral-100 dark:text-neutral-900" /> </button> <motion.p layoutId={layout ? `category-${card.title}` : undefined} className="text-base font-medium text-black dark:text-white" > {card.category} </motion.p> <motion.p layoutId={layout ? `title-${card.title}` : undefined} className="mt-4 text-2xl font-semibold text-neutral-700 md:text-5xl dark:text-white" > {card.title} </motion.p> <div className="py-10">{card.content}</div> </motion.div> </div> )} </AnimatePresence> <motion.button layoutId={layout ? `card-${card.title}` : undefined} onClick={handleOpen} className="relative z-10 flex h-48 w-40 flex-col items-start justify-start overflow-hidden rounded-3xl bg-gray-100 md:h-64 md:w-56 dark:bg-neutral-900" > <div className="pointer-events-none absolute inset-x-0 top-0 z-30 h-full bg-gradient-to-b from-black/50 via-transparent to-transparent" /> <div className="relative z-40 p-4 md:p-6"> <motion.p layoutId={layout ? `category-${card.category}` : undefined} className="text-left font-sans text-xs font-medium text-white md:text-sm" > {card.category} </motion.p> <motion.p layoutId={layout ? `title-${card.title}` : undefined} className="mt-1 max-w-xs text-left font-sans text-sm font-semibold [text-wrap:balance] text-white md:text-lg" > {card.title} </motion.p> </div> <BlurImage src={card.src} alt={card.title} fill className="absolute inset-0 z-10 object-cover" /> </motion.button> </> );};export type BlurImageProps = { src: string; alt?: string; className?: string; fill?: boolean; width?: number; height?: number;};export const BlurImage = ({ height, width, src, className, alt, fill, ...rest}: BlurImageProps) => { const [isLoading, setLoading] = useState(true); return ( <img className={cn( "h-full w-full transition duration-300", isLoading ? "blur-sm" : "blur-0", className, )} onLoad={() => setLoading(false)} src={src} width={width} height={height} loading="lazy" decoding="async" alt={alt ? alt : "Background of a beautiful view"} {...rest} /> );};
Installation
npx shadcn@latest add https://www.shadcn.io/registry/apple-cards-carousel.json
npx shadcn@latest add https://www.shadcn.io/registry/apple-cards-carousel.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/apple-cards-carousel.json
bunx shadcn@latest add https://www.shadcn.io/registry/apple-cards-carousel.json
Features
- Smooth horizontal scrolling with momentum-based navigation and scroll indicators
- Modal card expansion with full-screen overlay and backdrop blur effects using Framer Motion
- Responsive design with adaptive card sizing and mobile-optimized touch interactions
- Layout animations with optional shared element transitions between states
- Keyboard navigation with Escape key support and focus management
- TypeScript support with comprehensive type definitions and prop interfaces
- shadcn/ui integration using consistent design tokens and styling patterns
Examples
Interactive Layout Animations
"use client";import React from "react";import { Carousel, Card } from "@/components/ui/shadcn-io/apple-cards-carousel";export default function AppleCardsCarouselInteractiveDemo() { const cards = data.map((card, index) => ( <Card key={card.src} card={card} index={index} layout={true} /> )); return ( <div className="w-full h-full py-8"> <h2 className="max-w-7xl pl-4 mx-auto text-lg md:text-2xl font-bold text-neutral-800 dark:text-neutral-200 font-sans"> Interactive Cards with Layout Animations. </h2> <Carousel items={cards} /> </div> );}const DummyContent = () => { return ( <> {[...new Array(3).fill(1)].map((_, index) => { return ( <div key={"dummy-content" + index} className="bg-[#F5F5F7] dark:bg-neutral-800 p-8 md:p-14 rounded-3xl mb-4" > <p className="text-neutral-600 dark:text-neutral-400 text-base md:text-2xl font-sans max-w-3xl mx-auto"> <span className="font-bold text-neutral-700 dark:text-neutral-200"> Experience smooth layout animations with this interactive variant. </span>{" "} The cards transition seamlessly between their collapsed and expanded states using Framer Motion's layout animations. Click any card to see the effect in action. </p> <img src="https://assets.aceternity.com/macbook.png" alt="Macbook mockup from Aceternity UI" height="500" width="500" className="md:w-1/2 md:h-1/2 h-full w-full mx-auto object-contain" /> </div> ); })} </> );};const data = [ { category: "Animation", title: "Smooth layout transitions.", src: "https://images.unsplash.com/photo-1518173946687-a4c8892bbd9f?q=80&w=3387&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, }, { category: "Interactive", title: "Enhanced user experience.", src: "https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?q=80&w=3388&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, }, { category: "Design", title: "Beautiful card layouts.", src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=3540&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", content: <DummyContent />, },];
"use client";import React, { useEffect, useRef, useState, createContext, useContext,} from "react";import { IconArrowNarrowLeft, IconArrowNarrowRight, IconX,} from "@tabler/icons-react";import { cn } from "@/lib/utils";import { AnimatePresence, motion } from "motion/react";import { useOnClickOutside } from "@/components/ui/kibo-ui/use-on-click-outside";export interface CarouselProps { items: React.JSX.Element[]; initialScroll?: number;}export type Card = { src: string; title: string; category: string; content: React.ReactNode;};export const CarouselContext = createContext<{ onCardClose: (index: number) => void; currentIndex: number;}>({ onCardClose: () => {}, currentIndex: 0,});export const Carousel = ({ items, initialScroll = 0 }: CarouselProps) => { const carouselRef = React.useRef<HTMLDivElement>(null); const [canScrollLeft, setCanScrollLeft] = React.useState(false); const [canScrollRight, setCanScrollRight] = React.useState(true); const [currentIndex, setCurrentIndex] = useState(0); useEffect(() => { if (carouselRef.current) { carouselRef.current.scrollLeft = initialScroll; checkScrollability(); } }, [initialScroll]); const checkScrollability = () => { if (carouselRef.current) { const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current; setCanScrollLeft(scrollLeft > 0); setCanScrollRight(scrollLeft < scrollWidth - clientWidth); } }; const scrollLeft = () => { if (carouselRef.current) { carouselRef.current.scrollBy({ left: -300, behavior: "smooth" }); } }; const scrollRight = () => { if (carouselRef.current) { carouselRef.current.scrollBy({ left: 300, behavior: "smooth" }); } }; const handleCardClose = (index: number) => { if (carouselRef.current) { const cardWidth = isMobile() ? 160 : 224; // (w-40 = 160px, md:w-56 = 224px) const gap = isMobile() ? 4 : 8; const scrollPosition = (cardWidth + gap) * (index + 1); carouselRef.current.scrollTo({ left: scrollPosition, behavior: "smooth", }); setCurrentIndex(index); } }; const isMobile = () => { return window && window.innerWidth < 768; }; return ( <CarouselContext.Provider value={{ onCardClose: handleCardClose, currentIndex }} > <div className="relative w-full"> <div className="flex w-full overflow-x-scroll overscroll-x-auto scroll-smooth py-6 [scrollbar-width:none] md:py-10" ref={carouselRef} onScroll={checkScrollability} > <div className={cn( "absolute right-0 z-[1000] h-auto w-[5%] overflow-hidden bg-gradient-to-l", )} ></div> <div className={cn( "flex flex-row justify-start gap-4 pl-4", "mx-auto max-w-7xl", // remove max-w-4xl if you want the carousel to span the full width of its container )} > {items.map((item, index) => ( <motion.div initial={{ opacity: 0, y: 20, }} animate={{ opacity: 1, y: 0, transition: { duration: 0.5, delay: 0.2 * index, ease: "easeOut", once: true, }, }} key={"card" + index} className="rounded-3xl last:pr-[5%] md:last:pr-[33%]" > {item} </motion.div> ))} </div> </div> <div className="mr-10 flex justify-end gap-2"> <button className="relative z-40 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 disabled:opacity-50" onClick={scrollLeft} disabled={!canScrollLeft} > <IconArrowNarrowLeft className="h-6 w-6 text-gray-500" /> </button> <button className="relative z-40 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 disabled:opacity-50" onClick={scrollRight} disabled={!canScrollRight} > <IconArrowNarrowRight className="h-6 w-6 text-gray-500" /> </button> </div> </div> </CarouselContext.Provider> );};export type CardProps = { card: Card; index: number; layout?: boolean;};export const Card = ({ card, index, layout = false,}: CardProps) => { const [open, setOpen] = useState(false); const containerRef = useRef<HTMLDivElement>(null); const { onCardClose, currentIndex } = useContext(CarouselContext); useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { handleClose(); } } if (open) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = "auto"; } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [open]); useOnClickOutside(containerRef as React.RefObject<HTMLElement>, () => handleClose()); const handleOpen = () => { setOpen(true); }; const handleClose = () => { setOpen(false); onCardClose(index); }; return ( <> <AnimatePresence> {open && ( <div className="fixed inset-0 z-50 h-screen overflow-auto"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 h-full w-full bg-black/80 backdrop-blur-lg" /> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} ref={containerRef} layoutId={layout ? `card-${card.title}` : undefined} className="relative z-[60] mx-auto my-10 h-fit max-w-5xl rounded-3xl bg-white p-4 font-sans md:p-10 dark:bg-neutral-900" > <button className="sticky top-4 right-0 ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-black dark:bg-white" onClick={handleClose} > <IconX className="h-6 w-6 text-neutral-100 dark:text-neutral-900" /> </button> <motion.p layoutId={layout ? `category-${card.title}` : undefined} className="text-base font-medium text-black dark:text-white" > {card.category} </motion.p> <motion.p layoutId={layout ? `title-${card.title}` : undefined} className="mt-4 text-2xl font-semibold text-neutral-700 md:text-5xl dark:text-white" > {card.title} </motion.p> <div className="py-10">{card.content}</div> </motion.div> </div> )} </AnimatePresence> <motion.button layoutId={layout ? `card-${card.title}` : undefined} onClick={handleOpen} className="relative z-10 flex h-48 w-40 flex-col items-start justify-start overflow-hidden rounded-3xl bg-gray-100 md:h-64 md:w-56 dark:bg-neutral-900" > <div className="pointer-events-none absolute inset-x-0 top-0 z-30 h-full bg-gradient-to-b from-black/50 via-transparent to-transparent" /> <div className="relative z-40 p-4 md:p-6"> <motion.p layoutId={layout ? `category-${card.category}` : undefined} className="text-left font-sans text-xs font-medium text-white md:text-sm" > {card.category} </motion.p> <motion.p layoutId={layout ? `title-${card.title}` : undefined} className="mt-1 max-w-xs text-left font-sans text-sm font-semibold [text-wrap:balance] text-white md:text-lg" > {card.title} </motion.p> </div> <BlurImage src={card.src} alt={card.title} fill className="absolute inset-0 z-10 object-cover" /> </motion.button> </> );};export type BlurImageProps = { src: string; alt?: string; className?: string; fill?: boolean; width?: number; height?: number;};export const BlurImage = ({ height, width, src, className, alt, fill, ...rest}: BlurImageProps) => { const [isLoading, setLoading] = useState(true); return ( <img className={cn( "h-full w-full transition duration-300", isLoading ? "blur-sm" : "blur-0", className, )} onLoad={() => setLoading(false)} src={src} width={width} height={height} loading="lazy" decoding="async" alt={alt ? alt : "Background of a beautiful view"} {...rest} /> );};
Demonstrates smooth layout transitions between card states with enhanced animations.
Use Cases
This free open source React component works well for:
- Portfolio galleries - Showcase projects with expandable detail views built with Next.js
- Product catalogs - Display items with rich content overlays using TypeScript
- Blog previews - Feature articles with full-screen reading experience
- Team showcases - Present team members with detailed profiles and Tailwind CSS styling
- Case studies - Highlight work samples with comprehensive project details
API Reference
Carousel
Prop | Type | Default | Description |
---|---|---|---|
items | JSX.Element[] | required | Array of Card components to display in the carousel |
initialScroll | number | 0 | Initial scroll position for the carousel |
Card
Prop | Type | Default | Description |
---|---|---|---|
card | Card | required | Card data object with src, title, category, and content |
index | number | required | Card index for scroll positioning and animations |
layout | boolean | false | Enable layout animations for smooth state transitions |
Card Data Object
Property | Type | Description |
---|---|---|
src | string | Image URL for the card background |
title | string | Main heading text displayed on the card |
category | string | Category label shown above the title |
content | React.ReactNode | Full content displayed in the modal overlay |
BlurImage
Prop | Type | Default | Description |
---|---|---|---|
src | string | required | Image source URL |
alt | string | "Background of a beautiful view" | Alternative text for accessibility |
className | string | undefined | Additional CSS classes for styling |
fill | boolean | false | Whether image should fill its container |
width | number | undefined | Image width in pixels |
height | number | undefined | Image height in pixels |
Implementation Notes
- Component requires
@tabler/icons-react
andmotion
libraries for full functionality - Cards automatically handle modal state management and scroll positioning
- Uses
useOnClickOutside
hook for modal dismissal - ensure hook is available in project - Responsive breakpoints adjust card sizes and navigation behavior
- Modal overlays disable body scrolling and provide keyboard navigation
- Layout animations require
layout={true}
prop for optimal performance - Compatible with both light and dark themes through CSS custom properties
Animated Tooltip
Dynamic avatar group with spring-animated tooltips on hover. Perfect for React applications requiring team displays with Next.js integration and TypeScript support.
Apple Hello Effect
Handwriting animation component for React and Next.js applications. Built with Motion, TypeScript support, and Tailwind CSS styling featuring Apple-inspired text animations with multilingual support and customizable timing.