Cards & Layoutv0.1.0
Folder Card
A folder that opens to reveal its contents.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/folder-card.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/folder-card/folder-card.tsx"use client"; import { useState, type ComponentProps, type FocusEvent, type PointerEvent, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; export interface FolderCardProps extends Omit<ComponentProps<"a">, "children" | "title" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Folder name. */ title: string; /** Secondary line, e.g. "12 files". */ meta?: string; /** Up to three previews that rise out of the folder. Defaults to blank pages. */ items?: ReactNode[]; /** Folder color. @default "var(--primary)" */ color?: string; /** Destination; without it the card renders as a button. */ href?: string; /** Hold the folder open (it also opens on hover and keyboard focus). */ open?: boolean; } const SPRING = { type: "spring", stiffness: 260, damping: 22 } as const; function BlankPage() { return ( <div className="flex size-full flex-col gap-1.5 p-3"> <span className="h-1.5 w-1/2 rounded-full bg-muted-foreground/25" /> <span className="h-1 w-5/6 rounded-full bg-muted-foreground/15" /> <span className="h-1 w-4/6 rounded-full bg-muted-foreground/15" /> <span className="h-1 w-3/4 rounded-full bg-muted-foreground/15" /> </div> ); } export function FolderCard({ title, meta, items, color = "var(--primary)", href, open, className, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: FolderCardProps) { const reduceMotion = useReducedMotion(); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const isOpen = open ?? (hovered || focused); const pages = (items?.length ? items : [<BlankPage key="a" />, <BlankPage key="b" />, <BlankPage key="c" />]).slice(0, 3); const handlers = { onPointerEnter: (event: PointerEvent<HTMLAnchorElement>) => { onPointerEnter?.(event); if (event.pointerType === "mouse") setHovered(true); }, onPointerLeave: (event: PointerEvent<HTMLAnchorElement>) => { onPointerLeave?.(event); setHovered(false); }, onFocus: (event: FocusEvent<HTMLAnchorElement>) => { onFocus?.(event); setFocused(event.currentTarget.matches(":focus-visible")); }, onBlur: (event: FocusEvent<HTMLAnchorElement>) => { onBlur?.(event); setFocused(false); }, }; const Tag = (href ? "a" : "button") as "a"; const body = ( <> <span className="relative block aspect-[5/4] [perspective:800px]" aria-hidden="true"> {/* Back panel with its tab. */} <span className="absolute inset-x-0 top-[9%] bottom-0 rounded-xl" style={{ background: `color-mix(in oklab, ${color} 78%, black)` }} /> <span className="absolute top-0 left-0 h-[16%] w-[42%] rounded-t-lg" style={{ background: `color-mix(in oklab, ${color} 78%, black)` }} /> {/* Pages rise and fan out of the folder. */} {pages.map((page, i) => { const offset = i - (pages.length - 1) / 2; return ( <motion.span key={i} className="absolute inset-x-[11%] top-[16%] bottom-[14%] block overflow-hidden rounded-md border border-black/5 bg-white shadow-sm" initial={false} animate={ isOpen ? reduceMotion ? { y: "-14%" } : { y: `${-26 - (pages.length - 1 - i) * 7}%`, x: `${offset * 16}%`, rotate: offset * 7 } : { y: `${-(pages.length - 1 - i) * 3}%`, x: "0%", rotate: 0 } } transition={{ ...SPRING, delay: isOpen ? i * 0.03 : 0 }} > {page} </motion.span> ); })} {/* Front panel tips forward around its bottom edge. */} <motion.span className="absolute inset-x-0 bottom-0 h-[74%] origin-bottom rounded-xl shadow-[0_-1px_0_rgb(255_255_255/0.35)_inset,0_8px_20px_-8px_rgb(0_0_0/0.35)]" style={{ background: `linear-gradient(to bottom, color-mix(in oklab, ${color} 88%, white), ${color})` }} initial={false} animate={isOpen ? (reduceMotion ? { y: "4%" } : { rotateX: -34, y: "2%" }) : { rotateX: 0, y: "0%" }} transition={SPRING} /> </span> <span className="mt-3 block"> <span className="block truncate text-sm font-medium text-foreground">{title}</span> {meta ? <span className="block text-xs text-muted-foreground">{meta}</span> : null} </span> </> ); return ( <Tag href={href} {...(href ? {} : { type: "button" })} className={cn( "block w-full cursor-pointer rounded-xl p-2 text-left outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring", className, )} {...handlers} {...props} > {body} </Tag> ); }
Usage
import { FolderCard } from "@/components/ui/folder-card/folder-card";
export function Files() {
return <FolderCard title="Design reviews" meta="24 files" href="/files/design-reviews" />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | – | Folder name. |
meta | string | – | Secondary line, e.g. "12 files". |
items | ReactNode[] | – | Up to three previews that rise out of the folder. Defaults to blank pages. |
color | string | "var(--primary)" | Folder color. |
href | string | – | Destination; without it the card renders as a button. |
open | boolean | – | Hold the folder open (it also opens on hover and keyboard focus). |
Also accepts every native <a> attribute, including ref and className.
- The front panel tips forward in 3D around its bottom edge while up to three pages rise and fan out, all with transforms.
- Pass previews as `items` (images, documents), or leave it empty for blank pages. `color` tints the folder.
- `open` holds it open, e.g. to show a drop target while dragging files over it.
Accessibility
- A link when href is set, a button otherwise. The folder artwork is aria-hidden; the title and meta are the accessible name.
- Opens on hover and on keyboard focus (focus-visible), so keyboard users get the same preview.
- With reduced motion enabled, pages rise slightly without fanning and the front panel doesn't tip.