A fluid hover animation component that arranges cards along an arc with dynamic scaling, rotation, and blur transitions.
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; Install the following packages before using this component.
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; | Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | T[] | Yes | — | Array 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. |
| CardComponent | ComponentType<{ item: T }> | Yes | — | React component used to render each card. Receives a single item prop containing the corresponding item from items. |
| xOffset | number | No | 50 | Horizontal offset applied between cards to create the arc spacing effect. |
| rotateOffset | number | No | 20 | Rotation angle offset applied to cards based on their position relative to the center card. |
| scaleOffset | number | No | 0.125 | Scale difference applied between cards. Cards further from the center are scaled down by this offset. |
| blurOffset | number | No | 0.5 | Blur intensity offset applied to cards based on their distance from the center card. |
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.