File list
Files as rows with type icon, name, size and one download action.
npx shadcn@latest add https://ui.goodtabs.co/r/file-list.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/file-list
Icon tile, name, type and size, then the action.
Mixed types
Long names
Usage
import { FileList } from "@/components/ui/file-list"
<FileList files={[{ name: "spec.pdf", size: 240000, href: "/spec.pdf" }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| files | { name: string; size?: number; href: string }[] | Name with extension, size in bytes when known, and the download link. | |
| label | string | "Files" | Accessible name for the list. |
Source
file-list/file-list.tsx
import * as React from "react";
import { DownloadIcon, FileIcon, FileTextIcon, FileArchiveIcon, ImageIcon, FileCodeIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type FileItem = {
/** File name with its extension. */
name: string;
/** Size in bytes. Left out, the row shows the type alone. */
size?: number;
/** Where the action points. */
href: string;
};
type FileListProps = Omit<React.ComponentProps<"ul">, "children"> & {
files: FileItem[];
/** Accessible name for the list. */
label?: string;
};
const ICONS: Record<string, React.ComponentType<React.ComponentProps<"svg">>> = {
pdf: FileTextIcon,
md: FileTextIcon,
txt: FileTextIcon,
png: ImageIcon,
jpg: ImageIcon,
svg: ImageIcon,
zip: FileArchiveIcon,
json: FileCodeIcon,
tsx: FileCodeIcon,
};
/** 1 kB is 1000 bytes. One decimal under 10, none above. Empty when there is no size to show. */
export function formatBytes(bytes?: number) {
if (bytes === undefined || !Number.isFinite(bytes)) return "";
if (bytes < 1000) return `${bytes} B`;
const units = ["kB", "MB", "GB", "TB"];
let value = bytes;
let i = -1;
do {
value /= 1000;
i += 1;
} while (Math.round(value) >= 1000 && i < units.length - 1);
return `${value < 9.95 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
function extensionOf(name: string) {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}
/**
* Files as rows: type icon, name, extension and size in mono, and one download action.
* Rows share hairlines inside one bordered box.
*/
export function FileList({ files, label = "Files", className, ...props }: FileListProps) {
return (
<ul
aria-label={label}
className={cn(
"m-0 w-full list-none divide-y divide-border rounded-lg border border-border bg-background p-0",
className,
)}
{...props}
>
{files.map((file, i) => {
const ext = extensionOf(file.name);
const size = formatBytes(file.size);
const Icon = ICONS[ext] ?? FileIcon;
return (
<li key={i} className="flex items-center gap-3 px-3 py-2">
<span
aria-hidden="true"
className="inline-grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground"
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="m-0 truncate text-sm text-foreground">{file.name}</p>
<p className="m-0 font-mono text-xs text-muted-foreground tabular-nums">
{ext ? ext.toUpperCase() : "FILE"}
{size && " · "}
{size}
</p>
</div>
<Button asChild variant="ghost" size="icon-sm" className="rounded-full">
<a href={file.href} download aria-label={`Download ${file.name}`}>
<DownloadIcon />
</a>
</Button>
</li>
);
})}
</ul>
);
}
For AI agents
Hand your agent this page, the markdown at /components/file-list/prompt.md, or the Copy AI prompt button above.
When to use
- Attachments on a message, ticket or release
- A small set of downloads on a docs or handoff page
When not to use
- Picking or uploading files: use file-drop
- Hundreds of files with sorting: use a table
Agent notes
- One action per row by design: a download link with an icon-only button look and an aria-label
- Size is bytes in, decimal units out (1 kB is 1000 bytes); with no size the row shows the type alone
- The icon comes from the extension; unknown extensions get a plain file icon
- Server component; the link is a real anchor with the download attribute