OverlaysNEW
Animated Folder Icon
#folder#documents#hover
Documents
originalInfinity original
This is a newly added InfinityUI-only component and is not replacing any legacy component.
Installation
Use the registry command to add the component source, and install any package dependencies if needed.
Infinity Registry
Install the component source into your project with the shadcn CLI.
npx shadcn@latest add https://infinityui-pearl.vercel.app/r/animated-folder-iconPackage Dependencies
Install the npm packages used by this component source.
npm install clsx framer-motion tailwind-mergeComponent Code
Copy and paste this code into your component file.
tsx
"use client";
import { useState, type ComponentPropsWithoutRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
type MotionTarget = {
y: number;
x: number;
rotate: number;
zIndex: number;
};
type CardPosition = "left" | "middle" | "right";
export interface AnimatedFolderIconProps
extends ComponentPropsWithoutRef<"div"> {
label?: string;
cardCount?: 1 | 2 | 3;
}
type SkeletonCardProps = {
animate: MotionTarget;
initial: MotionTarget;
transition: typeof springTransition;
};
function SkeletonCard({
animate,
initial,
transition,
}: SkeletonCardProps) {
return (
<motion.div
initial={initial}
animate={animate}
transition={transition}
className="absolute bottom-0 flex h-36 w-28 origin-bottom flex-col gap-2 rounded-lg border border-gray-200 bg-white p-3 shadow-md"
>
<div className="h-3 w-full rounded bg-gray-200" />
<div className="h-2 w-3/4 rounded bg-gray-100" />
<div className="mt-1 h-px w-full bg-gray-100" />
<div className="mt-1 flex flex-col gap-1.5">
<div className="h-2 w-full rounded bg-gray-100" />
<div className="h-2 w-5/6 rounded bg-gray-100" />
<div className="h-2 w-4/6 rounded bg-gray-100" />
</div>
<div className="mt-auto flex items-center gap-1.5">
<div className="size-4 rounded-full bg-gray-200" />
<div className="h-2 flex-1 rounded bg-gray-100" />
</div>
</motion.div>
);
}
function FolderBack() {
return (
<div className="absolute inset-0 flex flex-col">
<div
className="ml-4 self-start rounded-t-md"
style={{
width: 72,
height: 14,
background: "linear-gradient(135deg, #1a1a1f 0%, #25252d 100%)",
}}
/>
<div
className="flex-1 rounded-b-xl rounded-tr-xl"
style={{
background: "linear-gradient(160deg, #1c1c24 0%, #14141a 100%)",
boxShadow: "0 6px 24px rgba(0,0,0,0.5)",
}}
/>
</div>
);
}
function FolderFront() {
return (
<div
className="absolute bottom-0 left-0 right-0 rounded-xl"
style={{
height: "72%",
background:
"linear-gradient(160deg, #26262f 0%, #1a1a22 60%, #111117 100%)",
boxShadow:
"0 -2px 0 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(0,0,0,0.6)",
borderTop: "1px solid rgba(255,255,255,0.08)",
}}
>
<div
className="absolute left-6 right-6 top-0 h-px rounded-full"
style={{
background:
"linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent)",
}}
/>
<div className="absolute bottom-3 right-4 flex gap-1.5">
{[0, 1, 2].map((dot) => (
<div
key={dot}
className="size-1 rounded-full"
style={{ background: "rgba(255,255,255,0.12)" }}
/>
))}
</div>
</div>
);
}
const springTransition = { type: "spring" as const, stiffness: 300, damping: 20 };
const cardVariants: Record<
CardPosition,
{ default: MotionTarget; hover: MotionTarget }
> = {
left: {
default: { y: 0, x: 0, rotate: 0, zIndex: 10 },
hover: { y: -50, x: -40, rotate: -12, zIndex: 10 },
},
middle: {
default: { y: 0, x: 0, rotate: 0, zIndex: 11 },
hover: { y: -60, x: 0, rotate: 0, zIndex: 11 },
},
right: {
default: { y: 0, x: 0, rotate: 0, zIndex: 10 },
hover: { y: -50, x: 40, rotate: 12, zIndex: 10 },
},
};
export function AnimatedFolderIcon({
label = "Documents",
cardCount = 3,
className,
...props
}: AnimatedFolderIconProps) {
const [hovered, setHovered] = useState(false);
return (
<div
className={cn("relative inline-flex flex-col items-center", className)}
{...props}
>
<motion.div
animate={hovered ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.3 }}
className="pointer-events-none absolute rounded-full"
style={{
width: 200,
height: 200,
background:
"radial-gradient(circle, rgba(120,100,255,0.12) 0%, transparent 70%)",
filter: "blur(20px)",
}}
/>
<motion.div
onHoverStart={() => setHovered(true)}
onHoverEnd={() => setHovered(false)}
animate={hovered ? "hover" : "default"}
variants={{
default: { rotateX: 0, scale: 1 },
hover: { rotateX: 4, scale: 1.03 },
}}
transition={springTransition}
style={{ perspective: 800, cursor: "pointer" }}
className="relative select-none"
aria-label={`${label} animated folder icon`}
>
<div className="relative" style={{ width: 160, height: 130 }}>
<FolderBack />
<div
className="absolute inset-x-0 flex justify-center"
style={{ bottom: "30%", zIndex: 5 }}
>
{cardCount >= 1 && (
<SkeletonCard
initial={cardVariants.left.default}
animate={
hovered
? cardVariants.left.hover
: cardVariants.left.default
}
transition={springTransition}
/>
)}
{cardCount >= 2 && (
<SkeletonCard
initial={cardVariants.right.default}
animate={
hovered
? cardVariants.right.hover
: cardVariants.right.default
}
transition={springTransition}
/>
)}
<SkeletonCard
initial={cardVariants.middle.default}
animate={
hovered
? cardVariants.middle.hover
: cardVariants.middle.default
}
transition={springTransition}
/>
</div>
<div className="absolute inset-0" style={{ zIndex: 20 }}>
<FolderFront />
</div>
</div>
<motion.p
animate={hovered ? { opacity: 1, y: 0 } : { opacity: 0.4, y: 2 }}
transition={{ duration: 0.25 }}
className="mt-5 text-center text-xs font-medium uppercase tracking-[0.18em]"
style={{
color: "rgba(255,255,255,0.55)",
fontFamily: "'DM Mono', 'Courier New', monospace",
}}
>
{label}
</motion.p>
</motion.div>
</div>
);
}
export default AnimatedFolderIcon;