File Explorer
A desktop-style file explorer: a directory tree, breadcrumbs with back and forward, grid and details views, desktop selection and keyboard shortcuts, context menus, drag and drop and uploads. It is a UI component — you bring the data and decide what every action does.
Installation
npx raya-ui@latest add file-explorerFile Structure
FileExplorer owns the state and composes small parts. The directory tree is FileTree, which renders one FileTreeNode per item; each node renders its children with another FileTreeNode. All state is keyed by id, so nothing copies or mutates your data.
Usage
Basic usage
Pass a nested tree to items. Every item needs a stable, unique id, a name and a type; folders hold children. The explorer handles browsing, selection and keyboard navigation on its own. Give it a height and it fills it.
<script setup lang="ts">
import { FileExplorer, type FileExplorerItem } from '@/components/ui/file-explorer'
const files: FileExplorerItem[] = [
{
id: 'src',
name: 'src',
type: 'folder',
children: [
{ id: 'src/App.vue', name: 'App.vue', type: 'file', size: 734 },
{ id: 'src/main.ts', name: 'main.ts', type: 'file', size: 298 },
],
},
{ id: 'package.json', name: 'package.json', type: 'file', size: 1087 },
]
</script>
<template>
<FileExplorer :items="files" class="h-[480px]" />
</template>Navigation
The open folder is an id (or null for the root), bound with v-model:folder. Double-click or Enter opens a folder; Back, Forward and Up work like a browser history, and every breadcrumb is clickable. Sync it with the URL to make locations shareable.
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const folder = computed({
get: () => (route.query.folder as string) ?? null,
set: id => router.push({ query: id ? { folder: id } : {} }),
})
</script>
<template>
<FileExplorer v-model:folder="folder" :items="files" root-label="My Drive" />
</template>Selection and opening files
Selection is a list of ids in the open folder. With multiple (on by default) it behaves like a desktop: Ctrl/Cmd-click toggles, Shift-click selects a range, clicking empty space clears. Double-clicking a file, pressing Enter or using the status bar emits open.
<script setup lang="ts">
const selected = ref<string[]>([])
function openFile(item: FileExplorerItem) {
router.push(`/editor/${encodeURIComponent(item.id)}`)
}
</script>
<template>
<FileExplorer v-model:selected="selected" :items="files" @open="openFile" />
</template>Grid and details views
The toolbar switches between cards and a details list; bind v-model:view to control or persist it. The details view sorts by name, modified date, type or size (v-model:sort), always keeping folders first.
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { FileExplorerSort, FileExplorerView } from '@/components/ui/file-explorer'
const view = useStorage<FileExplorerView>('explorer-view', 'grid')
const sort = ref<FileExplorerSort>({ key: 'modified', direction: 'desc' })
</script>
<template>
<FileExplorer v-model:view="view" v-model:sort="sort" :items="files" />
</template>Previews and metadata
Cards show whatever the item carries: size and modifiedAt in the footer, description as the subtitle (defaults to the file type), and either a thumbnail image or a few lines of preview text. Folders summarize their contents. Use the #preview slot for anything else, e.g. a waveform or a PDF page.
<script setup lang="ts">
const files: FileExplorerItem<{ url: string }>[] = [
{
id: 'useFileSystem.ts',
name: 'useFileSystem.ts',
type: 'file',
size: 2867,
modifiedAt: new Date(),
description: 'Composable hook',
preview: 'export const useFS = () =>\n return { readTree }',
},
{
id: 'hero.png',
name: 'hero.png',
type: 'file',
size: 1468006,
thumbnail: 'https://cdn.example.com/thumbs/hero.png',
},
]
</script>
<template>
<FileExplorer :items="files">
<template #preview="{ item }">
<AudioWaveform v-if="item.mimeType?.startsWith('audio/')" :src="item.data?.url" />
</template>
</FileExplorer>
</template>Uploads, new folders and deleting
The explorer never touches storage. Pass @upload, @create-folder and @delete and the matching UI appears: the Upload button, the drop tile and desktop file drops for uploads; the New Folder button; the Delete key. Each handler receives the destination folder (null for the root) or the selected items.
<script setup lang="ts">
async function onUpload(uploaded: File[], folder: FileExplorerItem | null) {
await Promise.all(uploaded.map(file => storage.put(folder?.id ?? '', file)))
files.value = await storage.list()
}
async function onCreateFolder(parent: FileExplorerItem | null) {
await storage.mkdir(parent?.id ?? '', 'New folder')
files.value = await storage.list()
}
async function onDelete(items: FileExplorerItem[]) {
if (!confirm(`Delete ${items.length} item(s)?`)) return
await storage.remove(items.map(item => item.id))
files.value = await storage.list()
}
</script>
<template>
<FileExplorer
:items="files"
accept="image/*,.pdf"
@upload="onUpload"
@create-folder="onCreateFolder"
@delete="onDelete"
/>
</template>Context menu
Fill the #context-menu slot with shadcn-vue ContextMenuItems. It opens for cards, rows, directory tree folders and the empty area (item is null there). Right-clicking outside the selection selects that item first.
<script setup lang="ts">
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'
</script>
<template>
<FileExplorer :items="files">
<template #context-menu="{ item }">
<template v-if="item">
<ContextMenuItem @select="rename(item)">Rename</ContextMenuItem>
<ContextMenuItem @select="copyLink(item)">Copy link</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" @select="remove(item)">Delete</ContextMenuItem>
</template>
<ContextMenuItem v-else @select="createFolder">New folder</ContextMenuItem>
</template>
</FileExplorer>
</template>Drag and drop
With draggable, cards and rows can be dropped onto folder cards, folders in the directory tree or any breadcrumb. Dragging a selected item drags the whole selection; hovering a closed tree folder opens it. The explorer emits move and leaves the data to you.
<script setup lang="ts">
async function onMove({ items, target }: FileExplorerMoveEvent) {
await storage.move(items.map(item => item.id), target?.id ?? '')
files.value = await storage.list()
}
</script>
<template>
<FileExplorer :items="files" draggable @move="onMove" />
</template>Toolbar and status bar
Add buttons to the toolbar with #toolbar-actions, and replace the status bar actions (an Open File link by default) with #status-actions, which receives the selected items.
<template>
<FileExplorer :items="files">
<template #toolbar-actions>
<Button size="sm" variant="ghost" @click="refresh">Refresh</Button>
</template>
<template #status-actions="{ items }">
<button v-if="items.length === 1" @click="download(items[0])">Download</button>
</template>
</FileExplorer>
</template>Empty and loading states
loading shows skeleton cards (or rows) and sets aria-busy. Empty folders and filters without matches show a message you can replace with the #empty slot.
<script setup lang="ts">
const { data: files, pending } = await useFetch<FileExplorerItem[]>('/api/files', { default: () => [] })
</script>
<template>
<FileExplorer :items="files" :loading="pending">
<template #empty="{ query }">
<p v-if="query">Nothing called “{{ query }}” here.</p>
<p v-else>Nothing here yet — drop files to get started.</p>
</template>
</FileExplorer>
</template>FileTree on its own
The directory tree is exported as FileTree: a recursive, accessible tree built on Reka UI with id-based v-model:selected and v-model:expanded, search that reveals matches, item slots, a context menu and drag and drop. Use it for editor sidebars and navigation.
<script setup lang="ts">
import { FileTree, formatBytes } from '@/components/ui/file-explorer'
const selected = ref<string[]>([])
const expanded = ref<string[]>(['src'])
</script>
<template>
<FileTree
v-model:selected="selected"
v-model:expanded="expanded"
:items="files"
searchable
class="h-80"
>
<template #actions="{ item }">
<span class="opacity-0 group-hover/row:opacity-100">{{ formatBytes(item.size) }}</span>
</template>
</FileTree>
</template>Keyboard Interactions
The items form a single-tab-stop listbox with aria-selected and aria-multiselectable. The directory tree is a separate tab stop following the WAI-ARIA tree pattern: arrows move and expand, Enter opens the folder.
Move between items (in two dimensions in the grid) and select.
Extend the selection from the anchor.
Move focus without selecting; Space then toggles.
First / last item.
Open a folder, or emit open for a file.
Up to the parent folder, selecting the folder you left.
Back, forward, up.
Select everything in the folder.
Toggle the focused item in the selection.
Clear the selection (or the filter, in the filter field).
Call the delete handler with the selection.
Jump to the next item whose name starts with the typed text.
API Reference
Props
itemsFileExplorerItem<TData>[][]The whole tree. Never mutated.
folderstring | nullnullOpen folder id, null for the root. Bind with v-model:folder. Unknown ids fall back to the root.
defaultFolderstring | nullnullInitial folder when folder is not bound.
selectedstring[]—Selected ids. Bind with v-model:selected. Cleared when the folder changes.
defaultSelectedstring[][]Initial selection when selected is not bound.
view"grid" | "list""grid"Bind with v-model:view.
defaultView"grid" | "list""grid"Initial view when view is not bound.
searchstring""Filters the open folder by name. Bind with v-model:search. Cleared on navigation.
sortFileExplorerSort{ key: "name", direction: "asc" }Bind with v-model:sort. Folders always come first.
multiplebooleantrueCtrl/Cmd-click, Shift-click, Shift+Arrow and Ctrl/Cmd+A multi-selection.
draggablebooleanfalseEnables drag and drop onto folders, the tree and breadcrumbs. Listen to move.
sidebarbooleantrueShows the directory tree when the explorer is at least 48rem wide.
loadingbooleanfalseSkeleton cards or rows, and aria-busy.
disabledbooleanfalseDisables every interaction.
rootLabelstring"root"Name of the root in the breadcrumbs.
acceptstring—accept attribute of the upload picker.
getIconFileExplorerIconResolver<TData>—Returns an icon component per item; undefined keeps the default type tile.
labelstring"Files"Accessible name of the item list.
classHTMLAttributes["class"]—Classes for the root. Give it a height.
Item
The shape of each entry in items. Only id, name and type are required.
idstringRequired. Stable and unique across the whole tree — never the name or an index.
namestringRequired. Displayed, filtered, sorted and used for type-ahead.
type"file" | "folder"Required.
childrenFileExplorerItem<TData>[]A folder's contents. Omit or [] for an empty folder.
sizenumberBytes. Shown on cards, rows and in the status bar; used for sorting.
modifiedAtDate | stringShown as “10m ago”; used for sorting.
descriptionstringCard subtitle and details “Type” column. Defaults to the file type.
previewstringThe first lines are shown on the card with light syntax coloring.
thumbnailstringImage URL shown on the card instead of preview.
mimeTypestringShown in the status bar.
extensionstringOverrides the extension parsed from name.
disabledbooleanVisible but cannot be selected, opened or dragged.
dataTDataYour own metadata, typed in slots and events.
Events & handlers
upload, create-folder and delete are declared as handler props, so the explorer can tell whether you listen and only shows those actions when you do.
update:folderstring | nullThe open folder changed.
update:selectedstring[]The selection changed.
update:view"grid" | "list"The view was switched.
update:searchstringThe filter changed.
update:sortFileExplorerSortA details column header was clicked.
openFileExplorerItem<TData>A file was opened (double-click, Enter or Open File).
moveFileExplorerMoveEvent<TData>{ items, target } after a drop; target is null for the root breadcrumb.
upload(files: File[], folder) => voidHandler. Enables the Upload button, drop tile and desktop drops.
create-folder(parent) => voidHandler. Enables the New Folder button.
delete(items) => voidHandler. Called with the selection on the Delete key.
Slots
#preview{ item }The preview area of a card.
#context-menu{ item: FileExplorerItem | null }Context menu entries; enables the menu.
#empty{ query }Empty folder or filter without matches.
#toolbar-actions—Extra toolbar buttons.
#status-actions{ items }Right side of the status bar.
TypeScript
Everything is exported from @/components/ui/file-explorer. Both components are generic over TData, inferred from items, so item.data is typed in slots and events.
FileExplorerItem<TData>A node of the tree.
FileExplorerView · FileExplorerSort"grid" | "list", and { key, direction }.
FileExplorerMoveEvent<TData>Payload of move.
FileExplorerProps / Emits / SlotsThe explorer contract, for wrappers.
FileTree · FileTreeProps / Emits / SlotsThe standalone tree and its contract.
formatBytes(bytes)1536 → "1.5 KB".
formatRelativeTime(date, now?)"just now", "10m ago", "3d ago".
getFileKind(item){ label, badge, tone } used for tiles and the type column.
sortFileItems(items, sort)Folders first, then by key, then by name.
indexFileTree(items)Map of id → { item, parentId, depth }, for paths and parents.
filterFileTree(items, query)The tree search as a pure function.
ui-primitives
Directory
Dialog.vue, Button.vue
+12 more
FileExplorer.vue
Vue 3 SFC
hero-banner.png
Raster asset

