System.Routing

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-explorer

File 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.

components/ui/file-explorer
FileExplorer.vue
FileExplorerToolbar.vue
FileExplorerSidebar.vue
FileExplorerContent.vue
FileExplorerCard.vue
FileExplorerRow.vue
FileExplorerFileIcon.vue
FileExplorerStatusBar.vue
FileTree.vue
FileTreeRoot.vue
FileTreeNode.vue
useFileExplorer*.ts
context.ts · types.ts · utils.ts · variants.ts · index.ts

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>

Renaming

Pass @rename to enable inline renaming: F2, or rename() from the context menu, turns the name into an input with the base name selected. Enter or clicking away commits, Esc cancels. Empty names, slashes and duplicates in the same folder are refused with an inline message; add your own rules with validate-name. The handler receives the item and the trimmed new name.

<script setup lang="ts">
async function onRename(item: FileExplorerItem, name: string) {
  await storage.rename(item.id, name)
  files.value = await storage.list()
}

const validateName = (name: string) =>
  /[<>:"|?*]/.test(name) ? 'Names cannot contain < > : " | ? *' : undefined
</script>

<template>
  <FileExplorer :items="files" :validate-name="validateName" @rename="onRename" />
</template>

Confirming deletes

Delete — the key, remove() from the context menu or the exposed remove(ids) — opens a confirmation dialog that names the file, or counts the items and folders involved. Cancel has focus, so Enter never deletes by accident. @delete runs only once the user confirms. Reword the message with #delete-description, or turn the dialog off with :confirm-delete="false" when your app has its own undo or trash.

<script setup lang="ts">
async function onDelete(items: FileExplorerItem[]) {
  await storage.moveToTrash(items.map(item => item.id))
  files.value = await storage.list()
}
</script>

<template>
  <FileExplorer :items="files" @delete="onDelete">
    <template #delete-description="{ items }">
      {{ items.length === 1 ? 'It' : 'They' }} will be moved to the trash for 30 days.
    </template>
  </FileExplorer>
</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. The scope also has rename() and remove(), which start the built-in inline rename and confirmed delete once the menu has closed.

<script setup lang="ts">
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'
</script>

<template>
  <FileExplorer :items="files">
    <template #context-menu="{ item, rename, remove }">
      <template v-if="item">
        <ContextMenuItem @select="rename">Rename</ContextMenuItem>
        <ContextMenuItem @select="copyLink(item)">Copy link</ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem variant="destructive" @select="remove">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.

ShiftArrows

Extend the selection from the anchor.

Ctrl / ⌘Arrows

Move focus without selecting; Space then toggles.

HomeEnd

First / last item.

Enter

Open a folder, or emit open for a file.

Backspace

Up to the parent folder, selecting the folder you left.

Alt← / → / ↑

Back, forward, up.

Ctrl / ⌘A

Select everything in the folder.

Space

Toggle the focused item in the selection.

Esc

Clear the selection (or the filter, in the filter field).

F2

Rename the focused item inline (Enter to save, Esc to cancel).

Delete

Delete the selection, after confirmation.

a–z

Jump to the next item whose name starts with the typed text.

API Reference

Props

items
FileExplorerItem<TData>[]
[]

The whole tree. Never mutated.

folder
string | null
null

Open folder id, null for the root. Bind with v-model:folder. Unknown ids fall back to the root.

defaultFolder
string | null
null

Initial folder when folder is not bound.

selected
string[]
—

Selected ids. Bind with v-model:selected. Cleared when the folder changes.

defaultSelected
string[]
[]

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.

search
string
""

Filters the open folder by name. Bind with v-model:search. Cleared on navigation.

sort
FileExplorerSort
{ key: "name", direction: "asc" }

Bind with v-model:sort. Folders always come first.

multiple
boolean
true

Ctrl/Cmd-click, Shift-click, Shift+Arrow and Ctrl/Cmd+A multi-selection.

draggable
boolean
false

Enables drag and drop onto folders, the tree and breadcrumbs. Listen to move.

sidebar
boolean
true

Shows the directory tree when the explorer is at least 48rem wide.

loading
boolean
false

Skeleton cards or rows, and aria-busy.

disabled
boolean
false

Disables every interaction.

rootLabel
string
"root"

Name of the root in the breadcrumbs.

accept
string
—

accept attribute of the upload picker.

validateName
(name, item) => string | undefined
—

Extra checks for renames. Return an error message to refuse a name.

confirmDelete
boolean
true

Ask for confirmation in a dialog before calling the delete handler.

getIcon
FileExplorerIconResolver<TData>
—

Returns an icon component per item; undefined keeps the default type tile.

label
string
"Files"

Accessible name of the item list.

class
HTMLAttributes["class"]
—

Classes for the root. Give it a height.

Item

The shape of each entry in items. Only id, name and type are required.

id
string

Required. Stable and unique across the whole tree — never the name or an index.

name
string

Required. Displayed, filtered, sorted and used for type-ahead.

type
"file" | "folder"

Required.

children
FileExplorerItem<TData>[]

A folder's contents. Omit or [] for an empty folder.

size
number

Bytes. Shown on cards, rows and in the status bar; used for sorting.

modifiedAt
Date | string

Shown as “10m ago”; used for sorting.

description
string

Card subtitle and details “Type” column. Defaults to the file type.

preview
string

The first lines are shown on the card with light syntax coloring.

thumbnail
string

Image URL shown on the card instead of preview.

mimeType
string

Shown in the status bar.

extension
string

Overrides the extension parsed from name.

disabled
boolean

Visible but cannot be selected, opened or dragged.

data
TData

Your own metadata, typed in slots and events.

Events & handlers

upload, create-folder, rename and delete are declared as handler props, so the explorer can tell whether you listen and only shows those actions when you do.

update:folder
string | null

The open folder changed.

update:selected
string[]

The selection changed.

update:view
"grid" | "list"

The view was switched.

update:search
string

The filter changed.

update:sort
FileExplorerSort

A details column header was clicked.

open
FileExplorerItem<TData>

A file was opened (double-click, Enter or Open File).

move
FileExplorerMoveEvent<TData>

{ items, target } after a drop; target is null for the root breadcrumb.

upload
(files: File[], folder) => void

Handler. Enables the Upload button, drop tile and desktop drops.

create-folder
(parent) => void

Handler. Enables the New Folder button.

rename
(item, name) => void

Handler. Enables inline renaming (F2 and rename() in the context menu).

delete
(items) => void

Handler. Called after the confirmation dialog, for the Delete key or remove() in the context menu.

Slots

#preview
{ item }

The preview area of a card.

#context-menu
{ item, rename, remove }

Context menu entries; enables the menu. rename() and remove() run the built-in actions.

#delete-description
{ items }

Body of the delete confirmation dialog.

#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 / Slots

The explorer contract, for wrappers.

FileTree · FileTreeProps / Emits / Slots

The standalone tree and its contract. It supports the same rename and delete handlers.

FileExplorerContextMenuSlotProps

{ item, rename, remove }.

validateItemName(index, items, item, name)

The built-in name checks, for server-side reuse.

ref.rename(id) · ref.remove(ids)

Exposed on a template ref, e.g. for toolbar buttons.

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

14 items

Dialog.vue, Button.vue

+12 more

Folder1d ago

FileExplorer.vue

Vue 3 SFC

<template>
<FileExplorer v-model="active" />
4.2 KB10m ago

hero-banner.png

Raster asset

PNG
1.4 MB3d ago

nuxt.config.ts

Core config

export default defineNuxtConfig({
devtools: { enabled: true }
1.1 KB4d ago

useFileSystem.ts

Composable hook

export const useFS = () =>
return { readTree }
2.8 KB2h ago

FileExplorer.vue

Settings
Directory treeShow the sidebar when there is room.
Multiple selectionCtrl/Cmd, Shift and Ctrl+A.
Drag and dropMove items onto folders.
File actionsUpload, new folder, rename, delete.
LoadingShow skeleton cards.