File Tree
Composable file and folder primitives with springing branches, a gliding selection, and complete keyboard navigation.
Preview
"use client";
import {
Braces,
FileCode2,
FileJson2,
FileText,
Palette,
} from "lucide-react";
import {
FileTree,
FileTreeFile,
FileTreeFolder,
} from "@/components/motion/file-tree";
export function FileTreePreview() {
return (
<div className="flex min-h-[420px] w-full items-start justify-center px-4 pt-10">
<div className="w-full max-w-xs p-2">
<FileTree
defaultValue="file-tree"
defaultExpandedIds={["app", "components"]}
ariaLabel="Project files"
>
<FileTreeFolder value="app" name="app">
<FileTreeFolder value="components" name="components">
<FileTreeFile
value="file-tree"
name="file-tree.tsx"
icon={<Braces className="size-4 text-sky-500" />}
/>
<FileTreeFile
value="button"
name="button.tsx"
icon={<Braces className="size-4 text-sky-500" />}
/>
</FileTreeFolder>
<FileTreeFile
value="page"
name="page.tsx"
icon={<FileCode2 className="size-4 text-sky-500" />}
/>
<FileTreeFile
value="styles"
name="globals.css"
icon={<Palette className="size-4 text-violet-500" />}
/>
</FileTreeFolder>
<FileTreeFolder value="public" name="public">
<FileTreeFile value="logo" name="logo.svg" />
<FileTreeFile value="grid" name="grid.svg" />
</FileTreeFolder>
<FileTreeFile
value="package"
name="package.json"
icon={<FileJson2 className="size-4 text-amber-500" />}
/>
<FileTreeFile
value="readme"
name="README.md"
icon={<FileText className="size-4 text-muted-foreground" />}
/>
</FileTree>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/file-tree
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
import {
Children,
Fragment,
useCallback,
isValidElement,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { SharedLayoutBg } from "@/components/motion/shared-layout-bg";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
type FileTreeItem = {
value: string;
name: string;
type: "file" | "folder";
children?: FileTreeItem[];
icon?: ReactNode;
disabled?: boolean;
className?: string;
};
export interface FileTreeFolderProps {
value: string;
name: string;
icon?: ReactNode;
disabled?: boolean;
children?: ReactNode;
className?: string;
}
export interface FileTreeFileProps {
value: string;
name: string;
icon?: ReactNode;
disabled?: boolean;
className?: string;
}
export type FileTreeClassNames = {
tree?: string;
item?: string;
icon?: string;
label?: string;
};
export interface FileTreeProps {
children: ReactNode;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string) => void;
expandedIds?: string[];
defaultExpandedIds?: string[];
onExpandedChange?: (expandedIds: string[]) => void;
ariaLabel?: string;
indent?: number;
className?: string;
classNames?: FileTreeClassNames;
}
type FlatFileTreeItem = {
item: FileTreeItem;
depth: number;
parentId: string | null;
position: number;
setSize: number;
};
// These declarative parts are read by FileTree and turned into one flattened,
// keyboard-navigable collection. They intentionally render nothing alone.
export function FileTreeFolder(_props: FileTreeFolderProps) {
return null;
}
export function FileTreeFile(_props: FileTreeFileProps) {
return null;
}
const ROW_ENTER = { duration: 0.22, ease: EASE_OUT } as const;
const BRANCH_DRAW = { duration: 0.3, ease: EASE_OUT } as const;
function flattenItems(
items: FileTreeItem[],
expanded: ReadonlySet<string>,
depth = 0,
parentId: string | null = null,
): FlatFileTreeItem[] {
return items.flatMap((item, index) => {
const row = {
item,
depth,
parentId,
position: index + 1,
setSize: items.length,
};
if (
item.type !== "folder" ||
!expanded.has(item.value) ||
!item.children?.length
) {
return [row];
}
return [
row,
...flattenItems(item.children, expanded, depth + 1, item.value),
];
});
}
function itemsFromChildren(children: ReactNode): FileTreeItem[] {
const items: FileTreeItem[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
if (child.type === Fragment) {
const props = child.props as { children?: ReactNode };
items.push(...itemsFromChildren(props.children));
return;
}
if (child.type === FileTreeFolder) {
const props = child.props as FileTreeFolderProps;
items.push({
value: props.value,
name: props.name,
type: "folder",
icon: props.icon,
disabled: props.disabled,
className: props.className,
children: itemsFromChildren(props.children),
});
return;
}
if (child.type === FileTreeFile) {
const props = child.props as FileTreeFileProps;
items.push({
value: props.value,
name: props.name,
type: "file",
icon: props.icon,
disabled: props.disabled,
className: props.className,
});
}
});
return items;
}
function DefaultIcon({
item,
open,
reduce,
}: {
item: FileTreeItem;
open: boolean;
reduce: boolean;
}) {
if (item.type === "file") return <File className="size-4" />;
if (reduce) {
return open ? (
<FolderOpen className="size-4" />
) : (
<Folder className="size-4" />
);
}
return (
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={open ? "open" : "closed"}
initial={{ opacity: 0, scale: 0.75, rotate: open ? -8 : 8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.75, rotate: open ? 8 : -8 }}
transition={SPRING_SWAP}
className="absolute inset-0 grid place-items-center"
>
{open ? (
<FolderOpen className="size-4" />
) : (
<Folder className="size-4" />
)}
</motion.span>
</AnimatePresence>
);
}
export function FileTree({
children,
value,
defaultValue = null,
onValueChange,
expandedIds,
defaultExpandedIds = [],
onExpandedChange,
ariaLabel = "Files",
indent = 18,
className,
classNames,
}: FileTreeProps) {
const reduce = useReducedMotion() ?? false;
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalExpandedIds, setInternalExpandedIds] = useState(
defaultExpandedIds,
);
const [focusedId, setFocusedId] = useState<string | null>(
value ?? defaultValue,
);
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const selectedId = value === undefined ? internalValue : value;
const currentExpandedIds = expandedIds ?? internalExpandedIds;
const expanded = useMemo(
() => new Set(currentExpandedIds),
[currentExpandedIds],
);
const items = useMemo(() => itemsFromChildren(children), [children]);
const rows = useMemo(() => flattenItems(items, expanded), [expanded, items]);
// Keep a real row tabbable in the first commit and immediately after a
// collapse removes the previously focused descendant.
const focusedRow =
focusedId !== null && rows.some(({ item }) => item.value === focusedId)
? focusedId
: (rows[0]?.item.value ?? null);
if (focusedId !== focusedRow) setFocusedId(focusedRow);
const focusRow = useCallback((id: string) => {
setFocusedId(id);
const row = rowRefs.current.get(id);
if (row) row.focus();
else requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
}, []);
const selectItem = useCallback(
(item: FileTreeItem) => {
if (item.disabled) return;
if (value === undefined) setInternalValue(item.value);
onValueChange?.(item.value);
},
[onValueChange, value],
);
const setExpanded = useCallback(
(next: string[]) => {
if (expandedIds === undefined) setInternalExpandedIds(next);
onExpandedChange?.(next);
},
[expandedIds, onExpandedChange],
);
const toggleFolder = useCallback(
(id: string) => {
const next = new Set(currentExpandedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setExpanded(Array.from(next));
},
[currentExpandedIds, setExpanded],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, row: FlatFileTreeItem) => {
const index = rows.findIndex(({ item }) => item.value === row.item.value);
const previous = rows[index - 1];
const next = rows[index + 1];
const isFolder = row.item.type === "folder";
const isOpen = expanded.has(row.item.value);
if (event.key === "ArrowDown" && next) {
event.preventDefault();
focusRow(next.item.value);
} else if (event.key === "ArrowUp" && previous) {
event.preventDefault();
focusRow(previous.item.value);
} else if (event.key === "Home" && rows[0]) {
event.preventDefault();
focusRow(rows[0].item.value);
} else if (event.key === "End" && rows.at(-1)) {
event.preventDefault();
focusRow(rows.at(-1)?.item.value ?? row.item.value);
} else if (event.key === "ArrowRight" && isFolder) {
event.preventDefault();
if (!isOpen && !row.item.disabled) toggleFolder(row.item.value);
else if (next?.parentId === row.item.value) focusRow(next.item.value);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
if (isFolder && isOpen && !row.item.disabled)
toggleFolder(row.item.value);
else if (row.parentId) focusRow(row.parentId);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (row.item.disabled) return;
selectItem(row.item);
if (isFolder) toggleFolder(row.item.value);
}
},
[expanded, focusRow, rows, selectItem, toggleFolder],
);
return (
<SharedLayoutBg
role="tree"
aria-label={ariaLabel}
aria-multiselectable="false"
inset={0}
pillClassName="rounded-xl bg-muted"
pillContainerClassName="inset-y-auto top-0 h-9"
className={cn("min-w-0", className, classNames?.tree)}
>
{rows.map((row) => {
const isFolder = row.item.type === "folder";
const isOpen = isFolder && expanded.has(row.item.value);
const isSelected = selectedId === row.item.value;
return (
<motion.div
layout={reduce ? false : "position"}
key={row.item.value}
initial={reduce ? false : { opacity: 0, y: -6 }}
animate={{
opacity: row.item.disabled ? 0.42 : 1,
y: 0,
transition: reduce
? { duration: 0 }
: {
...ROW_ENTER,
delay: Math.min(row.position * 0.025, 0.1),
},
}}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
>
<button
ref={(node) => {
if (node) rowRefs.current.set(row.item.value, node);
else rowRefs.current.delete(row.item.value);
}}
type="button"
role="treeitem"
aria-level={row.depth + 1}
aria-posinset={row.position}
aria-setsize={row.setSize}
aria-selected={isSelected}
aria-expanded={isFolder ? isOpen : undefined}
aria-disabled={row.item.disabled || undefined}
tabIndex={focusedRow === row.item.value ? 0 : -1}
onFocus={() => setFocusedId(row.item.value)}
onKeyDown={(event) => handleKeyDown(event, row)}
onClick={() => {
if (row.item.disabled) return;
selectItem(row.item);
if (isFolder) toggleFolder(row.item.value);
}}
className={cn(
"group/file-tree relative flex h-9 w-full items-center gap-2 overflow-hidden rounded-lg pr-2 text-left text-sm text-muted-foreground outline-none",
"transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"aria-disabled:cursor-not-allowed",
isSelected && "bg-muted font-medium text-foreground",
classNames?.item,
row.item.className,
)}
style={{ paddingLeft: 8 + row.depth * indent }}
>
{row.depth > 0 ? (
<motion.span
aria-hidden="true"
initial={reduce ? false : { opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
transition={reduce ? { duration: 0 } : BRANCH_DRAW}
className="absolute top-0 bottom-0 w-px origin-top bg-border/70"
style={{ left: 16 + (row.depth - 1) * indent }}
/>
) : null}
<motion.span
aria-hidden="true"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className={cn(
"relative z-10 grid size-4 shrink-0 place-items-center",
!isFolder && "opacity-0",
)}
>
<ChevronRight className="size-3.5" />
</motion.span>
<span
aria-hidden="true"
className={cn(
"relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground transition-colors group-hover/file-tree:text-foreground",
isFolder && isOpen && "text-foreground",
classNames?.icon,
)}
>
{row.item.icon ?? (
<DefaultIcon
item={row.item}
open={isOpen}
reduce={reduce}
/>
)}
</span>
<span
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
classNames?.label,
)}
>
{row.item.name}
</span>
</button>
</motion.div>
);
})}
</SharedLayoutBg>
);
}
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;
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";
// beui.dev/components/motion/file-tree
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
import {
Children,
Fragment,
useCallback,
isValidElement,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { SharedLayoutBg } from "@/components/motion/shared-layout-bg";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
type FileTreeItem = {
value: string;
name: string;
type: "file" | "folder";
children?: FileTreeItem[];
icon?: ReactNode;
disabled?: boolean;
className?: string;
};
export interface FileTreeFolderProps {
value: string;
name: string;
icon?: ReactNode;
disabled?: boolean;
children?: ReactNode;
className?: string;
}
export interface FileTreeFileProps {
value: string;
name: string;
icon?: ReactNode;
disabled?: boolean;
className?: string;
}
export type FileTreeClassNames = {
tree?: string;
item?: string;
icon?: string;
label?: string;
};
export interface FileTreeProps {
children: ReactNode;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string) => void;
expandedIds?: string[];
defaultExpandedIds?: string[];
onExpandedChange?: (expandedIds: string[]) => void;
ariaLabel?: string;
indent?: number;
className?: string;
classNames?: FileTreeClassNames;
}
type FlatFileTreeItem = {
item: FileTreeItem;
depth: number;
parentId: string | null;
position: number;
setSize: number;
};
// These declarative parts are read by FileTree and turned into one flattened,
// keyboard-navigable collection. They intentionally render nothing alone.
export function FileTreeFolder(_props: FileTreeFolderProps) {
return null;
}
export function FileTreeFile(_props: FileTreeFileProps) {
return null;
}
const ROW_ENTER = { duration: 0.22, ease: EASE_OUT } as const;
const BRANCH_DRAW = { duration: 0.3, ease: EASE_OUT } as const;
function flattenItems(
items: FileTreeItem[],
expanded: ReadonlySet<string>,
depth = 0,
parentId: string | null = null,
): FlatFileTreeItem[] {
return items.flatMap((item, index) => {
const row = {
item,
depth,
parentId,
position: index + 1,
setSize: items.length,
};
if (
item.type !== "folder" ||
!expanded.has(item.value) ||
!item.children?.length
) {
return [row];
}
return [
row,
...flattenItems(item.children, expanded, depth + 1, item.value),
];
});
}
function itemsFromChildren(children: ReactNode): FileTreeItem[] {
const items: FileTreeItem[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
if (child.type === Fragment) {
const props = child.props as { children?: ReactNode };
items.push(...itemsFromChildren(props.children));
return;
}
if (child.type === FileTreeFolder) {
const props = child.props as FileTreeFolderProps;
items.push({
value: props.value,
name: props.name,
type: "folder",
icon: props.icon,
disabled: props.disabled,
className: props.className,
children: itemsFromChildren(props.children),
});
return;
}
if (child.type === FileTreeFile) {
const props = child.props as FileTreeFileProps;
items.push({
value: props.value,
name: props.name,
type: "file",
icon: props.icon,
disabled: props.disabled,
className: props.className,
});
}
});
return items;
}
function DefaultIcon({
item,
open,
reduce,
}: {
item: FileTreeItem;
open: boolean;
reduce: boolean;
}) {
if (item.type === "file") return <File className="size-4" />;
if (reduce) {
return open ? (
<FolderOpen className="size-4" />
) : (
<Folder className="size-4" />
);
}
return (
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={open ? "open" : "closed"}
initial={{ opacity: 0, scale: 0.75, rotate: open ? -8 : 8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.75, rotate: open ? 8 : -8 }}
transition={SPRING_SWAP}
className="absolute inset-0 grid place-items-center"
>
{open ? (
<FolderOpen className="size-4" />
) : (
<Folder className="size-4" />
)}
</motion.span>
</AnimatePresence>
);
}
export function FileTree({
children,
value,
defaultValue = null,
onValueChange,
expandedIds,
defaultExpandedIds = [],
onExpandedChange,
ariaLabel = "Files",
indent = 18,
className,
classNames,
}: FileTreeProps) {
const reduce = useReducedMotion() ?? false;
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalExpandedIds, setInternalExpandedIds] = useState(
defaultExpandedIds,
);
const [focusedId, setFocusedId] = useState<string | null>(
value ?? defaultValue,
);
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const selectedId = value === undefined ? internalValue : value;
const currentExpandedIds = expandedIds ?? internalExpandedIds;
const expanded = useMemo(
() => new Set(currentExpandedIds),
[currentExpandedIds],
);
const items = useMemo(() => itemsFromChildren(children), [children]);
const rows = useMemo(() => flattenItems(items, expanded), [expanded, items]);
// Keep a real row tabbable in the first commit and immediately after a
// collapse removes the previously focused descendant.
const focusedRow =
focusedId !== null && rows.some(({ item }) => item.value === focusedId)
? focusedId
: (rows[0]?.item.value ?? null);
if (focusedId !== focusedRow) setFocusedId(focusedRow);
const focusRow = useCallback((id: string) => {
setFocusedId(id);
const row = rowRefs.current.get(id);
if (row) row.focus();
else requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
}, []);
const selectItem = useCallback(
(item: FileTreeItem) => {
if (item.disabled) return;
if (value === undefined) setInternalValue(item.value);
onValueChange?.(item.value);
},
[onValueChange, value],
);
const setExpanded = useCallback(
(next: string[]) => {
if (expandedIds === undefined) setInternalExpandedIds(next);
onExpandedChange?.(next);
},
[expandedIds, onExpandedChange],
);
const toggleFolder = useCallback(
(id: string) => {
const next = new Set(currentExpandedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setExpanded(Array.from(next));
},
[currentExpandedIds, setExpanded],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, row: FlatFileTreeItem) => {
const index = rows.findIndex(({ item }) => item.value === row.item.value);
const previous = rows[index - 1];
const next = rows[index + 1];
const isFolder = row.item.type === "folder";
const isOpen = expanded.has(row.item.value);
if (event.key === "ArrowDown" && next) {
event.preventDefault();
focusRow(next.item.value);
} else if (event.key === "ArrowUp" && previous) {
event.preventDefault();
focusRow(previous.item.value);
} else if (event.key === "Home" && rows[0]) {
event.preventDefault();
focusRow(rows[0].item.value);
} else if (event.key === "End" && rows.at(-1)) {
event.preventDefault();
focusRow(rows.at(-1)?.item.value ?? row.item.value);
} else if (event.key === "ArrowRight" && isFolder) {
event.preventDefault();
if (!isOpen && !row.item.disabled) toggleFolder(row.item.value);
else if (next?.parentId === row.item.value) focusRow(next.item.value);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
if (isFolder && isOpen && !row.item.disabled)
toggleFolder(row.item.value);
else if (row.parentId) focusRow(row.parentId);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (row.item.disabled) return;
selectItem(row.item);
if (isFolder) toggleFolder(row.item.value);
}
},
[expanded, focusRow, rows, selectItem, toggleFolder],
);
return (
<SharedLayoutBg
role="tree"
aria-label={ariaLabel}
aria-multiselectable="false"
inset={0}
pillClassName="rounded-xl bg-muted"
pillContainerClassName="inset-y-auto top-0 h-9"
className={cn("min-w-0", className, classNames?.tree)}
>
{rows.map((row) => {
const isFolder = row.item.type === "folder";
const isOpen = isFolder && expanded.has(row.item.value);
const isSelected = selectedId === row.item.value;
return (
<motion.div
layout={reduce ? false : "position"}
key={row.item.value}
initial={reduce ? false : { opacity: 0, y: -6 }}
animate={{
opacity: row.item.disabled ? 0.42 : 1,
y: 0,
transition: reduce
? { duration: 0 }
: {
...ROW_ENTER,
delay: Math.min(row.position * 0.025, 0.1),
},
}}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
>
<button
ref={(node) => {
if (node) rowRefs.current.set(row.item.value, node);
else rowRefs.current.delete(row.item.value);
}}
type="button"
role="treeitem"
aria-level={row.depth + 1}
aria-posinset={row.position}
aria-setsize={row.setSize}
aria-selected={isSelected}
aria-expanded={isFolder ? isOpen : undefined}
aria-disabled={row.item.disabled || undefined}
tabIndex={focusedRow === row.item.value ? 0 : -1}
onFocus={() => setFocusedId(row.item.value)}
onKeyDown={(event) => handleKeyDown(event, row)}
onClick={() => {
if (row.item.disabled) return;
selectItem(row.item);
if (isFolder) toggleFolder(row.item.value);
}}
className={cn(
"group/file-tree relative flex h-9 w-full items-center gap-2 overflow-hidden rounded-lg pr-2 text-left text-sm text-muted-foreground outline-none",
"transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"aria-disabled:cursor-not-allowed",
isSelected && "bg-muted font-medium text-foreground",
classNames?.item,
row.item.className,
)}
style={{ paddingLeft: 8 + row.depth * indent }}
>
{row.depth > 0 ? (
<motion.span
aria-hidden="true"
initial={reduce ? false : { opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
transition={reduce ? { duration: 0 } : BRANCH_DRAW}
className="absolute top-0 bottom-0 w-px origin-top bg-border/70"
style={{ left: 16 + (row.depth - 1) * indent }}
/>
) : null}
<motion.span
aria-hidden="true"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className={cn(
"relative z-10 grid size-4 shrink-0 place-items-center",
!isFolder && "opacity-0",
)}
>
<ChevronRight className="size-3.5" />
</motion.span>
<span
aria-hidden="true"
className={cn(
"relative z-10 grid size-4 shrink-0 place-items-center text-muted-foreground transition-colors group-hover/file-tree:text-foreground",
isFolder && isOpen && "text-foreground",
classNames?.icon,
)}
>
{row.item.icon ?? (
<DefaultIcon
item={row.item}
open={isOpen}
reduce={reduce}
/>
)}
</span>
<span
className={cn(
"relative z-10 min-w-0 flex-1 truncate",
classNames?.label,
)}
>
{row.item.name}
</span>
</button>
</motion.div>
);
})}
</SharedLayoutBg>
);
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
Children,
cloneElement,
forwardRef,
type HTMLAttributes,
isValidElement,
type MouseEvent,
type ReactElement,
type ReactNode,
type Ref,
useId,
useState,
} from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SharedLayoutBgProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
children: ReactNode;
/** Semantic container used for the children. */
as?: "div" | "ul";
/** Tailwind class applied to the moving pill. Defaults to a subtle foreground tint. */
pillClassName?: string;
/** Horizontal inset of the pill relative to each row (px). Default 20. */
inset?: number;
/** Optional positioning override for the pill wrapper inside each item. */
pillContainerClassName?: string;
}
const variants: Variants = {
initial: { opacity: 0, filter: "blur(6px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: (isActive: boolean) =>
!isActive ? { opacity: 0, filter: "blur(6px)" } : {},
};
const reducedVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: (isActive: boolean) => (!isActive ? { opacity: 0 } : {}),
};
export const SharedLayoutBg = forwardRef<HTMLElement, SharedLayoutBgProps>(
function SharedLayoutBg(
{
children,
as = "div",
className,
onMouseLeave,
pillClassName,
pillContainerClassName,
inset = 20,
...props
},
forwardedRef,
) {
const [activeId, setActiveId] = useState<string | null>(null);
const uid = useId();
const reduce = useReducedMotion();
const renderedChildren = Children.toArray(children)
.filter(isValidElement)
.map((child, index) => {
const el = child as ReactElement<{
className?: string;
onMouseEnter?: () => void;
children?: ReactNode;
}>;
const childKey = el.key ? String(el.key) : `item-${index}`;
return cloneElement(
el,
{
key: childKey,
className: cn("relative", el.props.className),
onMouseEnter: () => {
el.props.onMouseEnter?.();
setActiveId(childKey);
},
},
<>
<AnimatePresence custom={activeId !== null}>
{activeId !== null ? (
<motion.div
variants={reduce ? reducedVariants : variants}
initial="initial"
animate="animate"
exit="exit"
custom={activeId !== null}
className={cn(
"pointer-events-none absolute inset-y-0",
pillContainerClassName,
)}
style={{ left: -inset, right: -inset }}
>
{activeId === childKey ? (
<motion.div
layoutId={`shared-bg-${uid}`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"pointer-events-none h-full w-full rounded-2xl bg-muted/80",
pillClassName,
)}
/>
) : null}
</motion.div>
) : null}
</AnimatePresence>
<div className="relative z-10">{el.props.children}</div>
</>,
);
});
const handleMouseLeave = (event: MouseEvent<HTMLElement>) => {
setActiveId(null);
onMouseLeave?.(event);
};
// layoutRoot scopes the pill's layout projection to this list, so fixed or
// scrolled ancestors can't smear scroll offsets into its movement.
return as === "ul" ? (
<motion.ul
{...(props as HTMLMotionProps<"ul">)}
ref={forwardedRef as Ref<HTMLUListElement>}
layoutRoot
onMouseLeave={handleMouseLeave}
className={cn("flex w-full flex-col", className)}
>
{renderedChildren}
</motion.ul>
) : (
<motion.div
{...(props as HTMLMotionProps<"div">)}
ref={forwardedRef as Ref<HTMLDivElement>}
layoutRoot
onMouseLeave={handleMouseLeave}
className={cn("flex w-full flex-col", className)}
>
{renderedChildren}
</motion.div>
);
},
);
API Reference
FileTreeFolder
valuestring—namestring—icon?ReactNode—disabled?boolean—className?string—FileTreeFile
valuestring—namestring—icon?ReactNode—disabled?boolean—className?string—FileTree
value?string | null—defaultValue?string | nullnullonValueChange?((value: string) => void)—expandedIds?string[]—defaultExpandedIds?string[][]onExpandedChange?((expandedIds: string[]) => void)—ariaLabel?stringFilesindent?number18className?string—classNames?FileTreeClassNames—Related components
Animated Context Menu
Composable context-menu primitives with a pointer-origin clip morph, a gliding active row, checkbox and radio choices, keyboard navigation, typeahead, and long-press support.
Image Viewer
A composable React image viewer for galleries and attachment previews, with animated lightbox variants, custom thumbnails and controls, swipe and keyboard navigation, zoom and pan, accessible focus management, and controlled or uncontrolled selection.
Animated Color Selector
Composable color swatches with a spring-gliding selection ring, press feedback, native radio keyboard navigation, and controlled or uncontrolled state.
Updated