Arc Cards

A fluid hover animation component that arranges cards along an arc with dynamic scaling, rotation, and blur transitions.

Usage

arc-cards-preview.tsx
import ArcCards from "@/registry/components/arc-cards/arc-cards";

const ArcCardsPreview = () => {
  const cards = [
    "https://picsum.photos/id/235/480/640",
    "https://picsum.photos/id/199/480/640",
    "https://picsum.photos/id/177/480/640",
    "https://picsum.photos/id/168/480/640",
    "https://picsum.photos/id/202/480/640",
  ];

  return (
    <div>
      <ArcCards
        items={cards}
        className="w-[240px] h-[320px] rounded-xl"
        CardComponent={Card}
      />
      <p className="w-32 mt-4 mx-auto text-balance text-xs text-center text-gray-500 leading-4">
        Hover over a card to see the effect
      </p>
    </div>
  );
};

const Card = (
  props: { item: string; },
) => {
  return (
    <img
      src={props.item}
    />
  );
};

export default ArcCardsPreview;

Component

Install the following packages before using this component.

Tailwind CSS
Motion
arc-cards.tsx
import { memo, useCallback, useMemo, useState, type ComponentType } from "react";
import { motion, type HTMLMotionProps } from "motion/react";
import cn from "@/utils/cn";

export type ArcCardsProps<T> = {
  items: T[];
  CardComponent: ComponentType<{ item: T }>;
  xOffset?: number;
  rotateOffset?: number;
  scaleOffset?: number;
  blurOffset?: number;
} & HTMLMotionProps<"div">;

const ArcCards = <T,>(
  props: ArcCardsProps<T>,
) => {
  const {
    items = [],
    CardComponent,
    xOffset = 50,
    rotateOffset = 20,
    scaleOffset = 0.125,
    blurOffset = 0.5,
    className,
    ...restProps
  } = props;

  const [isHovered, setIsHovered] = useState(false);

  const center = useMemo(() => (
    Math.floor(items.length / 2)
  ), [items.length]);

  const cardProps = (
    index: number,
  ): HTMLMotionProps<"div"> => {
    const position = index - center;
    return {
      animate: {
        x: isHovered ? xOffset * position : 0,
        rotate: isHovered ? rotateOffset * position : 0,
        scale: isHovered ? 1.05 - (Math.abs(position) * scaleOffset) : 1,
        filter: `blur(${isHovered ? 0 : blurOffset * Math.abs(position)}px)`,
      },
      style: {
        zIndex: items.length - Math.abs(position),
      },
      transition: {
        type: "spring",
        stiffness: 200,
        damping: 15,
        mass: 0.5,
      },
    }
  };

  const hoverStartHandler = useCallback(() => {
    setIsHovered(true);
  }, []);

  const hoverEndHandler = useCallback(() => {
    setIsHovered(false);
  }, []);
  
  return (
    <motion.div
      {...restProps}
      className={cn(
        "relative",
        className,
      )}
      onHoverStart={hoverStartHandler}
      onHoverEnd={hoverEndHandler}
    >
      {items.map((item, itemIndex) => (
        <motion.div
          key={`item-${itemIndex}`}
          className={cn(
            "absolute inset-0 rounded-[inherit] overflow-hidden",
            "[transform-origin:50%_bottom]",
          )}
          {...cardProps(itemIndex)}
        >
          <CardComponent
            item={item}
          />
        </motion.div>
      ))}
    </motion.div>
  );
};

export default memo(ArcCards) as typeof ArcCards;

Component API

PropTypeRequiredDefaultDescription
itemsT[]YesArray of data items used to render cards. Odd numbers of items are preferred because the middle item (items.length / 2) is treated as the center card of the arc layout.
CardComponentComponentType<{ item: T }>YesReact component used to render each card. Receives a single item prop containing the corresponding item from items.
xOffsetnumberNo50Horizontal offset applied between cards to create the arc spacing effect.
rotateOffsetnumberNo20Rotation angle offset applied to cards based on their position relative to the center card.
scaleOffsetnumberNo0.125Scale difference applied between cards. Cards further from the center are scaled down by this offset.
blurOffsetnumberNo0.5Blur intensity offset applied to cards based on their distance from the center card.

Support the Project

If you find this component useful, consider starring the repository on GitHub. Found a bug or have a suggestion? Open an issue to help improve it.