Project Folder
A 3D folder whose front flap tilts open on hover while the papers inside fan out with a spring.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/blocks-project-folder.json02
Or copy the source below. Dependencies: none.
Source
components/niceui/blocks/ProjectFolder.tsx
import { useState } from 'react';
import { cn } from '@/lib/cn';
export type ProjectFolderProps = {
title: string;
meta: string;
color?: 'brand' | 'amber' | 'emerald' | 'rose';
papers?: string[];
open?: boolean;
onClick?: () => void;
};
const TONES = {
brand: { back: 'from-brand-700 to-brand-900', front: 'from-brand-500 to-brand-700', glow: 'rgba(56,189,248,0.35)' },
amber: { back: 'from-amber-600 to-amber-800', front: 'from-amber-400 to-amber-600', glow: 'rgba(251,191,36,0.3)' },
emerald: { back: 'from-emerald-700 to-emerald-900', front: 'from-emerald-400 to-emerald-600', glow: 'rgba(52,211,153,0.3)' },
rose: { back: 'from-rose-700 to-rose-900', front: 'from-rose-400 to-rose-600', glow: 'rgba(251,113,133,0.3)' },
};
const FAN = [
{ x: -34, y: -38, r: -14 },
{ x: 0, y: -52, r: 0 },
{ x: 34, y: -38, r: 14 },
];
export function ProjectFolder({ title, meta, color = 'brand', papers = ['Brief', 'Wireframes', 'Assets'], open, onClick }: ProjectFolderProps) {
const [hover, setHover] = useState(false);
const active = open ?? hover;
const tone = TONES[color];
return (
<button
onClick={onClick}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onFocus={() => setHover(true)}
onBlur={() => setHover(false)}
className="focus-ring group flex flex-col items-center gap-4 rounded-2xl p-3 outline-none"
>
<div
className="relative h-[120px] w-[160px] transition-transform duration-500 ease-spring"
style={{ transform: active ? 'translateY(-4px)' : 'none', filter: active ? `drop-shadow(0 18px 24px ${tone.glow})` : 'drop-shadow(0 8px 16px rgba(0,0,0,0.5))' }}
>
{/* back + tab */}
<div className={cn('absolute left-0 top-0 h-5 w-16 rounded-t-lg bg-gradient-to-b', tone.back)} />
<div className={cn('absolute inset-x-0 bottom-0 top-3 rounded-xl rounded-tl-none bg-gradient-to-b', tone.back)} />
{/* papers */}
{papers.slice(0, 3).map((p, i) => {
const f = FAN[i];
return (
<div
key={p}
className="absolute bottom-3 left-1/2 flex h-[92px] w-[110px] flex-col gap-1.5 rounded-lg bg-ink-50 p-2.5 shadow-card transition-transform duration-500 ease-spring"
style={{
transform: active
? `translateX(calc(-50% + ${f.x}px)) translateY(${f.y}px) rotate(${f.r}deg)`
: `translateX(-50%) translateY(${-6 - i * 3}px) rotate(${(i - 1) * 2}deg)`,
transitionDelay: `${active ? i * 50 : (2 - i) * 30}ms`,
}}
>
<span className="truncate text-left text-[10px] font-medium text-ink-900">{p}</span>
<span className="h-1 w-4/5 rounded-full bg-ink-200" />
<span className="h-1 w-3/5 rounded-full bg-ink-200" />
<span className="h-1 w-2/3 rounded-full bg-ink-200" />
</div>
);
})}
{/* front */}
<div
className={cn('absolute inset-x-0 bottom-0 h-[84px] origin-bottom rounded-xl bg-gradient-to-b shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-transform duration-500 ease-spring', tone.front)}
style={{ transform: active ? 'perspective(500px) rotateX(-34deg)' : 'perspective(500px) rotateX(-6deg)' }}
>
<div className="absolute inset-x-3 top-2 h-px bg-white/20" />
</div>
</div>
<div className="text-center">
<p className="text-sm font-medium text-ink-100">{title}</p>
<p className="text-xs text-ink-400">{meta}</p>
</div>
</button>
);
}
export function ProjectFolderDemo() {
const [selected, setSelected] = useState<string | null>(null);
const folders = [
{ title: 'Brand refresh', meta: '24 files · 3 members', color: 'brand' as const, papers: ['Moodboard', 'Logo v4', 'Guidelines'] },
{ title: 'Mobile app', meta: '112 files · 8 members', color: 'amber' as const, papers: ['Flows', 'Screens', 'Specs'] },
{ title: 'Q3 Launch', meta: '9 files · 2 members', color: 'emerald' as const, papers: ['Plan', 'Copy', 'Press kit'] },
];
return (
<div className="flex flex-col items-center gap-6 pt-8">
<div className="flex flex-wrap justify-center gap-6">
{folders.map((f) => (
<ProjectFolder key={f.title} {...f} open={selected === f.title ? true : undefined} onClick={() => setSelected((s) => (s === f.title ? null : f.title))} />
))}
</div>
<p className="h-4 text-xs text-ink-500">Hover a folder · click to pin it open</p>
</div>
);
}