Vela UI Docsvenizia.ai

Doc File

A file attachment showing its name, type and size with a download link and an icon and colour for its kind (PDF, document, spreadsheet, presentation, archive, image, audio, video, code), as in the mail attachments; empty, it takes a chosen or dropped file of any kind.

npx shadcn@latest add @velaui/doc-file
npx shadcn@latest add @velaui/doc-file

Examples

  • Default
  • Attached
  • Empty read only

View code

Doc File · Default

Installation

pnpm dlx velaui add @velaui/doc-file
pnpm dlx velaui add @velaui/doc-file

Code

doc-file-default.tsx
import * as React from "react";

import { DocFile, type DocFileInfo } from "@/components/ui/doc-file";

export default function Example() {
  const [file, setFile] = React.useState<DocFileInfo>();
  return (
    <div className="w-full max-w-xl p-6">
      <DocFile {...file} onFileChange={setFile} />
    </div>
  );
}

View code

Doc File · Attached

Installation

pnpm dlx velaui add @velaui/doc-file
pnpm dlx velaui add @velaui/doc-file

Code

doc-file-attached.tsx
import { DocFile } from "@/components/ui/doc-file";

// One of each kind: the icon and its colour follow the extension, as the mail block's attachments do.
const FILES = [
  { name: "Q4 launch plan.pdf", size: 1_482_000, mime: "application/pdf" },
  { name: "Meeting notes.docx", size: 38_400, mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
  { name: "Budget 2027.xlsx", size: 214_000, mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
  { name: "Strategy deck.pptx", size: 4_820_000, mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
  { name: "exports-2026-10.zip", size: 48_300_000, mime: "application/zip" },
  { name: "Mockup@2x.png", size: 612_000, mime: "image/png" },
  { name: "Interview.mp3", size: 9_100_000, mime: "audio/mpeg" },
  { name: "Demo.mp4", size: 82_000_000, mime: "video/mp4" },
  { name: "schema.sql", size: 812, mime: "application/sql" },
  { name: "LICENSE", size: 1_070, mime: "text/plain" },
];

export default function Example() {
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      {FILES.map(file => (
        <DocFile key={file.name} readOnly url="#" {...file} />
      ))}
    </div>
  );
}

View code

Doc File · Empty read only

Installation

pnpm dlx velaui add @velaui/doc-file
pnpm dlx velaui add @velaui/doc-file

Code

doc-file-empty-read-only.tsx
import { DocFile } from "@/components/ui/doc-file";

export default function Example() {
  return (
    <div className="w-full max-w-xl p-6">
      <DocFile readOnly />
    </div>
  );
}

Usage

import * as React from "react";

import { DocFile, type DocFileInfo } from "@/components/ui/doc-file";

export default function Example() {
  const [file, setFile] = React.useState<DocFileInfo>();
  return (
    <div className="w-full max-w-xl p-6">
      <DocFile {...file} onFileChange={setFile} />
    </div>
  );
}

Props

PropTypeDefault
classNamestring—
mimestring—
namestring—
onFileChange(file: DocFileInfo) => void—
onUpload(file: File) => string | Promise<string>—
readOnlyboolean—
sizenumber0
urlstring—

Takes these and every prop of DocFileProps.

Dependencies

npm
none besides React
Installs with it
10 icons

Source

components/ui/doc-file.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";

import { Code01 } from "@/components/icons/code-01";
import { FileAttach01 } from "@/components/icons/file-attach-01";
import { FileAttach02 } from "@/components/icons/file-attach-02";
import { FileAttach03 } from "@/components/icons/file-attach-03";
import { Image as ImageIcon } from "@/components/icons/image";
import { Package01 } from "@/components/icons/package-01";
import { Play01 } from "@/components/icons/play-01";
import { Presentation01 } from "@/components/icons/presentation-01";
import { Table } from "@/components/icons/table";
import { VideoOn } from "@/components/icons/video-on";
import { cn } from "@/lib/utils";

export interface DocFileInfo {
  name: string;
  /** Bytes. */
  size: number;
  /** MIME type; empty when the browser could not tell. */
  mime: string;
  /** Where the file can be fetched from. */
  url: string;
}

/** `1.4 MB`, `812 KB`, `37 B`: decimal units, one decimal place from MB up. */
export function formatFileSize(bytes: number) {
  if (bytes < 1000) return `${bytes} B`;
  if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} KB`;
  if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
  return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
}

/** The label for a file's type: its extension in capitals (`PDF`, `ZIP`), else what its MIME type says, else `FILE`. */
export function fileTypeLabel({ name, mime }: { name: string; mime?: string }) {
  const ext = /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1];
  if (ext) return ext.toUpperCase();
  const sub = mime?.split("/")[1]?.split(/[+;]/)[0];
  // `text/plain` is a text file and `octet-stream` is just "some bytes": neither is worth printing as a type.
  return !sub || sub === "octet-stream" ? "FILE" : sub === "plain" ? "TXT" : sub.toUpperCase();
}

export type DocFileKind =
  "pdf" | "document" | "spreadsheet" | "presentation" | "archive" | "image" | "audio" | "video" | "code" | "other";

const EXTENSIONS: [DocFileKind, string][] = [
  ["pdf", "pdf"],
  ["document", "doc docx odt rtf txt md pages"],
  ["spreadsheet", "xls xlsx xlsm ods csv tsv numbers"],
  ["presentation", "ppt pptx odp key"],
  ["archive", "zip rar 7z tar gz tgz bz2 xz"],
  ["image", "png jpg jpeg gif webp avif svg bmp ico heic tiff"],
  ["audio", "mp3 wav ogg flac m4a aac"],
  ["video", "mp4 mov webm mkv avi m4v"],
  ["code", "js jsx ts tsx json py rb go rs java c cpp cs php html css scss sql sh yml yaml xml toml"],
];

/** What kind of file this is, from its extension, else its MIME type: it picks the icon and its colour. */
export function fileKind({ name, mime }: { name: string; mime?: string }): DocFileKind {
  const ext = /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1].toLowerCase() ?? "";
  const byExtension = ext ? EXTENSIONS.find(([, list]) => list.split(" ").includes(ext)) : undefined;
  if (byExtension) return byExtension[0];
  const top = mime?.split("/")[0];
  return top === "image" || top === "audio" || top === "video" ? top : "other";
}

/**
 * The look of each kind, as the mail attachments have it: a tinted square with a coloured glyph. PDF and Word keep
 * mail's red and blue; the other kinds take a palette hue of their own so a row of files reads at a glance.
 */
const KIND_STYLE: Record<
  DocFileKind,
  { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; field: string; glyph: string }
> = {
  pdf: { icon: FileAttach01, field: "var(--color-blue-100)", glyph: "var(--color-negative-middle)" },
  document: { icon: FileAttach02, field: "var(--color-blue-50)", glyph: "var(--color-blue-600)" },
  spreadsheet: { icon: Table, field: "var(--color-green-100)", glyph: "var(--color-green-600)" },
  presentation: { icon: Presentation01, field: "var(--color-orange-100)", glyph: "var(--color-orange-600)" },
  archive: { icon: Package01, field: "var(--color-amber-100)", glyph: "var(--color-amber-600)" },
  image: { icon: ImageIcon, field: "var(--color-purple-100)", glyph: "var(--color-purple-600)" },
  audio: { icon: Play01, field: "var(--color-pink-100)", glyph: "var(--color-pink-600)" },
  video: { icon: VideoOn, field: "var(--color-violet-100)", glyph: "var(--color-violet-600)" },
  code: { icon: Code01, field: "var(--color-slate-100)", glyph: "var(--color-slate-600)" },
  other: { icon: FileAttach03, field: "var(--color-blue-50)", glyph: "var(--color-blue-600)" },
};

/**
 * Turns a chosen file into what the block shows. `upload` is the host's storage and returns the URL; without it the
 * file is held by an object URL, which works until the page closes, so a host that keeps documents should pass one.
 */
export async function readDocFile({
  file,
  upload,
}: {
  file: File;
  upload?: (file: File) => string | Promise<string>;
}): Promise<DocFileInfo> {
  return {
    name: file.name,
    size: file.size,
    mime: file.type,
    url: await (upload ? upload(file) : URL.createObjectURL(file)),
  };
}

/** The file's icon: a tinted square with a glyph in the colour of its kind. */
function FileGlyph({ name, mime }: { name: string; mime?: string }) {
  const { icon: Icon, field, glyph } = KIND_STYLE[fileKind({ name, mime })];
  return (
    <span
      aria-hidden
      className="flex size-10 shrink-0 items-center justify-center rounded-md"
      style={{ backgroundColor: field }}
    >
      <Icon className="size-5" style={{ color: glyph }} />
    </span>
  );
}

export interface DocFileProps extends Partial<DocFileInfo> {
  readOnly?: boolean;
  /** A file was chosen or dropped; its name, size, type and URL. */
  onFileChange?: (file: DocFileInfo) => void;
  /** The host's storage: gets the file, returns its URL. Without it the file lives in an object URL. */
  onUpload?: (file: File) => string | Promise<string>;
  className?: string;
}

/** A file attachment: its name, type and size with a download link; empty, it takes a chosen or dropped file of any kind. */
function DocFile({ name, size = 0, mime, url, readOnly, onFileChange, onUpload, className }: DocFileProps) {
  const [over, setOver] = React.useState(false);
  const [error, setError] = React.useState<string>();
  const take = async (file: File | undefined) => {
    if (!file) return;
    try {
      setError(undefined);
      onFileChange?.(await readDocFile({ file, upload: onUpload }));
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  };
  const picker = (label: string) => (
    <label className="cursor-pointer rounded px-2 py-1 text-sm text-text-muted hover:bg-bg-surface-2 pointer-coarse:min-h-11 pointer-coarse:content-center">
      {label}
      <input type="file" className="sr-only" onChange={e => (void take(e.target.files?.[0]), (e.target.value = ""))} />
    </label>
  );

  return (
    <div className={cn("my-2", className)}>
      {name ? (
        <div className="flex items-center gap-3 rounded-md border border-border-default px-3 py-2">
          <FileGlyph name={name} mime={mime} />
          <div className="min-w-0 flex-1">
            {url ? (
              <a href={url} download={name} className="block truncate text-sm font-medium hover:underline">
                {name}
              </a>
            ) : (
              <span className="block truncate text-sm font-medium">{name}</span>
            )}
            <span className="text-xs text-text-muted">
              {fileTypeLabel({ name, mime })} · {formatFileSize(size)}
            </span>
          </div>
          {!readOnly && picker("Replace")}
        </div>
      ) : readOnly ? (
        <div className="flex h-16 items-center justify-center rounded-md border-2 border-dashed border-border-default text-sm text-text-muted">
          No file attached
        </div>
      ) : (
        <div
          className={cn(
            "flex flex-wrap items-center gap-2 rounded-md border-2 border-dashed p-3 text-sm text-text-muted",
            over ? "border-primary-default bg-bg-surface-2" : "border-border-default",
          )}
          onDragOver={e => (e.preventDefault(), setOver(true))}
          onDragLeave={() => setOver(false)}
          onDrop={e => {
            e.preventDefault();
            e.stopPropagation();
            setOver(false);
            void take(e.dataTransfer.files[0]);
          }}
        >
          Drop a file here, or
          {picker("choose a file")}
        </div>
      )}
      {error && (
        <p role="alert" className="mt-1 text-sm text-negative-middle">
          {error}
        </p>
      )}
    </div>
  );
}

export { DocFile };