# File Manager

> A cloud drive with breadcrumbs, grid/list views, multi-select bulk actions, sorting, a storage meter and a drag-and-drop upload area.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/templates/file-manager
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/file-manager.json
```

Adds the page at `app/file-manager/page.tsx` together with the components below. Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Components

- [Breadcrumb](https://chromotion.dev/r/breadcrumb.md): `@/components/breadcrumb`
- [Dropdown Menu](https://chromotion.dev/r/dropdown-menu.md): `@/components/dropdown-menu`
- [File Dropzone](https://chromotion.dev/r/file-dropzone.md): `@/components/file-dropzone`
- [Segmented Control](https://chromotion.dev/r/segmented-control.md): `@/components/segmented-control`
- [Usage Meter](https://chromotion.dev/r/usage-meter.md): `@/components/usage-meter`

## Dependencies

```bash
npm install lucide-react
```

## Source

```tsx title="app/file-manager/page.tsx"
"use client"

import * as React from "react"
import {
  ArrowDownAZ,
  ArrowUpAZ,
  CloudUpload,
  Download,
  FileArchive,
  FileAudio,
  FileCode,
  FileImage,
  FileSpreadsheet,
  FileText,
  FileVideo,
  Folder,
  FolderInput,
  Hexagon,
  HardDrive,
  LayoutGrid,
  List,
  Clock,
  Search,
  Share2,
  Star,
  Trash2,
  X,
  ArrowUpDown,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { Breadcrumb } from "@/components/breadcrumb"
import { DropdownMenu } from "@/components/dropdown-menu"
import { FileDropzone } from "@/components/file-dropzone"
import { SegmentedControl } from "@/components/segmented-control"
import { UsageMeter } from "@/components/usage-meter"

type Kind = "folder" | "pdf" | "doc" | "image" | "video" | "audio" | "archive" | "code" | "sheet"
type SortKey = "name" | "modified" | "size"

interface DriveItem {
  id: string
  name: string
  kind: Kind
  /** Bytes for files, number of children for folders */
  size: number
  /** ISO date, yyyy-mm-dd */
  modified: string
}

const KB = 1024
const MB = KB * 1024

const initialItems: DriveItem[] = [
  { id: "f1", name: "Design assets", kind: "folder", size: 24, modified: "2026-11-06" },
  { id: "f2", name: "Contracts", kind: "folder", size: 8, modified: "2026-10-30" },
  { id: "f3", name: "Meeting recordings", kind: "folder", size: 12, modified: "2026-11-03" },
  { id: "f4", name: "Brand kit", kind: "folder", size: 31, modified: "2026-10-18" },
  { id: "d1", name: "Q4-roadmap.pdf", kind: "pdf", size: 4.2 * MB, modified: "2026-11-05" },
  { id: "d2", name: "Hero-banner.png", kind: "image", size: 2.8 * MB, modified: "2026-11-04" },
  { id: "d3", name: "Launch-teaser.mp4", kind: "video", size: 148 * MB, modified: "2026-11-03" },
  { id: "d4", name: "Revenue-forecast.xlsx", kind: "sheet", size: 1.1 * MB, modified: "2026-10-30" },
  { id: "d5", name: "Podcast-intro.wav", kind: "audio", size: 32 * MB, modified: "2026-10-28" },
  { id: "d6", name: "design-tokens.json", kind: "code", size: 18 * KB, modified: "2026-11-06" },
  { id: "d7", name: "Brand-guidelines.pdf", kind: "pdf", size: 12.6 * MB, modified: "2026-10-21" },
  { id: "d8", name: "Website-backup.zip", kind: "archive", size: 286 * MB, modified: "2026-10-14" },
  { id: "d9", name: "Meeting-notes.docx", kind: "doc", size: 240 * KB, modified: "2026-11-02" },
  { id: "d10", name: "Team-photo.jpg", kind: "image", size: 5.4 * MB, modified: "2026-10-09" },
  { id: "d11", name: "App-icons.zip", kind: "archive", size: 64 * MB, modified: "2026-09-29" },
]

const kinds: Record<Kind, { icon: React.ComponentType<{ className?: string }>; tone: string; label: string }> = {
  folder: { icon: Folder, tone: "bg-amber-500/15 text-amber-600 dark:text-amber-400", label: "Folder" },
  pdf: { icon: FileText, tone: "bg-rose-500/15 text-rose-600 dark:text-rose-400", label: "PDF document" },
  doc: { icon: FileText, tone: "bg-blue-500/15 text-blue-600 dark:text-blue-400", label: "Document" },
  image: { icon: FileImage, tone: "bg-violet-500/15 text-violet-600 dark:text-violet-400", label: "Image" },
  video: { icon: FileVideo, tone: "bg-fuchsia-500/15 text-fuchsia-600 dark:text-fuchsia-400", label: "Video" },
  audio: { icon: FileAudio, tone: "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400", label: "Audio" },
  archive: { icon: FileArchive, tone: "bg-orange-500/15 text-orange-600 dark:text-orange-400", label: "Archive" },
  code: { icon: FileCode, tone: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", label: "Code" },
  sheet: { icon: FileSpreadsheet, tone: "bg-green-500/15 text-green-600 dark:text-green-400", label: "Spreadsheet" },
}

const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

function formatDate(iso: string) {
  const [year, month, day] = iso.split("-").map(Number)
  return `${months[month - 1]} ${day}, ${year}`
}

function formatSize(item: DriveItem) {
  if (item.kind === "folder") return `${item.size} ${item.size === 1 ? "item" : "items"}`
  if (item.size < MB) return `${Math.round(item.size / KB)} KB`
  return `${(item.size / MB).toFixed(item.size < 100 * MB ? 1 : 0)} MB`
}

const sortLabels: Record<SortKey, string> = { name: "Name", modified: "Modified", size: "Size" }

function compare(a: DriveItem, b: DriveItem, key: SortKey) {
  if (key === "modified") return a.modified.localeCompare(b.modified)
  if (key === "size" && a.kind !== "folder" && b.kind !== "folder") return a.size - b.size
  return a.name.localeCompare(b.name, "en")
}

function KindIcon({ kind, className }: { kind: Kind; className?: string }) {
  const { icon: Icon, tone } = kinds[kind]
  return (
    <span aria-hidden className={cn("flex size-10 shrink-0 items-center justify-center rounded-lg", tone, className)}>
      <Icon className="size-5" />
    </span>
  )
}

function SelectBox({ checked, label, onChange }: { checked: boolean; label: string; onChange: () => void }) {
  return (
    <input
      type="checkbox"
      checked={checked}
      onChange={onChange}
      aria-label={label}
      className="size-4 shrink-0 cursor-pointer rounded accent-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none"
    />
  )
}

const storageSegments = [
  { label: "Documents", value: 21, color: "#6366f1" },
  { label: "Media", value: 46, color: "#06b6d4" },
  { label: "Backups", value: 12, color: "#f59e0b" },
]

const nav = [
  { label: "My files", icon: HardDrive, active: true },
  { label: "Recent", icon: Clock },
  { label: "Starred", icon: Star },
  { label: "Shared with me", icon: Share2 },
  { label: "Trash", icon: Trash2 },
]

export default function FileManager() {
  const searchId = React.useId()
  const [items, setItems] = React.useState<DriveItem[]>(initialItems)
  const [view, setView] = React.useState<"grid" | "list">("grid")
  const [selected, setSelected] = React.useState<string[]>([])
  const [sortKey, setSortKey] = React.useState<SortKey>("name")
  const [descending, setDescending] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [notice, setNotice] = React.useState<{ text: string; restore?: DriveItem[] } | null>(null)

  const needle = query.trim().toLowerCase()
  const visible = items
    .filter((item) => item.name.toLowerCase().includes(needle))
    .sort((a, b) => {
      // Folders always sit above files
      if ((a.kind === "folder") !== (b.kind === "folder")) return a.kind === "folder" ? -1 : 1
      const result = compare(a, b, sortKey)
      return descending ? -result : result
    })

  const selectedVisible = visible.filter((item) => selected.includes(item.id))
  const allSelected = visible.length > 0 && selectedVisible.length === visible.length
  const someSelected = selectedVisible.length > 0 && !allSelected

  const toggle = (id: string) => setSelected((current) => (current.includes(id) ? current.filter((x) => x !== id) : [...current, id]))
  const toggleAll = () => setSelected(allSelected ? [] : visible.map((item) => item.id))

  const remove = (ids: string[], text: (count: number) => string, undoable: boolean) => {
    const removed = items.filter((item) => ids.includes(item.id))
    setItems((all) => all.filter((item) => !ids.includes(item.id)))
    setSelected((current) => current.filter((id) => !ids.includes(id)))
    setNotice({ text: text(removed.length), restore: undoable ? removed : undefined })
  }

  const count = selectedVisible.length
  const plural = (n: number) => `${n} ${n === 1 ? "item" : "items"}`
  const selectedIds = selectedVisible.map((item) => item.id)

  const selectAll = (
    <input
      type="checkbox"
      checked={allSelected}
      onChange={toggleAll}
      ref={(element) => {
        if (element) element.indeterminate = someSelected
      }}
      aria-label="Select all"
      className="size-4 cursor-pointer rounded accent-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none"
    />
  )

  return (
    <div className="flex min-h-svh bg-background text-foreground">
      <aside className="sticky top-0 hidden h-svh w-64 shrink-0 flex-col border-r bg-muted/30 md:flex">
        <div className="flex h-14 shrink-0 items-center gap-2 border-b px-4 font-semibold">
          <Hexagon className="size-5 fill-primary text-primary" /> Hexa Drive
        </div>
        <div className="p-3">
          <a
            href="#upload"
            className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none"
          >
            <CloudUpload className="size-4" />
            Upload files
          </a>
        </div>
        <nav aria-label="Drive" className="min-h-0 flex-1 overflow-y-auto px-3">
          <ul className="flex flex-col gap-0.5">
            {nav.map(({ label, icon: Icon, active }) => (
              <li key={label}>
                <button
                  type="button"
                  aria-current={active ? "page" : undefined}
                  className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
                >
                  <Icon className="size-4 shrink-0" />
                  {label}
                </button>
              </li>
            ))}
          </ul>
        </nav>
        <div className="p-3">
          <UsageMeter title="Storage" unit="GB" limit={100} segments={storageSegments} />
        </div>
      </aside>

      <main className="flex min-w-0 flex-1 flex-col">
        <header className="flex flex-col gap-3 border-b px-4 py-4 sm:px-6">
          <Breadcrumb
            items={[
              { label: "My Drive", href: "#", icon: <HardDrive className="size-3.5" /> },
              { label: "Projects", href: "#" },
              { label: "Hexa Launch" },
            ]}
          />
          <div className="flex flex-wrap items-center justify-between gap-3">
            <div className="min-w-0">
              <h1 className="text-xl font-semibold tracking-tight">Hexa Launch</h1>
              <p className="text-sm text-muted-foreground">{plural(items.length)} in this folder</p>
            </div>
            <a
              href="#upload"
              className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none md:hidden"
            >
              <CloudUpload className="size-4" />
              Upload
            </a>
          </div>
        </header>

        <div className="flex flex-wrap items-center gap-2 border-b px-4 py-3 sm:px-6">
          <form role="search" onSubmit={(event) => event.preventDefault()} className="relative min-w-40 flex-1 sm:max-w-xs">
            <label htmlFor={searchId} className="sr-only">
              Search files
            </label>
            <Search aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
            <input
              id={searchId}
              type="search"
              value={query}
              onChange={(event) => setQuery(event.target.value)}
              placeholder="Search files"
              autoComplete="off"
              className="h-9 w-full rounded-lg border bg-background pr-3 pl-9 text-sm shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20"
            />
          </form>
          <div className="ml-auto flex items-center gap-2">
            <DropdownMenu
              align="end"
              width={200}
              trigger={
                <>
                  {descending ? <ArrowUpAZ className="size-4" /> : <ArrowDownAZ className="size-4" />}
                  <span className="sr-only">Sort by: </span>
                  {sortLabels[sortKey]}
                  <ArrowUpDown className="size-3.5 text-muted-foreground" />
                </>
              }
              items={[
                { type: "label", label: "Sort by" },
                ...(Object.keys(sortLabels) as SortKey[]).map((key) => ({
                  type: "checkbox" as const,
                  label: sortLabels[key],
                  checked: sortKey === key,
                  onCheckedChange: () => setSortKey(key),
                })),
                { type: "separator" },
                { type: "label", label: "Order" },
                { type: "checkbox", label: "Ascending", checked: !descending, onCheckedChange: () => setDescending(false) },
                { type: "checkbox", label: "Descending", checked: descending, onCheckedChange: () => setDescending(true) },
              ]}
            />
            <SegmentedControl
              size="sm"
              aria-label="View mode"
              value={view}
              onValueChange={(value) => setView(value as "grid" | "list")}
              options={[
                { value: "grid", label: "Grid", icon: <LayoutGrid /> },
                { value: "list", label: "List", icon: <List /> },
              ]}
            />
          </div>
        </div>

        {count > 0 && (
          <div
            role="toolbar"
            aria-label="Bulk actions"
            className="sticky top-0 z-10 flex flex-wrap items-center gap-2 border-b bg-primary/5 px-4 py-2 backdrop-blur-sm animate-in fade-in slide-in-from-top-1 sm:px-6"
          >
            <button
              type="button"
              onClick={() => setSelected([])}
              aria-label="Clear selection"
              className="flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <X className="size-4" />
            </button>
            <p className="mr-auto text-sm font-medium tabular-nums">{count} selected</p>
            <button
              type="button"
              onClick={() => setNotice({ text: `Preparing download of ${plural(count)}` })}
              className="inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <Download className="size-4" />
              Download
            </button>
            <DropdownMenu
              align="end"
              width={208}
              trigger={
                <>
                  <FolderInput className="size-4" />
                  Move
                </>
              }
              items={[
                { type: "label", label: "Move to" },
                ...["Archive", "Shared", "Team documents"].map((name) => ({
                  type: "item" as const,
                  label: name,
                  icon: Folder,
                  onSelect: () => remove(selectedIds, (n) => `Moved ${plural(n)} to ${name}`, true),
                })),
              ]}
            />
            <button
              type="button"
              onClick={() => remove(selectedIds, (n) => `Deleted ${plural(n)}`, true)}
              className="inline-flex h-9 items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 text-sm font-medium text-destructive hover:bg-destructive/15 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <Trash2 className="size-4" />
              Delete
            </button>
          </div>
        )}

        <div className="flex-1 px-4 py-5 sm:px-6">
          {visible.length === 0 ? (
            <div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed px-6 py-16 text-center">
              <Folder className="size-8 text-muted-foreground/60" />
              <p className="text-sm font-medium">{needle ? `No files match “${query.trim()}”` : "This folder is empty"}</p>
              <p className="text-sm text-muted-foreground">{needle ? "Try a different name." : "Drop files below to add them."}</p>
            </div>
          ) : view === "grid" ? (
            <>
              <div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
                {selectAll}
                <span>Select all</span>
              </div>
              <ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-5">
                {visible.map((item) => {
                  const checked = selected.includes(item.id)
                  return (
                    <li key={item.id} className="min-w-0">
                      <label
                        className={cn(
                          "relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-3 transition-[border-color,background-color,box-shadow] hover:shadow-sm has-focus-visible:ring-2 has-focus-visible:ring-ring",
                          checked && "border-primary bg-primary/5"
                        )}
                      >
                        <span className="flex items-start justify-between">
                          <KindIcon kind={item.kind} className="size-12 rounded-xl" />
                          <SelectBox checked={checked} onChange={() => toggle(item.id)} label={`Select ${item.name}`} />
                        </span>
                        <span className="min-w-0">
                          <span className="block truncate text-sm font-medium" title={item.name}>
                            {item.name}
                          </span>
                          <span className="mt-0.5 block truncate text-xs text-muted-foreground">
                            {formatSize(item)} · {formatDate(item.modified)}
                          </span>
                        </span>
                      </label>
                    </li>
                  )
                })}
              </ul>
            </>
          ) : (
            <div className="overflow-x-auto rounded-xl border">
              <table className="w-full text-left text-sm">
                <caption className="sr-only">Files in Hexa Launch</caption>
                <thead>
                  <tr className="border-b bg-muted/40 text-xs text-muted-foreground">
                    <th scope="col" className="w-10 py-2.5 pr-0 pl-4 font-medium">
                      {selectAll}
                    </th>
                    <th scope="col" className="px-3 py-2.5 font-medium">
                      Name
                    </th>
                    <th scope="col" className="hidden px-3 py-2.5 font-medium sm:table-cell">
                      Modified
                    </th>
                    <th scope="col" className="px-4 py-2.5 text-right font-medium">
                      Size
                    </th>
                  </tr>
                </thead>
                <tbody>
                  {visible.map((item) => {
                    const checked = selected.includes(item.id)
                    return (
                      <tr key={item.id} className={cn("border-b last:border-0 hover:bg-accent/40", checked && "bg-primary/5")}>
                        <td className="py-2.5 pr-0 pl-4">
                          <SelectBox checked={checked} onChange={() => toggle(item.id)} label={`Select ${item.name}`} />
                        </td>
                        <td className="max-w-0 px-3 py-2.5">
                          <span className="flex items-center gap-3">
                            <KindIcon kind={item.kind} className="size-8 rounded-md [&_svg]:size-4" />
                            <span className="min-w-0">
                              <span className="block truncate font-medium">{item.name}</span>
                              <span className="block truncate text-xs text-muted-foreground sm:hidden">{formatDate(item.modified)}</span>
                            </span>
                          </span>
                        </td>
                        <td className="hidden px-3 py-2.5 whitespace-nowrap text-muted-foreground sm:table-cell">{formatDate(item.modified)}</td>
                        <td className="px-4 py-2.5 text-right whitespace-nowrap text-muted-foreground tabular-nums">{formatSize(item)}</td>
                      </tr>
                    )
                  })}
                </tbody>
              </table>
            </div>
          )}

          <section id="upload" aria-labelledby="upload-title" className="mt-8 scroll-mt-6">
            <h2 id="upload-title" className="mb-3 text-sm font-semibold">
              Upload
            </h2>
            <FileDropzone className="max-w-none" title="Drop files here or click to browse" maxFiles={8} maxSizeMb={500} />
          </section>

          <div className="mt-8 md:hidden">
            <UsageMeter title="Storage" unit="GB" limit={100} segments={storageSegments} className="max-w-none" />
          </div>
        </div>
      </main>

      <div
        role="status"
        aria-live="polite"
        className={cn(
          notice
            ? "fixed bottom-4 left-1/2 z-20 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2 rounded-xl bg-foreground py-2 pr-2 pl-4 text-sm text-background shadow-lg animate-in fade-in slide-in-from-bottom-2"
            : "sr-only"
        )}
      >
        <span className="min-w-0 truncate">{notice?.text}</span>
        {notice?.restore && (
          <button
            type="button"
            onClick={() => {
              const restored = notice.restore ?? []
              setItems((all) => [...all, ...restored])
              setNotice({ text: `Restored ${plural(restored.length)}` })
            }}
            className="h-7 shrink-0 rounded-md px-2 font-medium underline-offset-2 hover:bg-background/15 hover:underline focus-visible:ring-2 focus-visible:ring-background focus-visible:outline-none"
          >
            Undo
          </button>
        )}
        {notice && (
          <button
            type="button"
            onClick={() => setNotice(null)}
            aria-label="Dismiss notification"
            className="flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-background/15 focus-visible:ring-2 focus-visible:ring-background focus-visible:outline-none"
          >
            <X className="size-4" />
          </button>
        )}
      </div>
    </div>
  )
}
```
