Right-click the folder, or a file once it fans out
npx shadcn@latest add https://sdk-components.thesqd.com/r/project-folder.jsonimport { ProjectFolder } from "@/components/ui/project-folder";"use client";
import { ProjectFolder } from "@/components/ui/project-folder";
// Hand it file URLs: images render as cover thumbnails, PDFs as their first
// page (react-pdf, loaded on demand — the host must send CORS headers),
// everything else as a type tile (INDD, AI…). Each one cross-fades in from
// its type tile once it paints. Pass `content` instead for a custom tile.
// The demo reads real rows from `files.files` via /api/previous-submissions.
const previews = files.map((f) => ({ id: f.url, src: f.url, name: f.name }));
export function Example() {
return (
<ProjectFolder
title="Merch Apparel"
updatedAt={submission.submittedAt}
previews={previews}
onExpandedChange={(expanded) => {
// Fires when the overlay opens and again when it finishes closing.
console.log(expanded ? "opened" : "closed");
}}
/>
);
}ProjectFolder (single component — pass everything as props)
├── back wall (tilts away on hover/focus)
├── preview fan (up to 5 previews, splayed from centre)
│ └── previews[n] (src → image thumb or type tile; content overrides)
├── front wall (title + count + updatedLabel/updatedAt)
├── context menu (optional — right-click, t-dropdown)
└── portal → dialog (rendered into document.body on click)
├── backdrop (click to dismiss)
├── header (title, count, close button)
└── grid (2 / 3 / 5 cols — each preview morphs in
from its fanned position via layoutId)Right-click the folder, or a file once it fans out
ProjectFolder
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Folder name on the front wall and in the overlay header. |
titleHref | string | — | Makes the open folder's title a link to what the folder is of (its project), opened in a new tab so the folder stays open. |
updatedAt | Date | string | number | — | When the folder last changed. Rendered relative on the meta row — "Updated 48d ago". Omitted, it reads "Updated recently". |
updatedLabel | string | "Updated" | The word in front of the relative time — "Edited", "Submitted", "Uploaded"… |
description | string | — | Replaces the whole meta line (label and time) with your own text. |
loading | boolean | false | Set while the folder's data is being fetched. The title, count and updated line give way to boneyard skeleton bones (captured from the real labels, shipped inline) while the card shell stays drawn; hover, expand and the context menu are blocked. When it flips false the bones fade out and the count rolls up to the real total. |
contextMenu | ContextMenuItemSpec[] | — | Right-click menu. Same item shape as `ContextMenu` / `DropdownMenu`: `{ label, icon?, shortcut?, onClick?, variant?, disabled? }`, `"separator"`, or `{ heading }`. Opens with the SDK's t-dropdown transition. |
onContextMenu | () => void | — | Fires on right-click, before the menu opens. |
previewContextMenu | ContextMenuItemSpec[] | (preview, index) => ContextMenuItemSpec[] | — | Right-click menu for a single file, on a fanned-out file and on its card in the overlay grid. An array applies to every file; a function varies it per file. It nests inside the folder's menu — a file opens only its own — and an empty array falls through to the folder's. Tucked-in files aren't targets until the fan is out, and the fan holds open while a file's menu is up. |
onPreviewClick | (preview: ProjectFolderPreview, index: number) => void | — | Makes each file clickable. Fires from a fanned-out file — the click doesn't also expand the folder — and from the overlay grid, where every file is a real button for keyboard and screen-reader users. Navigate, open a viewer, anything. |
renderPreviewDetail | (preview: ProjectFolderPreview, index: number) => ReactNode | — | Opens a file inside the overlay instead of handing it off. The clicked file grows to the top on the transitions-dev card-resize curve, the others fade down into a strip under it (click one to switch), and what this returns slides in beside it with the transitions-dev panel reveal. With it, overlay clicks select; the fan still fires `onPreviewClick`. Clicking the open file or Escape goes back to the grid. |
renderPreviewOpen | (preview: ProjectFolderPreview, index: number) => ReactNode | — | What the open file shows once it has grown into place, instead of its thumbnail — a viewer, such as a PDF's pages. The file grows as its thumbnail (the same tween every file makes), then this takes over the slot. Return null to keep the thumbnail. Needs `renderPreviewDetail`. |
selectedPreviewId | string | null | — | The file open in the overlay, by id — controlled. Looked up in every preview, not only the five the grid shows. Omit it and the folder keeps the selection itself. |
onSelectedPreviewChange | (preview: ProjectFolderPreview | null) => void | — | A file was opened, or `null` when the view goes back to the grid. Not called when the overlay closes; `onExpandedChange` reports that. |
previews | ProjectFolderPreview[] | [] | The folder's files. The first 5 fan out of the closed folder; the open folder shows every one (a file with no `src` is its type tile). Pass `count` when the folder holds more than you pass. |
count | number | previews.length | Number shown in the count label. |
itemLabel | string | "file" | Singular noun for the count label — pluralised with "s". |
open | boolean | — | Controls the fanned-out (hover/focus) state. Uncontrolled by default. |
defaultOpen | boolean | false | Initial fan state when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Fires when the fan opens or collapses. |
expanded | boolean | — | Controls the full-screen overlay. Uncontrolled by default. |
defaultExpanded | boolean | false | Initial overlay state when uncontrolled. |
onExpandedChange | (expanded: boolean) => void | — | Fires when the overlay opens or closes. |
onClick | () => void | — | Extra handler run alongside opening the overlay. |
disabled | boolean | false | Dims the card and blocks hover, focus, and expansion. |
ariaLabel | string | — | Accessible name for the folder button. |
className | string | — | Override the card's size or layout classes. |
ProjectFolderPreview
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable key — also drives the shared `layoutId` that morphs the tile from fan to grid. |
src | string | — | File URL. Images (png, jpg, webp, gif, avif, svg) render as a cover thumbnail; `.pdf` renders page 1 via react-pdf, loaded on first use with range requests so large files only fetch what page 1 needs (the host must send CORS headers — otherwise, or on any error, it falls back to the tile); anything else renders as a tile showing the file type and name. |
name | string | last segment of src | File name — alt text, tile label, the hover tooltip over the fanned file, and where the type is read from. |
content | ReactNode | — | Custom tile for both the fan and the overlay card — wins over `src`. Use it for video posters or PDF first pages. |
aspect | number | — | width ÷ height. The shape the file is drawn in once the folder is open — a masonry of real shapes, the strip at one height, the open file as large as fits the row and 60% of the screen's height — so nothing is cropped. Leave it out and the folder reads it off the thumbnail once it loads (4:5 until then). |