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>

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.

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

Delete

Call the delete handler with the selection.

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.

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

delete
(items) => void

Handler. 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 / Slots

The explorer contract, for wrappers.

FileTree · FileTreeProps / Emits / Slots

The 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

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.
Uploads & new foldersPass the handlers to show the actions.
LoadingShow skeleton cards.