knot-ui
componentfan-foldersource: ok · hit
Fan Folder
Cards fan out and spread behind the folder on hover.
npx shadcn add "https://knot-ui.trupal.me/r/fan-folder.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 5:57:53 PM
components/library/fan-folder.tsx
"use client"
import { motion } from "motion/react"
import { useId, useState } from "react"
const spring = { type: "spring" as const, stiffness: 500, damping: 45, mass: 1 }
const DEFAULT_SIZE = 80
const getScale = (size: number) => size / 121
// ─── Color Themes ────────────────────────────────────────────────────────────
type ColorTheme = {
front: [string, string, string, string, string]
back: [string, string, string, string, string]
shadowIntensity: { highlight: number; shadow: number; drop: number }
borderHighlight: number
}
const colorThemes = {
black: {
front: ["#4A4A4A", "#2C2C2C", "#1A1A1A", "#0D0D0D", "#1F1F1F"],
back: ["#525252", "#3D3D3D", "#252525", "#181818", "#0E0E0E"],
shadowIntensity: { highlight: 0.15, shadow: 0.4, drop: 0.5 },
borderHighlight: 0.18,
},
green: {
front: ["#7A8B6F", "#657A58", "#566B4A", "#465A3C", "#526645"],
back: ["#5C6B50", "#4F5E44", "#435139", "#38452F", "#2E3926"],
shadowIntensity: { highlight: 0.25, shadow: 0.25, drop: 0.35 },
borderHighlight: 0.3,
},
blue: {
front: ["#6B8DB5", "#4A7099", "#3A5F85", "#2B4D6E", "#35587A"],
back: ["#4A6A8A", "#3D5A78", "#314B65", "#263D53", "#1E3244"],
shadowIntensity: { highlight: 0.2, shadow: 0.3, drop: 0.4 },
borderHighlight: 0.25,
},
purple: {
front: ["#8B7AAF", "#7460A0", "#604D8D", "#4D3B78", "#5A4585"],
back: ["#6B5A8A", "#5C4D78", "#4D4065", "#3E3353", "#322944"],
shadowIntensity: { highlight: 0.2, shadow: 0.3, drop: 0.4 },
borderHighlight: 0.25,
},
} satisfies Record<string, ColorTheme>
export type FanFolderColor = keyof typeof colorThemes
// ─── Component ───────────────────────────────────────────────────────────────
export const FanFolder = ({
size = DEFAULT_SIZE,
color = "black",
}: {
size?: number
color?: FanFolderColor
}) => {
const [hovered, setHovered] = useState(false)
const s = getScale(size)
const svgHeight = Math.round(108 * s)
const bodyHeight = Math.round(78 * s)
const cardW = Math.round(44 * s)
const cardH = Math.round(52 * s)
const theme = colorThemes[color] ?? colorThemes.black
const cardVariants = {
hidden: { opacity: 0, y: 0, x: 0, rotate: 0, scale: 1, pointerEvents: "none" as const },
left: { opacity: 1, y: -38 * s, x: -28 * s, rotate: -15, scale: 1.25, pointerEvents: "auto" as const },
center: { opacity: 1, y: -47 * s, x: -4 * s, rotate: 6, scale: 1.25, pointerEvents: "auto" as const },
right: { opacity: 1, y: -34 * s, x: 20 * s, rotate: 23, scale: 1.25, pointerEvents: "auto" as const },
}
return (
<motion.div
className="relative cursor-pointer"
style={{ width: size, height: svgHeight }}
onHoverStart={() => setHovered(true)}
onHoverEnd={() => setHovered(false)}
animate={hovered ? { y: 5 } : { y: 0 }}
transition={spring}
>
{/* Folder SVG background */}
<FolderSvg size={size} color={color} />
{/* 3 white cards - fan out on hover */}
<motion.div
className="bg-white absolute rounded-md z-3 shadow-[0_1px_8px_0_rgba(255,255,255,0.25)] top-[40%] left-1/3 origin-bottom"
style={{ width: cardW, height: cardH }}
initial="hidden"
animate={hovered ? "left" : "hidden"}
variants={cardVariants}
transition={spring}
/>
<motion.div
className="bg-neutral-300 absolute rounded-md z-2 shadow-[0_1px_8px_0_rgba(255,255,255,0.25)] top-[40%] left-1/3 origin-bottom"
style={{ width: cardW, height: cardH }}
initial="hidden"
animate={hovered ? "center" : "hidden"}
variants={cardVariants}
transition={spring}
/>
<motion.div
className="bg-neutral-400 absolute rounded-md z-1 shadow-[0_1px_8px_0_rgba(255,255,255,0.25)] top-[40%] left-1/3 origin-bottom"
style={{ width: cardW, height: cardH }}
initial="hidden"
animate={hovered ? "right" : "hidden"}
variants={cardVariants}
transition={spring}
/>
{/* Folder body overlay */}
<div
className="absolute bottom-0 left-0 z-4"
style={{
width: size,
height: bodyHeight,
borderRadius: `${Math.round(12 * s)}px ${Math.round(12 * s)}px ${Math.round(10 * s)}px ${Math.round(10 * s)}px`,
background: `linear-gradient(160deg, ${theme.front[0]} 0%, ${theme.front[1]} 20%, ${theme.front[2]} 50%, ${theme.front[3]} 80%, ${theme.front[4]} 100%)`,
boxShadow: `inset 0 ${Math.round(2 * s)}px ${Math.round(3 * s)}px rgba(255,255,255,${theme.shadowIntensity.highlight}), inset 0 -${Math.round(2 * s)}px ${Math.round(4 * s)}px rgba(0,0,0,${theme.shadowIntensity.shadow}), 0 ${Math.round(2 * s)}px ${Math.round(8 * s)}px rgba(0,0,0,${theme.shadowIntensity.drop})`,
borderTop: `${Math.max(1, Math.round(1 * s))}px solid rgba(255,255,255,${theme.borderHighlight})`,
borderLeft: `${Math.max(1, Math.round(0.5 * s))}px solid rgba(255,255,255,${theme.borderHighlight * 0.5})`,
borderRight: `${Math.max(1, Math.round(0.5 * s))}px solid rgba(0,0,0,${theme.borderHighlight * 0.25})`,
borderBottom: `${Math.max(1, Math.round(1 * s))}px solid rgba(0,0,0,${theme.borderHighlight * 0.7})`,
}}
/>
</motion.div>
)
}
// ─── SVG Back Panel ──────────────────────────────────────────────────────────
const FOLDER_PATH = "M 0 15.319 C 0 6.858 7.179 0 16.035 0 L 34.354 0 C 39.172 0 43.878 1.382 47.864 3.968 L 60.431 12.117 C 64.416 14.703 69.122 16.085 73.94 16.085 L 104.697 16.085 C 113.527 16.085 120.696 22.905 120.733 31.341 L 121 92.617 C 121.037 101.103 113.847 108 104.964 108 L 16.035 108 C 7.179 108 0 101.142 0 92.681 Z"
export const FolderSvg = ({
size = DEFAULT_SIZE,
color = "black",
}: {
size?: number
color?: FanFolderColor
}) => {
const s = getScale(size)
const uid = useId()
const theme = colorThemes[color] ?? colorThemes.black
const backId = `f1Back-${uid}`
const shineId = `f1Shine-${uid}`
const edgeId = `f1Edge-${uid}`
const vignetteId = `f1Vignette-${uid}`
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={Math.round(108 * s)} viewBox="0 0 121 108" fill="none">
<defs>
<linearGradient id={backId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={theme.back[0]} />
<stop offset="15%" stopColor={theme.back[1]} />
<stop offset="45%" stopColor={theme.back[2]} />
<stop offset="75%" stopColor={theme.back[3]} />
<stop offset="100%" stopColor={theme.back[4]} />
</linearGradient>
<linearGradient id={shineId} x1="0" y1="0" x2="1" y2="0.6">
<stop offset="0%" stopColor="rgba(255,255,255,0.14)" />
<stop offset="30%" stopColor="rgba(255,255,255,0.04)" />
<stop offset="60%" stopColor="rgba(255,255,255,0)" />
<stop offset="85%" stopColor="rgba(255,255,255,0.06)" />
<stop offset="100%" stopColor="rgba(0,0,0,0.2)" />
</linearGradient>
<linearGradient id={edgeId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(255,255,255,0.22)" />
<stop offset="8%" stopColor="rgba(255,255,255,0.05)" />
<stop offset="100%" stopColor="rgba(0,0,0,0)" />
</linearGradient>
<radialGradient id={vignetteId} cx="0.5" cy="0.3" r="0.7">
<stop offset="0%" stopColor="rgba(255,255,255,0.05)" />
<stop offset="100%" stopColor="rgba(0,0,0,0.18)" />
</radialGradient>
</defs>
<path d={FOLDER_PATH} fill={`url(#${backId})`} />
<path d={FOLDER_PATH} fill={`url(#${shineId})`} />
<path d={FOLDER_PATH} fill={`url(#${edgeId})`} />
<path d={FOLDER_PATH} fill={`url(#${vignetteId})`} />
<path d={FOLDER_PATH} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="0.5" />
</svg>
)
}