fold-carousel

Fold Carousel

A carousel that folds from one slide to the next, like a two-panel phone. The image stays flat and the frame changes shape around it.

Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Slide 1 of 4

Installation

Needs a project set up with shadcn. Adds components/ui/fold-carousel and installs motion, lucide-react, and cn.

npx shadcn@latest add Kareem-AEz/iphone-duo-fold-carousel/fold-carousel

Usage

import type { ReactNode } from "react";

import {
  FoldCarousel,
  FoldCarouselFrame,
  FoldCarouselNext,
  FoldCarouselPrevious,
} from "@/components/ui/fold-carousel/fold-carousel";

export function DoorExample({ slides }: { slides: ReactNode[] }) {
  return (
    <FoldCarousel
      slides={slides}
      aria-label="Landscapes"
      className="flex flex-col items-center gap-20"
    >
      <FoldCarouselFrame panelWidth="min(20rem, 42vw)" />
      <div className="flex gap-2">
        <FoldCarouselPrevious />
        <FoldCarouselNext />
      </div>
    </FoldCarousel>
  );
}

Examples

Book

The next slide lies underneath and the fold uncovers it, like turning a page.

Blue desert mountain ridges under a heavy cloud
Pine forest silhouette against hazy mountains
Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Slide 1 of 4
import type { ReactNode } from "react";

import {
  FoldCarousel,
  FoldCarouselFrame,
  FoldCarouselNext,
  FoldCarouselPrevious,
} from "@/components/ui/fold-carousel/fold-carousel";

export function BookExample({ slides }: { slides: ReactNode[] }) {
  return (
    <FoldCarousel
      slides={slides}
      aria-label="Landscapes"
      className="flex flex-col items-center gap-14"
    >
      <FoldCarouselFrame variant="book" panelWidth="min(14rem, 38vw)" />
      <div className="flex gap-2">
        <FoldCarouselPrevious />
        <FoldCarouselNext />
      </div>
    </FoldCarousel>
  );
}

Custom controls, no loop

useFoldCarousel drives your own dots. Without a loop, the buttons disable at either end.

Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Slide 1 of 4
"use client";

import type { ReactNode } from "react";

import {
  FoldCarousel,
  FoldCarouselFrame,
  FoldCarouselNext,
  FoldCarouselPrevious,
} from "@/components/ui/fold-carousel/fold-carousel";
import { useFoldCarousel } from "@/components/ui/fold-carousel/use-fold-carousel";

export function ControlsExample({ slides }: { slides: ReactNode[] }) {
  return (
    <FoldCarousel
      slides={slides}
      loop={false}
      aria-label="Landscapes"
      className="flex flex-col items-center gap-14"
    >
      <FoldCarouselFrame panelWidth="min(14rem, 38vw)" />
      <div className="flex items-center gap-2">
        <FoldCarouselPrevious size="icon-sm" />
        <Dots />
        <FoldCarouselNext size="icon-sm" />
      </div>
    </FoldCarousel>
  );
}

function Dots() {
  const { index, count, goTo } = useFoldCarousel();

  return (
    <div className="flex">
      {Array.from({ length: count }, (_, slide) => (
        <button
          key={slide}
          type="button"
          aria-label={`Slide ${slide + 1} of ${count}`}
          aria-current={slide === index}
          onClick={() => goTo(slide)}
          className="group focus-visible:ring-ring/50 grid size-8 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-3"
        >
          <span className="bg-foreground/20 group-hover:bg-foreground/40 group-aria-current:bg-foreground size-1.5 rounded-full transition-colors" />
        </button>
      ))}
    </div>
  );
}

Try your photos

Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Pine forest silhouette against hazy mountains
Blue desert mountain ridges under a heavy cloud
Slide 1 of 4

Pick 2 to 8. Your photos never leave this tab, and the frame crops them to 2:1.

How it works

The slide never rotates. Each face stays flat and is clipped to the outline a turning leaf would have, then blurred and shaded by how far it has tilted. Drag to scrub one fold.

Lift0°
Clip×1.00
Ramps0%
BlurShade
EdgeHingeEdge

API

FoldCarousel

Holds the state. Put a frame and any controls inside it. Takes every div prop.

PropTypeDefault
slidesOne slide per item. Anything that fills a box works, like img or next/image with fill.ReactNode[]–
loopWhether the last slide folds on to the first.booleantrue
durationSeconds a turn takes to settle onto a slide.number0.8
defaultIndexThe slide shown first.number0
onIndexChangeCalled with the slide a turn is heading for, as soon as it starts.(index: number) => void–

FoldCarouselFrame

The folding picture. Takes every div prop.

PropTypeDefault
variantdoor swings the next slide in behind the current one. book lays it underneath, like a page."door" | "book""door"
panelWidthWidth of one square panel, as any CSS length. The frame is two panels wide.string"min(23rem, 42vw)"
depthPerspective distance, in panel widths. Lower is more dramatic. Must stay above 1.number3.83
bezelColorAny CSS color. Shows on a leaf's edge mid-turn, and in place of a slide until its images have loaded.string"black"

FoldCarouselPrevious, FoldCarouselNext

shadcn Buttons. They take every Button prop, children replace the icon, and they stay focusable when disabled.

useFoldCarousel

Reads the surrounding carousel, for building your own controls.

ValueType
indexThe slide a turn is heading for.number
countHow many slides there are.number
canPrev, canNextFalse at the ends when loop is off.boolean
prev, nextFolds one slide back or on.() => void
goToFolds to a slide, the short way round when looping.(slide: number) => void

Accessibility

  • Arrow keys change slides while focus is inside the carousel, including after clicking a slide.
  • Screen readers hear each slide once, and a live region announces the slide on change.
  • The hidden copies of each slide are inert, so Tab never lands on them.
  • Previous and next stay focusable at the ends when loop is off.
  • With reduced motion turned on, slides cut instead of folding.