For dark mode, add the dark class to your <html> element:
Optional
<html class="dark">
Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for one inside Claude Code, Codex, or Cursor and it installs the component you pick. Works with any AI Canvas account, free or premium.
Interactive Card Stack is a loose pile of five photo cards scattered around a focused center, each a polaroid-style print that breathes gently on its own slow loop. Click any card to pull it to the front, drag the front card sideways to cycle, or use the arrow keys, and the rest spring into a new arrangement around it. Motion drives the scatter, the breathing, and the focus shuffle; Tailwind covers the layout, and it honours reduced-motion settings. Drop in your own photos and titles, and set an optional link per card. It suits image galleries, moodboards, and portfolio sections where the photo itself is the point.
Built with
MotionTailwind CSS
Frequently asked questions
Is Interactive Card Stack free to use?
Yes. Interactive Card Stack is part of the free AI Canvas library and is open source under the MIT license, so you can use it in personal and commercial projects.
How do I install Interactive Card Stack?
One command: npx shadcn@latest add https://aicanvas.me/r/interactive-card-stack.json, free with a free AI Canvas account. Or connect the AI Canvas MCP server and ask your AI editor to install Interactive Card Stack for you. You can also copy the source straight from the Code tab, no account needed. It works in any React project with Tailwind CSS.
What is Interactive Card Stack built with?
Interactive Card Stack is built with React and TypeScript, using Motion and Tailwind CSS. It ships with both light and dark styling.
Where would I use Interactive Card Stack?
Common uses include Image gallery, Moodboard, and Portfolio. Like every AI Canvas component, it is self-contained and drops into any React project.
Can I remix Interactive Card Stack with AI?
Yes. Interactive Card Stack ships with one comprehensive AI prompt written against the real source code. Open "Remix with AI" on this page to read and copy it into Claude, Cursor, ChatGPT, or any AI tool. Prompts are for remixing your own variation; for the exact component, install it with the one-command CLI.
One comprehensive prompt, written against the real source code. Works in Claude, Cursor, ChatGPT, or any AI tool you use.
This prompt is for remixing. Use it to build your own variation of Interactive Card Stack. Results depend on the model you use, and no prompt in the world is 100% exact.
Want the exact component?
One command installs it, pixel-perfect. Copy, paste into your project, done.
Verify this project has Tailwind CSS v4, TypeScript, and React set up. If anything is missing, install via the shadcn CLI:
npx shadcn@latest init
---
Create a React client component named InteractiveCardStack at components-workspace/interactive-card-stack/index.tsx. Write it as a single self-contained file. Inline everything. Do not extract helper hooks, utility functions, or separate files. 'use client' at the top. Add the comment // npm install framer-motion right under it. No 'any' types, no type assertions beyond 'as const' on spring objects. Use Framer Motion for all animation. The component must work in light and dark mode, be responsive to 320px, respect reduced motion, and be accessible.
Export a single default function: export default function InteractiveCardStack().
What it looks like and does:
A loose pile of 5 polaroid-style photo cards with mixed aspect ratios (3 portrait, 2 landscape), scattered around a focused center card. The front card sits upright; the other 4 fan out behind it at varied offsets, rotations, and scales. The whole pile breathes softly when idle (each card bobs and rocks on its own period, out of phase). Click any back card to bring it to the front; the rest reshuffle around it. Drag the focused card left or right to cycle focus by one. Arrow keys left and right cycle focus, but ONLY while keyboard focus is inside the widget. A row of 5 dots underneath shows focus position (active dot is wider and brighter), with a single hint line: "drag, click, or use the arrow keys".
Types:
interface Card { id: number; orientation: 'portrait' | 'landscape'; title?: string; image: string }
interface Slot { x: number; y: number; rotate: number; scale: number; zIndex: number }
Data (exact 5 entries, keep ids, orientations, titles, and image URLs):
0: portrait, title "Scarlet macaw", image "https://ik.imagekit.io/aitoolkit/interactive-card-stack/scarlet-macaw-rainforest-branch.jpg"
1: landscape, title "Toco toucan", image "https://ik.imagekit.io/aitoolkit/interactive-card-stack/toco-toucan-rainforest-canopy.jpg"
2: portrait, title "Blue and gold macaw", image "https://ik.imagekit.io/aitoolkit/interactive-card-stack/blue-and-gold-macaw-jungle-perch.jpg"
3: landscape, title "Green-headed tanager", image "https://ik.imagekit.io/aitoolkit/interactive-card-stack/green-headed-tanager-mossy-branch.jpg"
4: portrait, title "Northern mockingbird", image "https://ik.imagekit.io/aitoolkit/interactive-card-stack/northern-mockingbird-autumn-woodland.jpg"
Orientation travels with the card id (not the slot). Each photo is pre-cropped to its card aspect (portrait 4:5, landscape 16:10), so a plain object-cover fill needs no further art direction. Every card renders a title strip; the title is also the card's accessible name. Because the visible title names each photo, the img alt is empty (alt="") so screen readers do not hear the name twice.
Slot tables (slot 0 is the focused front; slots 1-4 scatter behind):
SLOTS_DESKTOP:
0: { x: 0, y: 0, rotate: 1.5, scale: 1.00, zIndex: 50 }
1: { x: 160, y: -30, rotate: 12, scale: 0.90, zIndex: 40 }
2: { x: -150, y: -10, rotate: -14, scale: 0.89, zIndex: 30 }
3: { x: 90, y: 70, rotate: 8, scale: 0.86, zIndex: 20 }
4: { x: -110, y: 60, rotate: -9, scale: 0.84, zIndex: 10 }
SLOTS_MOBILE (tighter spread so all 5 stay visible under 640px):
0: { x: 0, y: 0, rotate: 1, scale: 1.00, zIndex: 50 }
1: { x: 90, y: -15, rotate: 6, scale: 0.92, zIndex: 40 }
2: { x: -85, y: 20, rotate: -7, scale: 0.91, zIndex: 30 }
3: { x: 55, y: 35, rotate: 4, scale: 0.88, zIndex: 20 }
4: { x: -55, y: 25, rotate: -4.5, scale: 0.87, zIndex: 10 }
Module-scope constants (hoist these so their identity stays stable across renders):
- SPRING = { type: 'spring' as const, stiffness: 280, damping: 26 } for runtime transitions.
- MOUNT_SPRING = { type: 'spring' as const, stiffness: 200, damping: 22 } for the entrance.
- STAGGER_S = 0.08
- Breathing keyframes: BREATH_Y_FOCUS = [0,-14,0,10,0], BREATH_Y_REST = [0,-8,0,6,0], BREATH_ROTATE_FOCUS = [0,1.5,0,-1.5,0], BREATH_ROTATE_REST = [0,1,0,-1,0].
- SHADOW_FOCUS = '0 24px 48px rgba(0,0,0,0.28), 0 6px 14px rgba(0,0,0,0.16)', SHADOW_REST = '0 12px 28px rgba(0,0,0,0.18), 0 4px 8px rgba(0,0,0,0.12)'.
- RING = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#A8B94D]' (visible focus ring on every interactive control).
- TITLE_STYLE and CHIP_STYLE as CSSProperties (see title strip and open button below).
State (useState): order: number[] initial [0,1,2,3,4] (order[slotIndex] = cardId, order[0] is the focused front); mounted: boolean initial false (flips true after first paint so the entrance stagger runs once); isMobile: boolean initial false.
Refs: containerRef on the inner wrapper (scopes the arrow keys); dragDelta = useRef(0) to tell a tap from a drag.
Reduced motion: const reduceMotion = useReducedMotion() from 'framer-motion'.
Effects (all cleaned up):
1. setMounted(true) on mount.
2. matchMedia('(min-width: 640px)'): setIsMobile(!mq.matches) on mount and on 'change'; remove the listener on unmount.
3. window 'keydown' handler that is SCOPED: bail out unless event.key is ArrowRight or ArrowLeft AND containerRef.current contains document.activeElement. Only then preventDefault and step(+1 / -1). This is critical: never call preventDefault on arrow keys for the whole page, or you break the host page's caret movement, scrolling, and native controls.
Callbacks:
- focusCard(cardId): setOrder(prev => { const idx = prev.indexOf(cardId); if (idx <= 0) return prev; return [cardId, ...prev.slice(0, idx), ...prev.slice(idx + 1)] }).
- step(dir: 1 | -1): setOrder(prev => dir === 1 ? [...prev.slice(1), prev[0]] : [prev[prev.length-1], ...prev.slice(0, prev.length-1)]).
- handleDragEnd(_, info: PanInfo): distance = info.offset.x, velocity = info.velocity.x. If distance < -80 || velocity < -400 then step(1) (a leftward drag advances to the next card). Else if distance > 80 || velocity > 400 then step(-1) (a rightward drag goes to the previous card).
Layout:
Root: <div className="flex min-h-screen w-full items-center justify-center bg-[#E8E8DF] px-4 dark:bg-[#1A1A19]">.
Inner wrapper carries containerRef: <div ref={containerRef} className="relative flex w-full max-w-4xl flex-col items-center gap-10 py-12">.
Stage: <div role="group" aria-label="Interactive card stack" aria-describedby="ics-hint" className="relative flex w-full select-none items-center justify-center overflow-hidden" style={{ perspective: '1400px', height: 'clamp(320px, 44vw, 440px)' }}>. The overflow-hidden is required so the scattered, rotated cards never spill past the container and create a horizontal page scrollbar at narrow widths.
Render each card (map over CARDS, keep key={card.id}, do not sort):
- const slotIndex = order.indexOf(card.id); const slot = (isMobile ? SLOTS_MOBILE : SLOTS_DESKTOP)[slotIndex]; const isFocus = slotIndex === 0; const isLandscape = card.orientation === 'landscape'.
- transition = (!reduceMotion && !mounted) ? { ...MOUNT_SPRING, delay: slotIndex * STAGGER_S } : SPRING.
- widthClass by orientation and breakpoint: landscape -> isMobile ? 'w-[clamp(200px,60vw,260px)]' : 'w-[clamp(220px,28vw,320px)]'; portrait -> isMobile ? 'w-[clamp(130px,42vw,180px)]' : 'w-[clamp(160px,20vw,220px)]'.
- breathing under reduced motion is OFF: breathY = reduceMotion ? 0 : (isFocus ? BREATH_Y_FOCUS : BREATH_Y_REST); breathRotate = reduceMotion ? 0 : (isFocus ? BREATH_ROTATE_FOCUS : BREATH_ROTATE_REST).
- zIndex = slot.zIndex. The z-index follows the slot, so a flicked card drops behind the others the instant it is released and slides under them on the way to the rear. Do not add a lingering "stay on top then drop" elevation, it reads as an awkward late z-index swap.
OUTER motion.div (positioning only, plus the accessibility surface):
- key={card.id}, tabIndex={0}, className={`absolute ${widthClass} rounded-[18px] outline-none ${isFocus ? '' : RING}`}.
- role: ONLY non-focused cards are buttons. role={isFocus ? undefined : 'button'}. The focused card has no activation action (it is dragged, not clicked), so it must NOT be a role="button" that does nothing.
- aria-label: isFocus ? `${card.title}, current. Drag or use the arrow keys to change cards.` : `Show ${card.title}`.
- onClick and onKeyDown are present ONLY when !isFocus: onClick preventDefault then (if Math.abs(dragDelta.current) < 8) focusCard(card.id); onKeyDown focuses on Enter or Space (with preventDefault). When isFocus, both are undefined.
- onPointerDown resets dragDelta.current = 0. drag={isFocus ? 'x' : false}, dragConstraints={{ left: 0, right: 0 }}, dragElastic={0.6}, onDrag sets dragDelta.current = info.offset.x, onDragEnd={handleDragEnd}.
- style={{ cursor: isFocus ? 'grab' : 'pointer', zIndex }}.
- initial={reduceMotion ? false : { opacity: 0, scale: 0.5, y: 60 }}; animate={{ x: slot.x, y: slot.y, rotate: slot.rotate, scale: slot.scale, opacity: 1 }}; transition={transition}; whileTap={isFocus ? { cursor: 'grabbing' } : undefined}.
MIDDLE motion.div (breathing loop + polaroid chrome):
- className="relative flex w-full flex-col rounded-[18px] ring-1 ring-black/[0.08] dark:ring-white/[0.12]".
- style={{ backgroundColor: '#FFFFFF', padding: '10px', boxShadow: isFocus ? SHADOW_FOCUS : SHADOW_REST }} (uniform 10px padding on all cards).
- animate={{ y: breathY, rotate: breathRotate }}; transition={ reduceMotion ? { duration: 0 } : { duration: 7 + card.id * 0.6, repeat: Infinity, ease: 'easeInOut' } }. Durations are 7.0s / 7.6s / 8.2s / 8.8s / 9.4s for ids 0-4.
- First child: a dark-mode paper overlay span, aria-hidden, className "pointer-events-none absolute inset-0 rounded-[18px] dark:bg-[#F5F5F0]" (light mode shows the white backgroundColor; dark mode shifts the paper to a creamy off-white).
INNER content:
- Title strip (rendered when card.title is set, which is all 5): a relative div, style {{ position: 'relative', padding: '14px 12px 8px 12px' }}. Inside it:
- A <p style={TITLE_STYLE}>{card.title}</p>. TITLE_STYLE: margin 0, paddingRight 34 (reserves room for the open button so the title never reflows), fontFamily 'var(--font-sans, sans-serif)', fontWeight 600, fontSize '15px', lineHeight 1.3, letterSpacing '-0.01em', color '#1a1a19', textAlign 'left', and a 2-line clamp (display '-webkit-box', WebkitLineClamp 2, WebkitBoxOrient 'vertical', overflow 'hidden', minHeight '2.6em').
- The top-right chip, rendered ONLY when isFocus (so there are never hidden, focusable controls stacked behind the front card). It is the developer seam for a per-card link and is NOT wired to anything in the demo. Behaviour depends on whether that card has an optional href field: if card.href is set, render a motion.a (real link, target="_blank", rel="noopener noreferrer", aria-label `Open ${card.title} in a new tab`, focusable, with the RING focus style); if not, render a decorative motion.span with aria-hidden (no role, not focusable, no navigation) so pressing it does nothing. The demo cards set no href, so the chip is decorative. Both variants share the exact same visuals: position absolute top:10 right:10 lineHeight:0, initial {{ opacity: 0, scale: 0.85 }}, animate {{ opacity: 1, scale: 1 }}, transition SPRING, whileHover {{ scale: 1.1 }}, whileTap {{ scale: 0.9 }}, onPointerDown stopPropagation so it never starts a drag, and the same chip inside: a <span style={CHIP_STYLE}> (clamp(28px,7.5vw,36px) square, backgroundColor '#141312', borderRadius 11, boxShadow '0 8px 18px rgba(0,0,0,0.42), 0 3px 6px rgba(0,0,0,0.30)', flex-centered) holding a 56% arrow svg (viewBox 0 0 24 24, stroke '#F5F1E8', strokeWidth 1, round caps and joins, paths "M7 17 L17 7" and "M9 7 H17 V15"). Hoist that chip to a shared element so both branches reuse it. Add href?: string to the Card interface.
- Image well: <div className={`relative w-full overflow-hidden ${isLandscape ? 'aspect-[16/10]' : 'aspect-[4/5]'}`} style={{ borderRadius: 10 }}>. Inside: <img src={card.image} alt="" loading={isFocus ? 'eager' : 'lazy'} fetchPriority={isFocus ? 'high' : 'low'} draggable={false} className="absolute inset-0 h-full w-full object-cover" />. The focused image is the LCP hero, so it loads eagerly at high priority; the rest defer.
After the stage, a visually-hidden live region that announces the front-card change to assistive tech: <p className="sr-only" aria-live="polite">{frontTitle ? `${frontTitle} in focus` : ''}</p>, where frontTitle = CARDS.find(c => c.id === order[0])?.title ?? ''.
Dot indicator + hint:
<div className="flex flex-col items-center gap-3"> with an inner row <div className="flex items-center gap-1.5"> of 5 dot buttons. Each dot is a plain <button type="button"> sized 24x24 (a WCAG 2.5.8 hit target) that flex-centers a small visible pill: className={`flex items-center justify-center rounded-full outline-none ${RING}`}, style {{ width: 24, height: 24 }}, aria-label={`Show ${card.title}`}, aria-current={isCurrent ? true : undefined}, onClick focusCard(card.id), where isCurrent = order[0] === card.id. Inside it a motion.span: className "block rounded-full bg-[#21211F] dark:bg-[#FAFAF0]", animate {{ width: isCurrent ? 20 : 5, opacity: isCurrent ? 1 : 0.3 }}, transition { type: 'spring', stiffness: 400, damping: 30 }, style {{ height: 5 }}. The visible pill stays 5/20px while the clickable button is a full 24x24.
Then the hint: <p id="ics-hint" className="text-sm tracking-wide text-[#666662] dark:text-[#9E9E98]">drag, click, or use the arrow keys</p>. Use text-sm (14px), not text-xs, so the instruction is legible on phones.
Accessibility and motion summary:
- Only back cards are buttons. The focused card is a labelled, draggable region with no fake action. The top-right chip is a real link only when its card has an href, otherwise a decorative aria-hidden affordance; only one chip exists at a time (on the focused card), never a dead button announced as actionable.
- Every interactive control shows a visible focus ring (RING).
- Arrow keys are scoped to the widget, never hijacked globally.
- Under prefers-reduced-motion the breathing loop and the scale/translate entrance are both disabled.
- The stage clips overflow so nothing spills horizontally on small screens.
Theme: works in light and dark. Container #E8E8DF / #1A1A19. Card paper is #FFFFFF in light and shifts to #F5F5F0 in dark via the overlay span. The ring flips from black/8 to white/12. Title color stays #1a1a19 in both themes. No em-dashes in any user-visible copy.
TypeScript strict, no any. Clean up every effect (matchMedia listener, keydown listener). Keep the order-array pattern: never mutate it, always return a new array from setOrder.