DocumentViewer

Responsive, type-aware preview: images, PDFs, CSV/Excel (bordered table), Markdown & Word (formatted), JSON, text, and media — with a download fallback.

import { DocumentViewer }from "@/components/ui"

Specification

Previews a single document by type — image, PDF, CSV/Excel spreadsheet (a bordered table with a tinted header and row-number gutter), Markdown and Word (.docx) as formatted prose, JSON pretty-printed, plain text, and video/audio — and falls back to a download card for anything else. Works with a client File or a stored src URL and is mobile-responsive via a capped, scrollable frame. Heavy parsers (Excel, Word, Markdown) are lazy-loaded only when their type is previewed; Word/Markdown HTML is sanitized.

Anatomy

  • Toolbar: type icon + name + size + download
  • Body (capped, scrollable): image / PDF frame / bordered spreadsheet table / formatted prose (md, docx) / text / media / fallback
  • Spreadsheet: cell borders, tinted header (X axis) and row-number gutter (Y axis)
  • Workbook: a sheet switcher (tabs) above the table for files with multiple sheets

States

  • Loading (spinner) while content is read/parsed
  • Rendered by type: image, PDF, CSV/Excel table, Markdown/Word prose, JSON, text, video, audio
  • Too large (>8MB) or unsupported: file card with a download action

Accessibility

  • Image has descriptive alt; PDF iframe is titled
  • Spreadsheets render as a real <table> with row headers + sticky header
  • Markdown/Word HTML is sanitized (DOMPurify) before rendering
  • Video/audio use native controls; download is a real <a download>

Spec sourced from mathesis ui-component/document-viewer

Props (7)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
fileFile | null—A File to preview (client-side). Takes precedence over src.
srcstring—A URL to preview (e.g. a stored document).
namestring—File name; drives the type when previewing by src.
typestring—MIME type hint (inferred from name/File otherwise).
downloadUrlstring—Download URL for the toolbar/fallback; defaults to src/file.
maxHeightnumber | string"min(70vh, 640px)"Caps the preview height; the frame scrolls within it (mobile-friendly).
classNamestring—Extra classes on the wrapper.

All document types

Switch between an image, PDF, spreadsheet, Markdown, Word, and text — each rendered by type. Word/Markdown show as formatted prose; spreadsheets get a bordered, axis-tinted table.

photo.svg
photo.svg
Full source
The complete component, read from src/components/ui/document-viewer.tsx.
document-viewer.tsx
"use client";

import { useState, useEffect } from "react";
import { Download, FileQuestion, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatFileSize, fileKind, fileIcon, fileExtension } from "@/lib/upload";

interface DocumentViewerProps {
  /** A File to preview (client-side). Takes precedence over `src`. */
  file?: File | null;
  /** A URL to preview (e.g. a stored document). */
  src?: string;
  /** File name (drives the type when previewing by `src`). */
  name?: string;
  /** MIME type hint (optional; inferred from name/File otherwise). */
  type?: string;
  /** Download URL/name for the fallback + toolbar. Defaults to `src`/file name. */
  downloadUrl?: string;
  /** Max preview height (mobile-responsive). Defaults to a sensible viewport-relative cap. */
  maxHeight?: number | string;
  className?: string;
}

/**
 * DocumentViewer — a responsive, type-aware preview for a document. Renders
 * images inline, PDFs in an embedded frame, CSV/Excel (.xlsx/.xls) spreadsheets
 * as a bordered table, Markdown and Word (.docx) as formatted prose, JSON
 * pretty-printed, plain text as a code block, and video/audio with native
 * controls; anything else shows a file card with a download action. Works with
 * a `File` (client) or a `src` URL.
 *
 * Heavy parsers are lazy-loaded via dynamic import only when their type is
 * actually previewed, so they never enter the bundle otherwise: `xlsx` (Excel),
 * `mammoth` (Word → HTML), `marked` (Markdown → HTML). Markdown/Word HTML is
 * sanitized with `dompurify` before rendering. Large files (>8MB) skip inline
 * parsing and offer a download; tables are capped at 500 rows. If an optional
 * parser package isn't installed, that type falls back to the download card.
 */
