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)
| Prop | Type | Default | Description |
|---|---|---|---|
| file | File | null | — | A File to preview (client-side). Takes precedence over src. |
| src | string | — | A URL to preview (e.g. a stored document). |
| name | string | — | File name; drives the type when previewing by src. |
| type | string | — | MIME type hint (inferred from name/File otherwise). |
| downloadUrl | string | — | Download URL for the toolbar/fallback; defaults to src/file. |
| maxHeight | number | string | "min(70vh, 640px)" | Caps the preview height; the frame scrolls within it (mobile-friendly). |
| className | string | — | 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.

Full source
src/components/ui/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
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
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
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 };
}