Folder Reveal
A folder that fans its papers out of the pocket on hover or focus, and pins open on click.
Hovering or focusing the folder previews the reveal; a click (or Enter/Space) pins it open so keyboard users get the same affordance as the mouse. The sheets stagger out and fan symmetrically around the center, the front pocket leans its top edge toward the viewer as it opens, and everything tucks back in reverse order on close. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/folder-reveal.tsx.
'use client';import * as React from 'react';import { motion, useReducedMotion, type Variants } from 'motion/react';import { cn } from '@/lib/cn';const SPRING = { type: 'spring' as const, duration: 0.5, bounce: 0.18 };type Slot = { x: number; rotate: number; lift: number };function fanSlots(count: number): Slot[] { const n = Math.max(1, Math.min(count, 5)); const mid = (n - 1) / 2; return Array.from({ length: n }, (_, i) => { const d = i - mid; return { x: d * 34, rotate: d * 9, lift: -Math.abs(d) * 10, }; });}export type FolderRevealProps = { title?: string; count?: number; sheets?: number; accent?: string; defaultOpen?: boolean; className?: string;};export function FolderReveal({ title = 'Design Folder', count = 45, sheets = 3, accent = '#f0883e', defaultOpen = false, className,}: FolderRevealProps) { const reduce = useReducedMotion(); const [hovered, setHovered] = React.useState(false); const [pinned, setPinned] = React.useState(defaultOpen); const open = hovered || pinned; const slots = React.useMemo(() => fanSlots(sheets), [sheets]); const folderVariants: Variants = { closed: { transition: { staggerChildren: 0.045, staggerDirection: -1 } }, open: { transition: { staggerChildren: 0.05, delayChildren: 0.04 } }, }; const sheetVariants: Variants = { closed: () => ({ x: 0, y: 20, rotate: 0, transition: reduce ? { duration: 0.2 } : SPRING, }), open: (s: Slot) => ({ x: reduce ? 0 : s.x, y: reduce ? 0 : -26 + s.lift, rotate: reduce ? 0 : s.rotate, transition: reduce ? { duration: 0.2 } : SPRING, }), }; const pocketVariants: Variants = { closed: { rotateX: 0, transition: reduce ? { duration: 0.2 } : SPRING }, open: { rotateX: reduce ? 0 : -20, transition: reduce ? { duration: 0.2 } : SPRING }, }; return ( <motion.button type="button" aria-pressed={pinned} onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)} onFocus={() => setHovered(true)} onBlur={() => setHovered(false)} onClick={() => setPinned((p) => !p)} whileTap={reduce ? undefined : { scale: 0.98 }} animate={open ? 'open' : 'closed'} variants={folderVariants} initial={false} style={ { '--folder-accent': accent, perspective: 900, } as React.CSSProperties } className={cn( 'group relative block h-[230px] w-[300px] cursor-pointer select-none', 'rounded-[16px] outline-none', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--folder-accent)]', className, )} > <div className="absolute inset-x-0 bottom-0 top-[26px] z-0"> <div className={cn( 'bloom-edge h-full w-full rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5]', 'dark:from-[#1d1d1d] dark:to-[#161716]', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_8px_22px_-12px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_12px_30px_-12px_rgba(0,0,0,0.6)]', )} /> </div> <div className="absolute inset-x-0 bottom-[60px] z-10 flex justify-center"> {slots.map((slot, i) => ( <motion.div key={i} custom={slot} variants={sheetVariants} className={cn( 'absolute bottom-0 h-[136px] w-[108px] origin-bottom overflow-hidden rounded-[10px]', 'bloom-edge bg-gradient-to-b from-white to-[#ececed]', 'dark:from-[#2b2b2b] dark:to-[#202020]', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.06),0_8px_18px_-8px_rgba(0,0,0,0.22)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_8px_18px_-8px_rgba(0,0,0,0.6)]', )} > <div className="flex h-full flex-col gap-[7px] px-3 pt-4"> {[10, 9, 9, 7, 9, 6].map((w, j) => ( <span key={j} className="h-[3px] rounded-full bg-black/[0.10] dark:bg-white/[0.10]" style={{ width: `${w * 9}%` }} /> ))} </div> </motion.div> ))} </div> <motion.div variants={pocketVariants} className="absolute inset-x-0 bottom-0 z-20 h-[128px] origin-bottom" style={{ transformStyle: 'preserve-3d' }} > <div className={cn( 'bloom-edge relative h-full w-full rounded-[14px] text-left', 'bg-gradient-to-b from-[#fbfbfb] to-[#efeff0]', 'dark:from-[#242424] dark:to-[#171717]', 'shadow-[0_-1px_1px_0_rgba(255,255,255,0.6),0_1px_2px_0_rgba(0,0,0,0.06),0_14px_30px_-14px_rgba(0,0,0,0.22)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.07),0_1px_2px_0_rgba(0,0,0,0.5),0_16px_34px_-14px_rgba(0,0,0,0.7)]', )} > <div className="absolute bottom-0 left-0 p-4"> <p className="text-[14px] font-medium tracking-[-0.01em] text-neutral-900 dark:text-white"> {title} </p> <p className="mt-0.5 flex items-center gap-1.5 text-[12px] text-neutral-500 dark:text-[#929292]"> <span aria-hidden className="size-1.5 rounded-full" style={{ backgroundColor: 'var(--folder-accent)' }} /> {count} {count === 1 ? 'file' : 'files'} </p> </div> </div> </motion.div> </motion.button> );}Usage
import { FolderReveal } from '@/components/ui/folder-reveal';
export default function Example() {
return <FolderReveal />;
}Examples
A wider fan of four sheets in a cyan accent.
A single sheet, pinned open by default.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "Design Folder" | Folder name shown on the front pocket. |
count | number | 45 | File count rendered in the muted sub-line. |
sheets | number | 3 | How many paper sheets peek out, 1 to 5. |
accent | string | "#f0883e" | Accent for the tab highlight and focus ring. |
defaultOpen | boolean | false | Start in the open (revealed) state. |
className | string | - | Forwarded to the root. |