nuxt.config.ts
Core config
useFileSystem.ts
Composable hook
FileExplorer.vue(4.2 KB, text/x-vue)
<script setup lang="ts">
import { ref } from 'vue'
import {
FileExplorer,
type FileExplorerItem,
type FileExplorerMoveEvent,
} from '@/components/ui/file-explorer'
const files = ref<FileExplorerItem[]>([
{
id: 'components',
name: 'components',
type: 'folder',
children: [
{
id: 'components/ui',
name: 'ui',
type: 'folder',
children: [
{
id: 'components/ui/FileExplorer.vue',
name: 'FileExplorer.vue',
type: 'file',
size: 4300,
modifiedAt: '2026-09-27T09:40:00Z',
description: 'Vue 3 SFC',
preview: '<template>\n <FileExplorer v-model="active" />',
},
{
id: 'components/ui/hero-banner.png',
name: 'hero-banner.png',
type: 'file',
size: 1468006,
thumbnail: '/images/hero-banner.png',
},
],
},
],
},
{ id: 'README.md', name: 'README.md', type: 'file', size: 3402 },
])
const folder = ref<string | null>('components/ui')
const selected = ref<string[]>([])
function onMove({ items, target }: FileExplorerMoveEvent) {
// Persist the move, then update `files`.
}
function onUpload(uploaded: File[], folder: FileExplorerItem | null) {
// Send to your storage, then add the new items under `folder`.
}
function onCreateFolder(parent: FileExplorerItem | null) {}
function onDelete(items: FileExplorerItem[]) {}
function openFile(item: FileExplorerItem) {
router.push(`/editor/${encodeURIComponent(item.id)}`)
}
</script>
<template>
<FileExplorer
v-model:folder="folder"
v-model:selected="selected"
:items="files"
draggable
@move="onMove"
@upload="onUpload"
@create-folder="onCreateFolder"
@delete="onDelete"
@open="openFile"
class="h-[560px]"
/>
</template>