deck

PreviousNext

A Tinder-like swipeable card stack component with smooth animations.

Docs
kibo-uiui

Preview

Loading preview…
index.tsx
"use client";

import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
  motion,
  type PanInfo,
  useMotionValue,
  useTransform,
} from "motion/react";
import {
  Children,
  cloneElement,
  type HTMLAttributes,
  type ReactElement,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";

export type DeckProps = HTMLAttributes<HTMLDivElement>;

export const Deck = ({ className, ...props }: DeckProps) => (
  <div className={cn("relative isolate", className)} {...props} />
);

export type DeckCardsProps = HTMLAttributes<HTMLDivElement> & {
  onSwipe?: (index: number, direction: "left" | "right") => void;
  onSwipeEnd?: (index: number, direction: "left" | "right") => void;
  threshold?: number;
  stackSize?: number;
  perspective?: number;
  scale?: number;
  currentIndex?: number;
  defaultCurrentIndex?: number;
  onCurrentIndexChange?: (index: number) => void;
  animateOnIndexChange?: boolean;
  indexChangeDirection?: "left" | "right";
};

export const DeckCards = ({
  children,
  className,
  onSwipe,
  onSwipeEnd,
  threshold = 150,
  stackSize = 3,
  perspective = 1000,
  scale = 0.05,
  currentIndex: currentIndexProp,
  defaultCurrentIndex = 0,
  onCurrentIndexChange,
  animateOnIndexChange = true,
  indexChangeDirection = "left",
  ...props
}: DeckCardsProps) => {
  const childrenArray = Children.toArray(children) as ReactElement[];
  const [currentIndex, setCurrentIndex] = useControllableState({
    prop: currentIndexProp,
    defaultProp: defaultCurrentIndex,
    onChange: onCurrentIndexChange,
  });
  const [exitDirection, setExitDirection] = useState<"left" | "right" | null>(
    null
  );
  const [displayIndex, setDisplayIndex] = useState(currentIndex);
  const isInternalChangeRef = useRef(false);
  const prevIndexRef = useRef(currentIndex);

  // Detect external currentIndex changes and trigger animation
  useEffect(() => {
    const prevIndex = prevIndexRef.current;

    // Skip initial mount and internal changes
    if (prevIndex === currentIndex || isInternalChangeRef.current) {
      isInternalChangeRef.current = false;
      prevIndexRef.current = currentIndex;
      setDisplayIndex(currentIndex);
      return;
    }

    // Only animate if the option is enabled and we have cards to show
    if (animateOnIndexChange && prevIndex < childrenArray.length) {
      setExitDirection(indexChangeDirection);

      // Update display index after animation completes
      setTimeout(() => {
        setExitDirection(null);
        setDisplayIndex(currentIndex);
      }, 300);
    } else {
      // No animation, update display index immediately
      setDisplayIndex(currentIndex);
    }

    prevIndexRef.current = currentIndex;
  }, [
    currentIndex,
    animateOnIndexChange,
    indexChangeDirection,
    childrenArray.length,
  ]);

  const handleSwipe = useCallback(
    (direction: "left" | "right") => {
      if (displayIndex >= childrenArray.length) {
        return;
      }

      setExitDirection(direction);

      if (direction === "left") {
        onSwipe?.(displayIndex, "left");
      } else {
        onSwipe?.(displayIndex, "right");
      }

      onSwipeEnd?.(displayIndex, direction);

      // Move to next card after animation
      setTimeout(() => {
        isInternalChangeRef.current = true;
        const newIndex = displayIndex + 1;
        setCurrentIndex(newIndex);
        setDisplayIndex(newIndex);
        setExitDirection(null);
      }, 300);
    },
    [displayIndex, childrenArray.length, onSwipe, onSwipeEnd, setCurrentIndex]
  );

  const visibleCards = childrenArray.slice(
    displayIndex,
    displayIndex + stackSize
  );

  if (displayIndex >= childrenArray.length) {
    return null;
  }

  return (
    <div
      className={cn("relative z-10 size-full", className)}
      style={{ perspective }}
      {...props}
    >
      {visibleCards.map((child, index) => {
        const isTopCard = !index;
        const zIndex = stackSize - index;
        const scaleValue = 1 - index * scale;
        const yOffset = index * 4;
        const cardKey = `${displayIndex}-${child.key ?? index}`;

        if (isTopCard) {
          return (
            <DeckCard
              exitDirection={exitDirection}
              key={cardKey}
              onSwipe={handleSwipe}
              style={{
                zIndex,
                scale: scaleValue,
                y: yOffset,
              }}
              threshold={threshold}
            >
              {child}
            </DeckCard>
          );
        }

        const nextCardScale = index === 1 && exitDirection ? 1 : scaleValue;
        const nextCardY = index === 1 && exitDirection ? 0 : yOffset;

        return (
          <motion.div
            animate={{
              scale: nextCardScale,
              y: nextCardY,
            }}
            className="absolute inset-0"
            key={cardKey}
            style={{
              zIndex,
              scale: scaleValue,
              y: yOffset,
            }}
            transition={{ duration: 0.3, ease: "easeOut" }}
          >
            {child}
          </motion.div>
        );
      })}
    </div>
  );
};

type DeckCardProps = {
  children: ReactElement;
  onSwipe: (direction: "left" | "right") => void;
  threshold: number;
  style?: object;
  exitDirection: "left" | "right" | null;
};

const DeckCard = ({
  children,
  onSwipe,
  threshold,
  style,
  exitDirection,
}: DeckCardProps) => {
  const x = useMotionValue(0);
  const rotate = useTransform(x, [-200, 200], [-25, 25]);
  const opacity = useTransform(
    x,
    [-200, -threshold, 0, threshold, 200],
    [0, 1, 1, 1, 0]
  );

  const handleDragEnd = (_: unknown, info: PanInfo) => {
    const swipeThreshold = threshold;

    if (Math.abs(info.offset.x) > swipeThreshold) {
      const direction = info.offset.x > 0 ? "right" : "left";
      onSwipe(direction);
    }
  };

  let exitX = 0;

  if (exitDirection === "left") {
    exitX = -500;
  } else if (exitDirection === "right") {
    exitX = 500;
  }

  const castedChildren = children as ReactElement<
    HTMLAttributes<HTMLDivElement>
  >;

  return (
    <motion.div
      animate={exitDirection ? { x: exitX, opacity: 0 } : undefined}
      className="absolute inset-0 cursor-grab active:cursor-grabbing"
      drag="x"
      dragConstraints={{ left: 0, right: 0 }}
      onDragEnd={handleDragEnd}
      style={{
        x,
        rotate,
        opacity,
        ...style,
      }}
      transition={{ duration: 0.3, ease: "easeOut" }}
      whileDrag={{ scale: 1.05 }}
    >
      {cloneElement(castedChildren, {
        className: cn(
          "h-full w-full select-none rounded-lg shadow-lg",
          castedChildren.props.className
        ),
      })}
    </motion.div>
  );
};

export type DeckItemProps = HTMLAttributes<HTMLDivElement>;

export const DeckItem = ({ className, ...props }: DeckItemProps) => (
  <div
    className={cn(
      "flex h-full w-full items-center justify-center rounded-lg border bg-card text-card-foreground shadow-lg",
      className
    )}
    {...props}
  />
);

export type DeckEmptyProps = HTMLAttributes<HTMLDivElement>;

export const DeckEmpty = ({
  children,
  className,
  ...props
}: HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "absolute inset-0 flex items-center justify-center rounded-lg border border-dashed text-muted-foreground",
      className
    )}
    {...props}
  >
    {children ?? <p className="text-sm">No more cards</p>}
  </div>
);

Installation

npx shadcn@latest add @kibo-ui/deck

Usage

import { Deck } from "@/components/ui/deck"
<Deck />