diff --git a/apps/www/content/docs/ui/file-tree.mdx b/apps/www/content/docs/ui/file-tree.mdx
new file mode 100644
index 00000000..86a495f0
--- /dev/null
+++ b/apps/www/content/docs/ui/file-tree.mdx
@@ -0,0 +1,123 @@
+---
+title: File Tree
+description: Renders a hierarchical structure of files and folders.
+links:
+ doc: https://www.radix-ui.com/docs/primitives/components/collapsible
+ api: https://www.radix-ui.com/docs/primitives/components/collapsible#api-reference
+dependencies: ['@radix-ui/react-collapsible']
+---
+
+
+```json doc-gen:file
+{
+ "file": "./src/components/demos/FileTree/FileTree.tsx",
+ "codeblock": true
+}
+```
+
+
+## Installation
+
+
+
+ ### Install the primitive
+
+ Install the `@radix-ui/react-collapsible` package.
+
+ ```package-install
+ @radix-ui/react-collapsible
+ ```
+
+
+
+
+ ### Copy-paste the component
+
+ Copy and paste the component code in a `.tsx` file.
+
+ ```json doc-gen:file
+ {
+ "file": "../../packages/ui/src/file-tree.tsx",
+ "codeblock": true
+ }
+ ```
+
+
+
+
+ ### Update import paths
+
+ Update the `@kosori/ui` import paths to fit your project structure, for example, using `~/components/ui`.
+
+
+
+
+ ### Add keyframes animation
+
+ Add the `collapsible-down` and `collapsible-up` keyframes animation to your Tailwind CSS config.
+
+ ```ts title="tailwind.config.ts"
+ import type { Config } from 'tailwindcss';
+
+ const config: Config = {
+ // ...,
+ keyframes: {
+ // [!code highlight:9]
+ 'collapsible-down': {
+ from: { height: '0' },
+ to: { height: 'var(--radix-collapsible-content-height)' },
+ },
+ 'collapsible-up': {
+ from: { height: 'var(--radix-collapsible-content-height)' },
+ to: { height: '0' },
+ },
+ },
+ animation: {
+ // [!code highlight:3]
+ 'collapsible-down': 'collapsible-down 0.15s ease-out',
+ 'collapsible-up': 'collapsible-up 0.15s ease-out',
+ }
+ };
+ ```
+
+
+
+
+
+## Usage
+
+```ts
+import {
+ File,
+ FileTree,
+ Folder,
+ FolderFiles,
+ FolderName,
+} from '~/components/ui/file-tree';
+```
+
+```tsx
+
+
+ app
+
+ layout.tsx
+ page.tsx
+
+
+
+```
+
+## Reference
+
+### File
+
+`,
+ description: 'Optional icon to display next to the file name.',
+ type: 'ReactNode',
+ },
+ }}
+/>
diff --git a/apps/www/src/components/ComponentPreview/Components.tsx b/apps/www/src/components/ComponentPreview/Components.tsx
index 3027a36d..0fbc4a83 100644
--- a/apps/www/src/components/ComponentPreview/Components.tsx
+++ b/apps/www/src/components/ComponentPreview/Components.tsx
@@ -547,6 +547,15 @@ export const Components: Record = {
})),
),
},
+ 'file-tree': {
+ name: 'file-tree',
+ type: 'component:example',
+ component: lazy(() =>
+ import('../demos/FileTree').then((module) => ({
+ default: module.FileTreeDemo,
+ })),
+ ),
+ },
'dropdown-menu-checkboxes': {
name: 'dropdown-menu-checkboxes',
type: 'component:example',
diff --git a/apps/www/src/components/demos/FileTree/FileTree.tsx b/apps/www/src/components/demos/FileTree/FileTree.tsx
new file mode 100644
index 00000000..9332c821
--- /dev/null
+++ b/apps/www/src/components/demos/FileTree/FileTree.tsx
@@ -0,0 +1,43 @@
+import {
+ File,
+ FileTree,
+ Folder,
+ FolderFiles,
+ FolderName,
+} from '@kosori/ui/file-tree';
+
+export const FileTreeDemo = () => {
+ return (
+
+
+ app
+
+
+
+ components
+
+
+ button.tsx
+ card.tsx
+
+
+
+ layout.tsx
+ global.css
+ page.tsx
+
+
+
+
+ public
+
+
+ favicon.ico
+ index.html
+
+
+
+ package.json
+
+ );
+};
diff --git a/apps/www/src/components/demos/FileTree/index.ts b/apps/www/src/components/demos/FileTree/index.ts
new file mode 100644
index 00000000..8fc129e1
--- /dev/null
+++ b/apps/www/src/components/demos/FileTree/index.ts
@@ -0,0 +1 @@
+export * from './FileTree';
diff --git a/packages/ui/src/accordion.tsx b/packages/ui/src/accordion.tsx
index f110d213..c7a7e55f 100644
--- a/packages/ui/src/accordion.tsx
+++ b/packages/ui/src/accordion.tsx
@@ -57,7 +57,6 @@ const { item, trigger, triggerIcon, content } = accordionStyles();
*
* @see {@link https://dub.sh/ui-accordion Accordion Docs} for further information.
*/
-
export const Accordion = Root;
type AccordionItemRef = React.ElementRef;
diff --git a/packages/ui/src/file-tree.tsx b/packages/ui/src/file-tree.tsx
new file mode 100644
index 00000000..5098ee50
--- /dev/null
+++ b/packages/ui/src/file-tree.tsx
@@ -0,0 +1,187 @@
+'use client';
+
+import { forwardRef } from 'react';
+import { clsx } from 'clsx/lite';
+import { FileIcon, FolderIcon, FolderOpenIcon } from 'lucide-react';
+import { tv } from 'tailwind-variants';
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@kosori/ui/collapsible';
+
+const fileTreeStyles = tv({
+ slots: {
+ fileTree: 'rounded-lg border bg-grey-bg-subtle p-2',
+ folder: 'w-full',
+ folderName: clsx(
+ 'group w-full inline-flex items-center gap-2 rounded-lg px-2 h-8',
+ 'hover:bg-grey-bg-hover',
+ ),
+ folderFiles: clsx(
+ 'overflow-hidden',
+ 'data-[state=closed]:animate-collapsible-up',
+ 'data-[state=open]:animate-collapsible-down',
+ ),
+ file: clsx(
+ 'w-full inline-flex h-8 select-none items-center gap-2 rounded-lg px-2 text-sm',
+ 'hover:bg-grey-bg-hover',
+ ),
+ },
+});
+
+const { fileTree, folder, folderName, folderFiles, file } = fileTreeStyles();
+
+type FileTreeRef = HTMLDivElement;
+type FileTreeProps = React.ComponentPropsWithoutRef<'div'>;
+
+/**
+ * FileTree component that represents a hierarchical structure of files and folders.
+ *
+ * @param {FileTreeProps} props - Additional props to pass to the file tree container.
+ *
+ * @example
+ *
+ *
+ * Documents
+ *
+ * Resume.pdf
+ * CoverLetter.docx
+ *
+ *
+ *
+ * Images
+ *
+ * Vacation.jpg
+ * Profile.png
+ *
+ *
+ *
+ */
+export const FileTree = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+
+FileTree.displayName = 'FileTree';
+
+type FolderRef = React.ElementRef;
+type FolderProps = React.ComponentPropsWithoutRef;
+
+/**
+ * Folder component that represents a collapsible folder in the file tree.
+ *
+ * @param {FolderProps} props - Additional props to pass to the folder component.
+ *
+ * @example
+ *
+ * My Folder
+ *
+ * File1.txt
+ * File2.txt
+ *
+ *
+ */
+export const Folder = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+
+Folder.displayName = 'Folder';
+
+type FolderNameRef = React.ElementRef;
+type FolderNameProps = React.ComponentPropsWithoutRef<
+ typeof CollapsibleTrigger
+>;
+
+/**
+ * FolderName component that acts as the clickable header for a Folder.
+ *
+ * @param {FolderNameProps} props - Additional props to pass to the folder name.
+ *
+ * @example
+ * My Folder
+ */
+export const FolderName = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+ ),
+);
+
+FolderName.displayName = 'FolderName';
+
+type FolderFilesRef = React.ElementRef;
+type FolderFilesProps = React.ComponentPropsWithoutRef<
+ typeof CollapsibleContent
+>;
+
+/**
+ * FolderFiles component that displays the contents of a Folder.
+ *
+ * @param {FolderFilesProps} props - Additional props to pass to the folder files container.
+ *
+ * @example
+ *
+ * File1.txt
+ * File2.txt
+ *
+ */
+export const FolderFiles = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ ),
+);
+
+FolderFiles.displayName = 'FolderFiles';
+
+type FileRef = HTMLDivElement;
+type FileProps = React.ComponentPropsWithoutRef<'div'> & {
+ /**
+ * Optional icon to display next to the file name.
+ */
+ icon?: React.ReactNode;
+};
+
+/**
+ * File component that represents a single file in the file tree.
+ *
+ * @param {FileProps} props - Additional props to pass to the file component.
+ * @param {React.ReactNode} [icon=] - Optional icon to display next to the file name.
+ *
+ * @example
+ * MyFile.txt
+ */
+export const File = forwardRef(
+ (
+ { className, icon = , children, ...props },
+ ref,
+ ) => (
+
+ {icon}
+ {children}
+
+ ),
+);
+
+File.displayName = 'File';
diff --git a/tooling/tailwind/web.ts b/tooling/tailwind/web.ts
index 4e4dfb05..b1564add 100644
--- a/tooling/tailwind/web.ts
+++ b/tooling/tailwind/web.ts
@@ -39,6 +39,18 @@ export default {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
+ 'collapsible-down': {
+ from: { height: '0' },
+ to: {
+ height: 'var(--radix-collapsible-content-height)',
+ },
+ },
+ 'collapsible-up': {
+ from: {
+ height: 'var(--radix-collapsible-content-height)',
+ },
+ to: { height: '0' },
+ },
flash: {
'0%': { opacity: '0.2' },
'20%': { opacity: '1' },
@@ -85,6 +97,8 @@ export default {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
+ 'collapsible-down': 'collapsible-down 0.15s ease-out',
+ 'collapsible-up': 'collapsible-up 0.15s ease-out',
flash: 'flash 1.4s infinite linear',
jump: 'jump 1s ease-in-out infinite',
pulse2: 'pulse2 1.3s ease-in-out infinite',