Project Folder
An interactive project folder that opens its file fan on hover or focus, expands into a focus-managed overlay, then retraces the complete path when closed.
Preview
TSXcomponents/previews/blocks/project-folder.preview.tsx
"use client";
import { ProjectFolder } from "@/components/motion/project-folder";
const previews = [
{ id: "moss", color: "bg-emerald-200", mark: "A" },
{ id: "clay", color: "bg-orange-200", mark: "B" },
{ id: "sky", color: "bg-sky-200", mark: "C" },
{ id: "lilac", color: "bg-violet-200", mark: "D" },
{ id: "sand", color: "bg-amber-100", mark: "E" },
].map((preview) => ({
id: preview.id,
content: (
<span className={cn("relative block h-full w-full", preview.color)}>
<span className="absolute left-3 top-3 h-2 w-8 rounded-full bg-black/15" />
<span className="absolute inset-x-3 top-8 h-px bg-black/10" />
<span className="absolute inset-x-3 top-11 h-px bg-black/10" />
<span className="absolute bottom-3 right-3 text-sm font-medium text-black/50">
{preview.mark}
</span>
</span>
),
}));
function cn(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}
export function ProjectFolderPreview() {
return (
<div className="flex min-h-80 w-full items-center justify-center px-6 py-10">
<ProjectFolder
title="Brand direction"
description="Updated recently"
count={5}
previews={previews}
onClick={() => {}}
/>
</div>
);
}
TSXcomponents/motion/project-folder.tsx
"use client";
// beui.dev/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>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/project-folder
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/hooks/use-hover-capable.ts
"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;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/project-folder.tsx
"use client";
// beui.dev/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>
);
}
API Reference
titlestring—description?stringUpdated recentlypreviews?{}[]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