srcM
main.tsx
App.tsxM
index.css
components
uiM
button.tsx
card.tsxM
toast.tsxA
header.tsx
footer.tsx
hooksU
lib
public
.envI
.eslintrc.cjs
index.html
package.jsonM
tsconfig.json
vite.config.ts
1'use client';23import { Files } from '@/components/ui/files';45export function Default() {6 return (7 <Files defaultValue="src/components/ui">8 <Files.Folder name="src" status="modified">9 <Files.File name="main.tsx" />10 <Files.File name="App.tsx" status="modified" />11 <Files.File name="index.css" />1213 <Files.Folder name="components">14 <Files.Folder name="ui" status="modified">15 <Files.File name="button.tsx" />16 <Files.File name="card.tsx" status="modified" />17 <Files.File name="toast.tsx" status="added" />18 </Files.Folder>1920 <Files.File name="header.tsx" />21 <Files.File name="footer.tsx" />22 </Files.Folder>2324 <Files.Folder name="hooks" status="untracked">25 <Files.File name="use-auth.ts" status="untracked" />26 <Files.File name="use-toast.ts" status="untracked" />27 </Files.Folder>2829 <Files.Folder name="lib">30 <Files.File name="cn.ts" />31 <Files.File name="utils.ts" />32 </Files.Folder>33 </Files.Folder>3435 <Files.Folder name="public">36 <Files.File name="favicon.svg" />37 </Files.Folder>3839 <Files.File name=".env" status="ignored" />40 <Files.File name=".eslintrc.cjs" />41 <Files.File name="index.html" />42 <Files.File name="package.json" status="modified" />43 <Files.File name="tsconfig.json" />44 <Files.File name="vite.config.ts" />45 </Files>46 );47}
Installation
Copy and paste the following code in your project.
'use client';import {ArrowRight01Icon,File01Icon,Folder01Icon,Folder02Icon,} from '@hugeicons/core-free-icons';import { HugeiconsIcon } from '@hugeicons/react';import { AnimatePresence, motion } from 'motion/react';import React from 'react';import { cn } from '../lib/cn';// --- Types ---export type GitStatus = 'modified' | 'deleted' | 'added' | 'untracked' | 'renamed' | 'ignored';// --- Constants (module level) ---const GIT_STATUS_STYLES: Record<GitStatus, { color: string; letter: string }> = {modified: { color: 'text-yellow-500', letter: 'M' },deleted: { color: 'text-red-500 line-through opacity-70', letter: 'D' },added: { color: 'text-green-500', letter: 'A' },untracked: { color: 'text-green-500', letter: 'U' },renamed: { color: 'text-blue-500', letter: 'R' },ignored: { color: 'text-muted-foreground opacity-50', letter: 'I' },};const FILE_HOVER = { x: 4, backgroundColor: 'rgba(0, 0, 0, 0.05)' } as const;const FILE_TAP = { scale: 0.98 } as const;const FILE_TRANSITION = { duration: 0.15 } as const;const FILE_STYLE = { willChange: 'transform' } as const;const FOLDER_CHEVRON_OPEN = { rotate: 90 } as const;const FOLDER_CHEVRON_CLOSED = { rotate: 0 } as const;const FOLDER_CHEVRON_TRANSITION = { duration: 0.2, ease: 'easeInOut' } as const;const FOLDER_ICON_OPEN = { scale: 1.1 } as const;const FOLDER_ICON_CLOSED = { scale: 1 } as const;const FOLDER_ICON_TRANSITION = { duration: 0.2 } as const;const FOLDER_CONTENT_VARIANTS = {open: { height: 'auto', opacity: 1 },closed: { height: 0, opacity: 0 },} as const;const FOLDER_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeInOut' } as const;const FOLDER_CONTENT_STYLE = { willChange: 'height, opacity' } as const;// --- Context ---interface FilesContextValue {openFolders: Set<string>;toggleFolder: (path: string) => void;}const FilesContext = React.createContext<FilesContextValue | null>(null);const useFiles = () => {const context = React.use(FilesContext);if (!context) {throw new Error('File components must be used within Files');}return context;};const FolderPathContext = React.createContext<string>('');// --- Components ---export type FileProps = {name: string;className?: string;onClick?: () => void;status?: GitStatus;};const File: React.FC<FileProps> = ({ name, className, onClick, status }) => {const statusConfig = status ? GIT_STATUS_STYLES[status] : undefined;const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault();onClick?.();}};return (<motion.divrole="button"tabIndex={0}onClick={onClick}onKeyDown={handleKeyDown}whileHover={FILE_HOVER}whileTap={FILE_TAP}transition={FILE_TRANSITION}style={FILE_STYLE}className={cn('flex cursor-pointer items-center space-x-2 rounded px-2 py-1 text-sm transition-colors',className,)}><span className={cn('text-muted-foreground', statusConfig?.color)}><HugeiconsIcon icon={File01Icon} className="size-4" size={16} /></span><span className={cn('flex-1 truncate', statusConfig?.color)}>{name}</span>{statusConfig ? (<span className={cn('ml-auto w-4 text-center text-xs font-bold', statusConfig.color)}>{statusConfig.letter}</span>) : null}</motion.div>);};export type FolderProps = {name: string;children?: React.ReactNode;className?: string;path?: string;status?: GitStatus;};const Folder: React.FC<FolderProps> = ({name,children,className,path: externalPath,status,}) => {const { openFolders, toggleFolder } = useFiles();const parentPath = React.use(FolderPathContext);const currentPath = externalPath || (parentPath ? `${parentPath}/${name}` : name);const isOpen = openFolders.has(currentPath);const hasChildren = React.Children.count(children) > 0;const statusConfig = status ? GIT_STATUS_STYLES[status] : undefined;const handleToggle = () => {if (hasChildren) {toggleFolder(currentPath);}};const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {if (hasChildren && (e.key === 'Enter' || e.key === ' ')) {e.preventDefault();toggleFolder(currentPath);}};return (<div className={className}><motion.divrole="button"tabIndex={0}aria-expanded={hasChildren ? isOpen : undefined}onClick={handleToggle}onKeyDown={handleKeyDown}whileHover={hasChildren ? { x: 2 } : {}}whileTap={hasChildren ? { scale: 0.98 } : {}}className={cn('hover:bg-muted flex items-center rounded px-2 py-1 text-sm font-medium transition-colors',hasChildren && 'cursor-pointer',)}>{hasChildren ? (<motion.divanimate={isOpen ? FOLDER_CHEVRON_OPEN : FOLDER_CHEVRON_CLOSED}transition={FOLDER_CHEVRON_TRANSITION}><HugeiconsIconicon={ArrowRight01Icon}className="text-muted-foreground mr-1 size-4 shrink-0"size={16}/></motion.div>) : (<span className="w-5" />)}<motion.divanimate={isOpen ? FOLDER_ICON_OPEN : FOLDER_ICON_CLOSED}transition={FOLDER_ICON_TRANSITION}className={cn('text-muted-foreground', statusConfig?.color)}>{isOpen ? (<HugeiconsIcon icon={Folder02Icon} className="size-4" size={16} />) : (<HugeiconsIcon icon={Folder01Icon} className="size-4" size={16} />)}</motion.div><span className={cn('ml-2 flex-1 truncate', statusConfig?.color)}>{name}</span>{statusConfig ? (<span className={cn('ml-auto w-4 text-center text-xs font-bold', statusConfig.color)}>{statusConfig.letter}</span>) : null}</motion.div><AnimatePresence initial={false}>{hasChildren && isOpen && (<motion.divvariants={FOLDER_CONTENT_VARIANTS}initial="closed"animate="open"exit="closed"transition={FOLDER_CONTENT_TRANSITION}style={FOLDER_CONTENT_STYLE}className="overflow-hidden"><div className="border-border mt-1 ml-2 border-l pl-4"><FolderPathContext value={currentPath}>{children}</FolderPathContext></div></motion.div>)}</AnimatePresence></div>);};type FilesProps = {defaultValue?: string;children: React.ReactNode;className?: string;};const FilesRoot: React.FC<FilesProps> = ({ children, defaultValue, className }) => {const [openFolders, setOpenFolders] = React.useState<Set<string>>(() => {if (!defaultValue) return new Set();const paths = defaultValue.split('/');const fullPaths = new Set<string>();let currentPath = '';paths.forEach((segment) => {currentPath = currentPath ? `${currentPath}/${segment}` : segment;fullPaths.add(currentPath);});return fullPaths;});const toggleFolder = React.useCallback((path: string) => {setOpenFolders((prev) => {const next = new Set(prev);if (next.has(path)) {next.delete(path);} else {next.add(path);}return next;});}, []);const contextValue = React.useMemo(() => ({ openFolders, toggleFolder }),[openFolders, toggleFolder],);return (<FilesContext value={contextValue}><div className={cn('w-full min-w-[250px]', className)}>{children}</div></FilesContext>);};const Files = Object.assign(FilesRoot, {Folder,File,});export { Files };
Make sure to update the import paths according to your project structure.
Anatomy
import { Files } from '@/components/ui/files';
<Files defaultValue="src/components"><Files.Folder name="src"><Files.Folder name="components"><Files.File name="button.tsx" /><Files.File name="card.tsx" /></Files.Folder><Files.File name="index.ts" /></Files.Folder><Files.File name="package.json" /></Files>
Features
- Expandable folders - Click to expand/collapse nested structures
- Git status indicators - Show file modification states
- Smooth animations - Animated expand/collapse transitions
- Auto-expand - Use
defaultValueto expand specific paths on load
API Reference
Files
Root container for the file tree.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | - | Path to expand by default (e.g., "src/components") |
className | string | - | Additional CSS classes |
Folder
Represents a folder that can contain files and other folders.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | - | Folder name |
path | string | - | Optional explicit path |
status | GitStatus | - | Git status indicator |
className | string | - | Additional CSS classes |
children | ReactNode | - | Nested files and folders |
File
Represents an individual file.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | - | File name |
onClick | () => void | - | Click handler |
status | GitStatus | - | Git status indicator |
className | string | - | Additional CSS classes |
Git Status Types
Available status values:
'modified' | 'deleted' | 'added' | 'untracked' | 'renamed' | 'ignored'Each status has a unique color and letter indicator:
- M (Modified) - Yellow
- D (Deleted) - Red with strikethrough
- A (Added) - Green
- U (Untracked) - Green
- R (Renamed) - Blue
- I (Ignored) - Gray