liten

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.

Terminal
bun add motion

Copy the component into components/ui/folder-reveal.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
titlestring"Design Folder"Folder name shown on the front pocket.
countnumber45File count rendered in the muted sub-line.
sheetsnumber3How many paper sheets peek out, 1 to 5.
accentstring"#f0883e"Accent for the tab highlight and focus ring.
defaultOpenbooleanfalseStart in the open (revealed) state.
classNamestring-Forwarded to the root.
On this page0%