export function DocumentViewer({
  file,
  src,
  name,
  type,
  downloadUrl,
  maxHeight = "min(70vh, 640px)",
  className,
}: DocumentViewerProps) {
  const [objectUrl, setObjectUrl] = useState<string | null>(null);
  const [text, setText] = useState<string | null>(null);
  const [html, setHtml] = useState<string | null>(null);
  const [rows, setRows] = useState<string[][] | null>(null);
  // Multi-sheet workbooks: parsed sheets (name → rows) + the active sheet name.
  const [sheets, setSheets] = useState<{ name: string; rows: string[][] }[] | null>(null);
  const [activeSheet, setActiveSheet] = useState(0);
  const [loading, setLoading] = useState(false);
  const [tooLarge, setTooLarge] = useState(false);

  const resolvedName = file?.name ?? name ?? "document";
  const resolvedType = file?.type ?? type ?? "";
  const kind = fileKind(resolvedName, resolvedType);
  const ext = fileExtension(resolvedName);
  const isCsv = ext === "csv" || resolvedType === "text/csv";
  const isExcel = ext === "xlsx" || ext === "xls" || resolvedType.includes("spreadsheet") || resolvedType.includes("excel");
  const isMarkdown = ext === "md" || ext === "markdown" || resolvedType === "text/markdown";
  const isDocx = ext === "docx" || resolvedType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
  const isJson = ext === "json" || resolvedType === "application/json";
  const isPlainText = !isMarkdown && !isJson && (kind === "code" || ext === "txt" || resolvedType.startsWith("text/"));
  const isMedia = kind === "video" || kind === "audio";
  const url = objectUrl ?? src ?? undefined;
  const download = downloadUrl ?? url;

  // Guard: skip heavy inline parsing for very large files.
  const parseByteLimit = 8 * 1024 * 1024;

  // Build an object URL for a File.
  useEffect(() => {
    if (file) {
      const u = URL.createObjectURL(file);
      setObjectUrl(u);
      return () => URL.revokeObjectURL(u);
    }
    setObjectUrl(null);
  }, [file]);

  // Load + parse content for the inline renderers. Heavy parsers (SheetJS,
  // mammoth, marked) are lazy-loaded only when their type is actually
  // previewed, so they never enter the bundle otherwise.
  useEffect(() => {
    let cancelled = false;
    setText(null);
    setHtml(null);
    setRows(null);
    setSheets(null);
    setActiveSheet(0);
    setTooLarge(false);

    const needsParse = isCsv || isExcel || isMarkdown || isDocx || isJson || isPlainText;
    if (!needsParse || (!file && !src)) return;

    if (file && file.size > parseByteLimit) {
      setTooLarge(true);
      return;
    }

    setLoading(true);
    const run = async () => {
      try {
        if (isExcel) {
          const XLSX = await import("xlsx");
          const buf = file ? await file.arrayBuffer() : await fetch(src!).then((r) => r.arrayBuffer());
          const wb = XLSX.read(buf, { type: "array" });
          const parsed = wb.SheetNames.map((sheetName) => {
            const aoa = XLSX.utils.sheet_to_json<string[]>(wb.Sheets[sheetName], { header: 1, blankrows: false, defval: "" });
            return { name: sheetName, rows: aoa.map((r) => r.map((c) => String(c ?? ""))).slice(0, 500) };
          });
          if (!cancelled) setSheets(parsed.length ? parsed : [{ name: "Sheet1", rows: [] }]);
        } else if (isDocx) {
          // Word → HTML via mammoth, then sanitize before rendering.
          const [mammoth, DOMPurify] = await Promise.all([
            import("mammoth"),
            import("dompurify"),
          ]);
          const buf = file ? await file.arrayBuffer() : await fetch(src!).then((r) => r.arrayBuffer());
          const { value } = await mammoth.convertToHtml({ arrayBuffer: buf });
          if (!cancelled) setHtml(DOMPurify.default.sanitize(value));
        } else if (isMarkdown) {
          const [{ marked }, DOMPurify] = await Promise.all([import("marked"), import("dompurify")]);
          const content = file ? await file.text() : await fetch(src!).then((r) => r.text());
          if (cancelled) return;
          const parsed = await marked.parse(content, { async: true });
          setHtml(DOMPurify.default.sanitize(parsed));
        } else {
          const content = file ? await file.text() : await fetch(src!).then((r) => r.text());
          if (cancelled) return;
          if (isCsv) setRows(parseCsv(content).slice(0, 500));
          else if (isJson) setText(prettyJson(content));
          else setText(content);
        }
      } catch {
        if (!cancelled) {
          setText(null);
          setHtml(null);
          setRows(null);
          setSheets(null);
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    };
    run();
    return () => {
      cancelled = true;
    };
  }, [file, src, isCsv, isExcel, isMarkdown, isDocx, isJson, isPlainText]);

  const frameStyle = { maxHeight, height: kind === "image" ? undefined : maxHeight };

  return (
    <div className={cn("overflow-hidden rounded-xl border border-border bg-surface", className)}>
      <Toolbar name={resolvedName} sizeLabel={file ? formatFileSize(file.size) : undefined} download={download} kind={kind} />

      <div className="bg-bg-secondary" style={{ maxHeight }}>
        {loading ? (
          <Centered style={frameStyle}>
            <Loader2 size={20} className="animate-spin text-text-tertiary" aria-hidden />
            <span className="sr-only">Loading preview</span>
          </Centered>
        ) : tooLarge ? (
          <Unsupported
            name={resolvedName}
            type={resolvedType}
            download={download}
            style={frameStyle}
            message="File is too large to preview here."
          />
        ) : !url && !text && !rows && !html && !sheets ? (
          <Centered style={frameStyle}>
            <FileQuestion size={22} className="text-text-tertiary" aria-hidden />
            <p className="text-sm text-text-secondary">Nothing to preview.</p>
          </Centered>
        ) : kind === "image" ? (
          <div className="flex items-center justify-center overflow-auto p-4" style={{ maxHeight }}>
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src={url} alt={resolvedName} className="max-h-full max-w-full rounded-md object-contain" style={{ maxHeight }} />
          </div>
        ) : kind === "document" && ext === "pdf" ? (
          <iframe title={resolvedName} src={url} className="w-full" style={{ height: maxHeight }} />
        ) : kind === "video" ? (
          <div className="flex items-center justify-center bg-black" style={{ maxHeight }}>
            {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
            <video src={url} controls className="max-h-full max-w-full" style={{ maxHeight }} />
          </div>
        ) : kind === "audio" ? (
          <div className="flex items-center justify-center p-6">
            {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
            <audio src={url} controls className="w-full max-w-md" />
          </div>
        ) : isExcel && sheets != null ? (
          <Workbook sheets={sheets} active={activeSheet} onSelect={setActiveSheet} maxHeight={maxHeight} />
        ) : isCsv && rows != null ? (
          <SheetTable rows={rows} maxHeight={maxHeight} />
        ) : (isMarkdown || isDocx) && html != null ? (
          <div className="overflow-auto bg-surface" style={{ maxHeight }}>
            <div
              className="doc-prose mx-auto max-w-2xl p-5 text-sm text-text"
              // Sanitized with DOMPurify before being set (see parse effect).
              dangerouslySetInnerHTML={{ __html: html }}
            />
          </div>
        ) : text != null ? (
          <pre className="overflow-auto p-4 font-mono text-xs leading-relaxed text-text" style={{ maxHeight }}>
            {text}
          </pre>
        ) : (
          <Unsupported name={resolvedName} type={resolvedType} download={download} style={frameStyle} />
        )}
      </div>
    </div>
  );
}

function Toolbar({ name, sizeLabel, download, kind }: { name: string; sizeLabel?: string; download?: string; kind: ReturnType<typeof fileKind> }) {
  const Icon = fileIcon(kind);
  return (
    <div className="flex items-center gap-2 border-b border-border px-3 py-2">
      <Icon size={16} className="shrink-0 text-text-secondary" aria-hidden />
      <span className="min-w-0 flex-1 truncate text-sm font-medium text-text">{name}</span>
      {sizeLabel && <span className="hidden shrink-0 text-xs tabular-nums text-text-tertiary sm:inline">{sizeLabel}</span>}
      {download && (
        <a
          href={download}
          download={name}
          className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-text-secondary transition-colors hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
        >
          <Download size={13} aria-hidden />
          <span className="hidden sm:inline">Download</span>
        </a>
      )}
    </div>
  );
}

/** Minimal, dependency-free CSV parser (handles quotes and escaped quotes). */
/** Pretty-print a JSON string; returns the original text if it isn't valid JSON. */
function prettyJson(text: string): string {
  try {
    return JSON.stringify(JSON.parse(text), null, 2);
  } catch {
    return text;
  }
}

function parseCsv(text: string): string[][] {
  const rows: string[][] = [];
  let row: string[] = [];
  let cell = "";
  let inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"') {
        if (text[i + 1] === '"') {
          cell += '"';
          i++;
        } else inQuotes = false;
      } else cell += c;
    } else if (c === '"') {
      inQuotes = true;
    } else if (c === ",") {
      row.push(cell);
      cell = "";
    } else if (c === "\n" || c === "\r") {
      if (c === "\r" && text[i + 1] === "\n") i++;
      row.push(cell);
      rows.push(row);
      row = [];
      cell = "";
    } else cell += c;
  }
  if (cell.length > 0 || row.length > 0) {
    row.push(cell);
    rows.push(row);
  }
  return rows.filter((r) => r.some((v) => v.trim() !== ""));
}

/** A multi-sheet workbook: a sheet switcher + the active sheet's table. */
function Workbook({
  sheets,
  active,
  onSelect,
  maxHeight,
}: {
  sheets: { name: string; rows: string[][] }[];
  active: number;
  onSelect: (i: number) => void;
  maxHeight: number | string;
}) {
  const current = sheets[Math.min(active, sheets.length - 1)] ?? sheets[0];
  const multi = sheets.length > 1;
  // Reserve ~40px for the sheet tab bar so the table still fits the frame.
  const tableMax = multi ? `calc(${typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight} - 40px)` : maxHeight;

  return (
    <div>
      {multi && (
        <div
          role="tablist"
          aria-label="Sheets"
          className="flex items-center gap-1 overflow-x-auto border-b border-border bg-surface px-2 py-1.5"
        >
          {sheets.map((s, i) => {
            const on = i === active;
            return (
              <button
                key={`${s.name}-${i}`}
                type="button"
                role="tab"
                aria-selected={on}
                onClick={() => onSelect(i)}
                className={cn(
                  "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
                  on ? "bg-accent-light text-accent" : "text-text-secondary hover:bg-bg-hover hover:text-text"
                )}
              >
                {s.name}
              </button>
            );
          })}
        </div>
      )}
      <SheetTable rows={current.rows} maxHeight={tableMax} />
    </div>
  );
}

function SheetTable({ rows, maxHeight }: { rows: string[][]; maxHeight: number | string }) {
  if (rows.length === 0) {
    return (
      <Centered style={{ height: maxHeight }}>
        <p className="text-sm text-text-secondary">Empty spreadsheet.</p>
      </Centered>
    );
  }
  const [header, ...body] = rows;
  const colCount = rows.reduce((max, r) => Math.max(max, r.length), 0);
  const cols = Array.from({ length: colCount });
  return (
    <div className="overflow-auto" style={{ maxHeight }}>
      <table className="border-collapse text-left text-xs tabular-nums">
        <thead className="sticky top-0 z-10">
          <tr>
            {/* Corner cell of the header / row-number gutter (X × Y axis origin). */}
            <th
              className="sticky left-0 z-10 border border-border bg-bg-active px-2 py-2 text-center font-semibold text-text-tertiary"
              aria-hidden
            />
            {cols.map((_, i) => (
              <th
                key={i}
                className="whitespace-nowrap border border-border bg-bg-secondary px-3 py-2 font-semibold uppercase tracking-wide text-text-secondary"
              >
                {header[i] || `Col ${i + 1}`}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {body.map((r, ri) => (
            <tr key={ri}>
              {/* Row-number gutter — the Y axis, subtly tinted like the header. */}
              <th
                scope="row"
                className="sticky left-0 border border-border bg-bg-secondary px-2 py-1.5 text-center font-medium text-text-tertiary"
              >
                {ri + 1}
              </th>
              {cols.map((_, ci) => (
                <td key={ci} className="whitespace-nowrap border border-border px-3 py-1.5 text-text">
                  {r[ci] ?? ""}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function Unsupported({ name, type, download, style, message }: { name: string; type: string; download?: string; style: React.CSSProperties; message?: string }) {
  const Icon = fileIcon(fileKind(name, type));
  return (
    <Centered style={style}>
      <span className="flex size-14 items-center justify-center rounded-xl bg-surface text-text-secondary">
        <Icon size={26} aria-hidden />
      </span>
      <div className="text-center">
        <p className="text-sm font-medium text-text">{name}</p>
        <p className="text-xs text-text-secondary">{message ?? "Preview isn’t available for this file type."}</p>
      </div>
      {download && (
        <a
          href={download}
          download={name}
          className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
        >
          <Download size={15} aria-hidden />
          Download
        </a>
      )}
    </Centered>
  );
}

function Centered({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
  return (
    <div className="flex flex-col items-center justify-center gap-3 p-8" style={style}>
      {children}
    </div>
  );
}

Dependencies

To use DocumentViewer you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ document-viewer.tsx
src/lib/
  ├─ upload.ts
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/lib/upload.ts
upload.ts
import {
  FileText,
  FileSpreadsheet,
  FileImage,
  FileArchive,
  FileVideo,
  FileAudio,
  FileCode,
  File as FileIcon,
  type LucideIcon,
} from "lucide-react";

/**
 * Shared, framework-agnostic helpers for the upload components
 * (FileUpload, FileUploadMultiple, PhotoUpload) and DocumentsTable.
 * Pure functions — safe to call during render.
 */

/** Broad file kind used for preview + icon selection. */
export type FileKind = "image" | "document" | "spreadsheet" | "archive" | "video" | "audio" | "code" | "other";

/** Format a byte count as a human-readable size, e.g. `2.4 MB`. */
export function formatFileSize(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
  const value = bytes / 1024 ** i;
  // No decimals for bytes; one decimal otherwise.
  return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}

/** Lowercased extension without the dot (e.g. `pdf`), or `""` when none. */
export function fileExtension(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}

const EXT_KIND: Record<string, FileKind> = {
  // documents
  pdf: "document", doc: "document", docx: "document", txt: "document", rtf: "document", md: "document",
  // spreadsheets
  xls: "spreadsheet", xlsx: "spreadsheet", csv: "spreadsheet",
  // images
  png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", avif: "image", heic: "image",
  // archives
  zip: "archive", rar: "archive", "7z": "archive", tar: "archive", gz: "archive",
  // media
  mp4: "video", mov: "video", webm: "video", avi: "video", mkv: "video",
  mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio",
  // code
  js: "code", ts: "code", tsx: "code", jsx: "code", json: "code", html: "code", css: "code",
};

/** Classify a file by MIME type first, falling back to its extension. */
export function fileKind(nameOrType: string, mime?: string): FileKind {
  if (mime) {
    if (mime.startsWith("image/")) return "image";
    if (mime.startsWith("video/")) return "video";
    if (mime.startsWith("audio/")) return "audio";
    if (mime === "application/pdf") return "document";
    if (mime.includes("spreadsheet") || mime.includes("excel") || mime === "text/csv") return "spreadsheet";
    if (mime.includes("zip") || mime.includes("compressed") || mime.includes("tar")) return "archive";
  }
  return EXT_KIND[fileExtension(nameOrType)] ?? "other";
}

const KIND_ICON: Record<FileKind, LucideIcon> = {
  image: FileImage,
  document: FileText,
  spreadsheet: FileSpreadsheet,
  archive: FileArchive,
  video: FileVideo,
  audio: FileAudio,
  code: FileCode,
  other: FileIcon,
};

/** The lucide icon that represents a file kind. */
export function fileIcon(kind: FileKind): LucideIcon {
  return KIND_ICON[kind];
}

export interface FileConstraints {
  /** Max size per file, in bytes. */
  maxSize?: number;
  /**
   * Accepted types, in the same syntax as the input `accept` attribute:
   * extensions (".pdf"), MIME types ("image/png"), or wildcards ("image/*").
   */
  accept?: string;
}

/**
 * Validate a file against constraints. Returns a human-readable error string
 * ("what + why"), or `null` when the file is acceptable.
 */
export function validateFile(file: File, constraints: FileConstraints = {}): string | null {
  const { maxSize, accept } = constraints;
  if (typeof maxSize === "number" && file.size > maxSize) {
    return `File is too large — max ${formatFileSize(maxSize)}.`;
  }
  if (accept && !matchesAccept(file, accept)) {
    return `File type not allowed — accepted: ${accept}.`;
  }
  return null;
}

/** Whether a file satisfies an `accept` list (extensions, MIME, wildcards). */
export function matchesAccept(file: File, accept: string): boolean {
  const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
  if (patterns.length === 0) return true;
  const type = file.type.toLowerCase();
  const ext = `.${fileExtension(file.name)}`;
  return patterns.some((p) => {
    if (p.startsWith(".")) return ext === p;
    if (p.endsWith("/*")) return type.startsWith(p.slice(0, -1)); // e.g. "image/"
    return type === p;
  });
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}