Carousels

Stacked Peek Carousel

A card stack carousel where the layers behind peek out, and the next card moves up to take the front on each swipe.

The current card sits centered, with the 2–3 cards behind it shrunk and offset upward so their edges peek out. On a drag or a button press the front card flies out with a rotation while the card behind springs up to take its place, with wraparound looping and dot indicators — well suited to browsing curated content such as trip cards or product cards.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add stacked-peek-carousel.en

New project or installation trouble? Start here →

Stacked Peek Carousel is a React stacked card carousel built with Tailwind CSS and Motion. The cards behind sit smaller and shifted up so their edges peek out; dragging or pressing a button sends the front card flying off with a rotation while the next one springs forward. The arrow icons come from lucide-react.

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

2
16
0.06
Active edited usageDefault props
<StackedPeekCarousel/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

When to use

  • Featured travel, event or course cards, presenting one item at a time.
  • Recommended or new products on a store homepage, swiped by drag on mobile.
  • A compact showcase of portfolio pieces or customer stories where a horizontal list would not fit.
  • Feature cards on an app landing page, with a caption below that changes along with the card.
  • Not a good fit: for autoplay with segmented progress bars, use Vertical Story Carousel.

Installation

npx webberui@latest add https://webberui.com/r/stacked-peek-carousel.en.json

Or, once registries are configured in components.json, install it as @webberui/stacked-peek-carousel.

Usage

import { StackedPeekCarousel } from "@/components/ui/stacked-peek-carousel";

<StackedPeekCarousel
  items={[
    <TripCard key="kyoto" title="Kyoto · Arashiyama" />,
    <TripCard key="swiss" title="Switzerland · Jungfrau" />,
    <TripCard key="palau" title="Palau · Rock Islands" />,
  ]}
  peek={2}
  onIndexChange={(index) => console.log("now on card", index + 1)}
/>

Each item fills the stage area (h-72 w-64 by default, overridable with stageClassName), so give the card itself h-full w-full and a rounded background.

Examples

Product recommendation cards

items accepts any array of ReactNodes, each with a key. Use stageClassName to size the card stage and give the cards themselves h-full w-full.

import { StackedPeekCarousel } from "@/components/ui/stacked-peek-carousel";

const products = [
  { id: "kettle", name: "Pour-over kettle", price: "$42", image: "/products/kettle.jpg" },
  { id: "grinder", name: "Hand coffee grinder", price: "$79", image: "/products/grinder.jpg" },
  { id: "dripper", name: "Ceramic dripper", price: "$22", image: "/products/dripper.jpg" },
];

<StackedPeekCarousel
  stageClassName="h-96 w-72"
  items={products.map((p) => (
    <a
      key={p.id}
      href={`/products/${p.id}`}
      className="flex h-full w-full flex-col overflow-hidden rounded-2xl border bg-white shadow-md"
    >
      <img src={p.image} alt="" className="h-3/4 w-full object-cover" draggable={false} />
      <div className="p-4">
        <p className="font-medium">{p.name}</p>
        <p className="text-sm text-neutral-500">{p.price}</p>
      </div>
    </a>
  ))}
/>

Syncing a caption

The component manages the current index internally. Sync it to your own state with onIndexChange to show a matching caption below the carousel; passing setIndex directly keeps the callback reference stable.

"use client";

import * as React from "react";
import { StackedPeekCarousel } from "@/components/ui/stacked-peek-carousel";

const features = [
  { id: "sync", title: "Multi-device sync", body: "Your data stays in sync across phone, tablet and desktop." },
  { id: "share", title: "Shared lists", body: "Invite family members to edit the same list." },
  { id: "remind", title: "Location reminders", body: "Get a reminder when you arrive at a chosen place." },
];

export function FeatureTour() {
  const [index, setIndex] = React.useState(0);

  return (
    <div className="space-y-4 text-center">
      <StackedPeekCarousel
        onIndexChange={setIndex}
        items={features.map((f) => (
          <div key={f.id} className="flex h-full w-full items-center justify-center rounded-2xl bg-neutral-900 text-lg text-white">
            {f.title}
          </div>
        ))}
      />
      <p className="text-sm text-neutral-600">{features[index].body}</p>
    </div>
  );
}

No looping, deeper stack

With loop={false} the carousel stops at the last card and the arrows become disabled at either end; peek, offset and scaleStep set how many layers peek out behind, their spacing and how much each one shrinks.

<StackedPeekCarousel
  items={cards}
  loop={false}
  showDots={false}
  peek={3}
  offset={12}
  scaleStep={0.05}
/>

Props

PropTypeDefaultDescription
itemsReact.ReactNode[]—The list of carousel card content; each node fills the stage area
peeknumber2Number of stacked layers peeking out behind
offsetnumber16Distance each stacked layer is offset upward (px)
scaleStepnumber0.06Scale reduction per stacked layer (0–1)
loopbooleantrueWhether to wrap around at the start and the end
showDotsbooleantrueWhether to show the dot indicators
showArrowsbooleantrueWhether to show the left and right switch buttons
stageClassNamestring—Sizing styles of the stage (the card stack area), h-72 w-64 by default
onIndexChange(index: number) => void—Callback fired when the current index changes
classNamestring—Forwarded to the outermost container

Accessibility

  • The stage is marked role="region" with aria-roledescription="carousel", and the total number of cards is stated in the label
  • The current position is announced live through an aria-live="polite" block as "card X of N"
  • The peeking cards behind are hidden from assistive technology with aria-hidden and have interaction disabled with pointer-events-none
  • The left and right buttons and the dots are all native buttons that can be focused and operated by keyboard, each carrying an aria-label for "previous / next / go to card N", with the current dot marked aria-current
  • When the user has "reduce motion" enabled at the system level, the drag gesture and the spring animation are disabled and switching completes instantly

FAQ

Does it work with the Next.js App Router?

Yes. The component file already starts with "use client", so import it directly from a Server Component page. Because items is an array of React nodes and props like onIndexChange are functions, the wrapper that passes a function needs to be a Client Component.

Can I set the current card from outside?

No. There is no controlled index prop and no autoplay; the current index is managed internally, and you can only observe changes through onIndexChange. Users switch cards by dragging, the arrow buttons or the dots.

Why does dragging to the right also go to the next card?

Once the drag offset plus a velocity bonus crosses the threshold, the carousel always advances to the next card; the drag direction only decides which side the front card flies off to. Use the left arrow or the dots to go back.

Can it be used with a keyboard?

The arrow buttons and dots are native buttons, reachable with Tab and operated with Enter / Space; the stage itself has no arrow-key handler. With "reduce motion" enabled, dragging and the spring animation are disabled and switching is instant.

What are the dependencies?

It depends on motion, lucide-react (arrow icons), clsx and tailwind-merge. Without the CLI, copy the source from the Manual tab in the installation section.