shadimage
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>
    )
}

More from knot-ui

Browse all components in this library →