Project Folder
A folder card for portfolios and file browsers: hovering or focusing fans out preview cards from inside, clicking grows it into a full overlay with managed focus and Escape to close, and closing plays the same path back into the folder.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/blocks/project-folder
import { X } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Transition,
} from "motion/react";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ProjectFolderPreview = {
id: string;
content: ReactNode;
};
export interface ProjectFolderProps {
title: string;
description?: string;
previews?: ProjectFolderPreview[];
count?: number;
itemLabel?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onClick?: () => void;
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
const MAX_PREVIEWS = 5;
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
function getPreviewTransform(index: number, count: number) {
const offset = index - (count - 1) / 2;
const distance = Math.abs(offset);
const centerLift = Math.max(0, 2 - distance) * 8;
return {
x: offset * 44,
y: 8 - centerLift,
rotate: offset * 6,
scale: distance === 0 ? 1.04 : distance === 1 ? 0.95 : 0.88,
opacity: distance === 0 ? 1 : distance === 1 ? 0.78 : 0.58,
zIndex: 10 - distance,
};
}
export function ProjectFolder({
title,
description = "Updated recently",
previews = [],
count = previews.length,
itemLabel = "file",
open,
defaultOpen = false,
onOpenChange,
expanded,
defaultExpanded = false,
onExpandedChange,
onClick,
disabled = false,
ariaLabel,
className,
}: ProjectFolderProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const layoutGroupId = useId();
const dialogTitleId = `${layoutGroupId}-title`;
const hoveredRef = useRef(false);
const focusedRef = useRef(false);
const restoringFocusRef = useRef(false);
const folderButtonRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const [isClosing, setIsClosing] = useState(false);
const openControlled = open !== undefined;
const expandedControlled = expanded !== undefined;
const isExpanded = expanded ?? internalExpanded;
const isOpen = (open ?? internalOpen) || isExpanded;
const previewItems = previews.slice(0, MAX_PREVIEWS);
const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const countText = `${count} ${itemLabel}${count === 1 ? "" : "s"}`;
const setOpen = useCallback(
(next: boolean) => {
if (disabled) return;
if (!openControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[disabled, onOpenChange, openControlled],
);
const setExpanded = useCallback(
(next: boolean) => {
if (disabled || previewItems.length === 0) return;
if (!expandedControlled) setInternalExpanded(next);
onExpandedChange?.(next);
},
[disabled, expandedControlled, onExpandedChange, previewItems.length],
);
const finishClose = useCallback(() => {
setIsClosing(false);
restoringFocusRef.current = true;
requestAnimationFrame(() => folderButtonRef.current?.focus());
}, []);
const closeOverlay = useCallback(() => {
setIsClosing(true);
setOpen(false);
setExpanded(false);
}, [setExpanded, setOpen]);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (reduce && isClosing) finishClose();
}, [finishClose, isClosing, reduce]);
useEffect(() => {
if (!isExpanded) return;
const previousOverflow = document.body.style.overflow;
const focusFrame = requestAnimationFrame(() => closeButtonRef.current?.focus());
document.body.style.overflow = "hidden";
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeOverlay();
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((element) => element.tabIndex >= 0);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
cancelAnimationFrame(focusFrame);
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", handleKeyDown);
};
}, [closeOverlay, isExpanded]);
const handleFolderClick = () => {
setIsClosing(false);
setExpanded(true);
setOpen(true);
onClick?.();
};
const overlay = isExpanded || isClosing ? (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={dialogTitleId}
aria-hidden={isExpanded ? undefined : "true"}
className={cn(
"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto sm:items-center",
isClosing && "pointer-events-none",
)}
>
<AnimatePresence initial={false}>
{isExpanded ? (
<motion.button
key="project-files-backdrop"
type="button"
tabIndex={-1}
aria-label="Close file overlay"
onClick={closeOverlay}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.18 }}
className="absolute inset-0 cursor-default bg-background/80 backdrop-blur-xl"
/>
) : null}
</AnimatePresence>
<div className="relative z-10 w-full max-w-5xl px-6 py-8">
<AnimatePresence initial={false}>
{isExpanded ? (
<motion.div
key="project-files-header"
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -8 }}
transition={reduce ? { duration: 0 } : { duration: 0.18 }}
className="mb-6 flex items-center justify-between gap-4"
>
<div>
<h2 id={dialogTitleId} className="text-xl font-medium text-foreground">
{title}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{countText}</p>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={closeOverlay}
aria-label={`Close ${title}`}
className="flex size-10 items-center justify-center rounded-full border border-foreground/10 bg-background/50 text-muted-foreground backdrop-blur-xl transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" aria-hidden="true" />
</button>
</motion.div>
) : null}
</AnimatePresence>
<div className="grid grid-cols-2 place-items-center gap-3 sm:grid-cols-3 lg:grid-cols-5">
{isExpanded
? previewItems.map((preview) => (
<motion.div
key={preview.id}
layoutId={`file-${preview.id}`}
transition={transition}
className="aspect-[3/4] w-full max-w-40 overflow-hidden rounded-xl border border-foreground/10 bg-background/50 backdrop-blur-xl"
>
{preview.content}
</motion.div>
))
: null}
</div>
</div>
</div>
) : null;
return (
<LayoutGroup id={layoutGroupId}>
<motion.button
ref={folderButtonRef}
type="button"
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="dialog"
aria-expanded={isExpanded}
data-open={isOpen ? "true" : "false"}
data-expanded={isExpanded ? "true" : "false"}
tabIndex={isExpanded ? -1 : undefined}
onPointerEnter={() => {
if (!canHover) return;
hoveredRef.current = true;
setOpen(true);
}}
onPointerLeave={() => {
if (!canHover) return;
hoveredRef.current = false;
if (!isExpanded && !isClosing) setOpen(focusedRef.current);
}}
onFocus={() => {
if (restoringFocusRef.current) {
restoringFocusRef.current = false;
focusedRef.current = false;
return;
}
focusedRef.current = true;
setOpen(true);
}}
onBlur={() => {
focusedRef.current = false;
if (!isExpanded && !isClosing) setOpen(hoveredRef.current);
}}
onClick={handleFolderClick}
whileTap={reduce || disabled ? undefined : { scale: 0.98 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className={cn(
"relative block h-56 w-72 select-none rounded-2xl text-left outline-none [perspective:1200px] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
<motion.span
aria-hidden="true"
animate={{ rotateX: isOpen && !reduce ? 15 : 0 }}
transition={transition}
className="absolute inset-0 rounded-2xl border border-foreground/10 bg-background/25 backdrop-blur-xl [transform-origin:center_bottom]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<span className="absolute left-1/2 top-0 block h-0 w-0">
<AnimatePresence initial={false}>
{!isExpanded
? previewItems.map((preview, index) => {
const opened = getPreviewTransform(
index,
previewItems.length,
);
return (
<motion.span
key={preview.id}
layoutId={`file-${preview.id}`}
initial={false}
animate={
isOpen && !reduce
? {
x: opened.x * 1.4,
y: opened.y - 8,
rotate: opened.rotate * 1.3,
scale: opened.scale * 1.02,
opacity: Math.min(1, opened.opacity + 0.18),
}
: {
x: opened.x,
y: opened.y,
rotate: opened.rotate,
scale: opened.scale,
opacity: opened.opacity,
}
}
transition={transition}
onLayoutAnimationComplete={() => {
if (isClosing && index === 0) finishClose();
}}
className="absolute left-0 top-0 -ml-12 block h-40 w-24 overflow-hidden rounded-lg border border-foreground/10 bg-background/45 backdrop-blur-lg"
style={{ zIndex: opened.zIndex }}
>
{preview.content}
</motion.span>
);
})
: null}
</AnimatePresence>
</span>
</span>
<motion.span
initial={false}
animate={{ rotateX: isOpen && !reduce ? -25 : 0 }}
transition={transition}
className="absolute inset-x-0 bottom-0 z-20 overflow-hidden rounded-2xl border border-foreground/10 bg-background/60 backdrop-blur-2xl [backface-visibility:hidden] [transform-origin:center_bottom]"
>
<span className="flex h-16 items-center px-4">
<span className="line-clamp-2 text-xl font-medium leading-tight text-foreground">
{title}
</span>
</span>
<span className="flex h-12 items-center justify-between gap-3 border-t border-foreground/10 px-4">
<span className="shrink-0 text-sm font-medium text-foreground/70">
{countText}
</span>
<span className="truncate text-sm text-muted-foreground">
{description}
</span>
</span>
</motion.span>
</motion.button>
{mounted ? createPortal(overlay, document.body) : null}
</LayoutGroup>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
titlestring—description?stringUpdated recentlypreviews?ProjectFolderPreview[][]count?numberpreviews.lengthitemLabel?stringfileopen?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—expanded?boolean—defaultExpanded?booleanfalseonExpandedChange?((expanded: boolean) => void)—onClick?(() => void)—disabled?booleanfalseariaLabel?string—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated