{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swipeable-cards","type":"registry:component","title":"Swipeable Cards","description":"A deck of cards you swipe through.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["swiper","lucide-react"],"files":[{"path":"registry/dimaac/swipeable-cards/swipeable-cards.tsx","type":"registry:component","target":"components/dimaac/swipeable-cards.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type KeyboardEvent } from \"react\";\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport { A11y, Autoplay, EffectCards } from \"swiper/modules\";\nimport { Swiper, SwiperSlide } from \"swiper/react\";\nimport type { Swiper as SwiperInstance } from \"swiper/types\";\nimport \"swiper/css\";\nimport \"swiper/css/effect-cards\";\nimport \"swiper/css/a11y\";\n\n// A deck of photos you flick through. The top card flies off to the side when you drag it and the\n// next one settles into its place, with the rest fanned out behind. Swiper's \"cards\" effect does\n// the 3D, we add the buttons and dots around it. Those talk to THIS deck through its instance,\n// not through global class names, so two decks on one page never steer each other.\n\nexport type SwipeableCard = {\n  /** The image address. */\n  src: string;\n  /** What the image shows, for screen readers. */\n  alt: string;\n};\n\nexport type SwipeableCardsProps = {\n  /** The cards, top one first. Eight or more looks best. */\n  images: SwipeableCard[];\n  /** Width of a card, in pixels. On a narrower screen it shrinks to fit. */\n  cardWidth?: number;\n  /** Height of a card, in pixels. */\n  cardHeight?: number;\n  /** Corner radius of a card, in pixels. */\n  radius?: number;\n  /** Start again at the first card after the last one. */\n  loop?: boolean;\n  /** Flick to the next card on a timer. It pauses under the pointer and never runs for visitors who prefer reduced motion. */\n  autoplay?: boolean;\n  /** Milliseconds between cards while autoplay is on. */\n  autoplayDelay?: number;\n  /** Milliseconds a card takes to move. Visitors who prefer reduced motion get an instant change. */\n  speed?: number;\n  /** Tilt of each card behind the top one, in degrees. */\n  perSlideRotate?: number;\n  /** How far each card behind the top one peeks out to the side, in pixels (roughly). */\n  perSlideOffset?: number;\n  /** Tilt the cards as they fan out. Off keeps the deck flat and straight. */\n  rotate?: boolean;\n  /** Darken the cards behind the top one, so the deck has depth. */\n  slideShadows?: boolean;\n  /** Show previous and next buttons under the deck. */\n  showNavigation?: boolean;\n  /** Show one dot per card under the deck. They are buttons too. */\n  showPagination?: boolean;\n  /** Show a grab hand over the deck. */\n  grabCursor?: boolean;\n  /** The name screen readers announce for the deck. */\n  label?: string;\n  /** Called with the number of the card on top (starting at 0) each time it changes. */\n  onIndexChange?: (index: number) => void;\n  /** Extra classes for the outer box. */\n  className?: string;\n  /** Extra classes for each card, e.g. a different shadow. */\n  cardClassName?: string;\n};\n\nexport function SwipeableCards({\n  images,\n  cardWidth = 300,\n  cardHeight = 440,\n  radius = 24,\n  loop = true,\n  autoplay = false,\n  autoplayDelay = 2500,\n  speed = 300,\n  perSlideRotate = 2,\n  perSlideOffset = 8,\n  rotate = true,\n  slideShadows = true,\n  showNavigation = false,\n  showPagination = false,\n  grabCursor = true,\n  label = \"Swipeable cards\",\n  onIndexChange,\n  className = \"\",\n  cardClassName = \"shadow-[0_26px_40px_-22px_rgba(0,0,0,0.5)]\",\n}: SwipeableCardsProps) {\n  // The Swiper instance of this deck. The buttons and the dots use it.\n  const swiperRef = useRef<SwiperInstance | null>(null);\n  const [index, setIndex] = useState(0);\n  // Read after mount so the server and the first client render match\n  const [reducedMotion, setReducedMotion] = useState(false);\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setReducedMotion(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n\n  // Swiper doesn't pick up a change of loop, autoplay or the card effect settings on its own, so\n  // those rebuild the deck (it stays on the same card). Everything else, like speed, updates in place.\n  const running = autoplay && !reducedMotion;\n  const deckKey = `${loop}-${rotate}-${slideShadows}-${perSlideRotate}-${perSlideOffset}-${running}-${autoplayDelay}-${images.length}`;\n\n  // The listener lives on the box that holds the deck, so only the deck the visitor focused reacts\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      swiperRef.current?.slideNext();\n    } else if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      swiperRef.current?.slidePrev();\n    }\n  };\n\n  const buttonClass =\n    \"grid h-11 w-11 place-items-center rounded-full border border-current/25 bg-transparent text-inherit transition-colors duration-200 hover:bg-current/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\";\n\n  return (\n    <div\n      className={`group/deck relative flex w-full flex-col items-center gap-7 outline-none ${className}`}\n      role=\"group\"\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      tabIndex={0}\n      onKeyDown={onKeyDown}\n    >\n      <div\n        className=\"relative group-focus-visible/deck:rounded-[32px] group-focus-visible/deck:outline-2 group-focus-visible/deck:outline-offset-[10px] group-focus-visible/deck:outline-current/50\"\n        style={{ width: cardWidth, maxWidth: \"calc(100% - 3rem)\", height: cardHeight }}\n      >\n        <Swiper\n          key={deckKey}\n          className=\"!h-full !w-full\"\n          modules={[EffectCards, Autoplay, A11y]}\n          effect=\"cards\"\n          cardsEffect={{ rotate, slideShadows, perSlideRotate, perSlideOffset }}\n          grabCursor={grabCursor}\n          loop={loop}\n          speed={reducedMotion ? 0 : speed}\n          slideToClickedSlide\n          autoplay={running ? { delay: autoplayDelay, disableOnInteraction: false, pauseOnMouseEnter: true } : false}\n          a11y={{ slideLabelMessage: \"Card {{index}} of {{slidesLength}}\" }}\n          initialSlide={index}\n          onSwiper={(swiper) => {\n            swiperRef.current = swiper;\n          }}\n          onRealIndexChange={(swiper) => {\n            setIndex(swiper.realIndex);\n            onIndexChange?.(swiper.realIndex);\n          }}\n        >\n          {images.map((image, i) => (\n            <SwiperSlide key={`${i}-${image.src}`} className={cardClassName} style={{ borderRadius: radius }}>\n              <img src={image.src} alt={image.alt} draggable={false} decoding=\"async\" className=\"block h-full w-full object-cover select-none\" />\n            </SwiperSlide>\n          ))}\n        </Swiper>\n      </div>\n\n      {(showNavigation || showPagination) && (\n        <div className=\"flex items-center gap-4\">\n          {showNavigation && (\n            <button type=\"button\" className={buttonClass} aria-label=\"Previous card\" onClick={() => swiperRef.current?.slidePrev()}>\n              <ChevronLeftIcon className=\"h-5 w-5\" aria-hidden=\"true\" />\n            </button>\n          )}\n          {showPagination && (\n            <div className=\"flex items-center\" role=\"group\" aria-label=\"Choose a card\">\n              {images.map((image, i) => (\n                <button\n                  key={`${i}-${image.src}`}\n                  type=\"button\"\n                  className=\"group grid h-6 place-items-center px-[3px] text-inherit focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current\"\n                  aria-label={`Show card ${i + 1} of ${images.length}`}\n                  aria-current={i === index ? \"true\" : undefined}\n                  onClick={() => (loop ? swiperRef.current?.slideToLoop(i) : swiperRef.current?.slideTo(i))}\n                >\n                  <span\n                    className={`block h-[6px] rounded-full bg-current transition-[width,opacity] duration-300 ${i === index ? \"w-5 opacity-100\" : \"w-[6px] opacity-30 group-hover:opacity-60\"}`}\n                  />\n                </button>\n              ))}\n            </div>\n          )}\n          {showNavigation && (\n            <button type=\"button\" className={buttonClass} aria-label=\"Next card\" onClick={() => swiperRef.current?.slideNext()}>\n              <ChevronRightIcon className=\"h-5 w-5\" aria-hidden=\"true\" />\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"categories":["cards","swiper","swipe","deck","images"],"docs":"import { SwipeableCards } from \"@/components/dimaac/swipeable-cards\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/swipeable-cards","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/swipeable-cards","credits":[{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Portrait with Neon Sunglasses","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-woman-portrait-htofy","what":"Vibrant Woman Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Vivid Portrait on Pink","source":"Lummi","licence":"Lummi License"},{"by":"Steph Meade","url":"https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc","what":"Expressive Minimal Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Lloyd Creates","url":"https://www.lummi.ai/photo/poised-portrait-ooi9b","what":"Poised Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/stylish-portrait-8inth","what":"Stylish Portrait","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s","what":"Monochrome Portrait of a Young Woman","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/mystery-portrait-cffyo","what":"Mystery Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Kuba","url":"https://www.lummi.ai/photo/abstract-eye-close-up-uamae","what":"Abstract Eye Close-Up","source":"Lummi","licence":"Lummi License"},{"by":"studiomiklus","url":"https://www.lummi.ai/photo/striking-portrait-jodiu","what":"Striking Portrait","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Fashion Portrait with Orange Visor","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/stylish-studio-portrait-d9xnl","what":"Stylish Studio Portrait","source":"Lummi","licence":"Lummi License"}]}}