There is a category of web apps where users keep asking the same question: “can I just drop my Excel file into this?” Internal tools. Reporting dashboards. Data import wizards. Pricing-sheet uploaders. The honest answer for most of them has been “we’ll send it to the server, then parse it, then re-render it.” Round-tripping a 5 MB file through a backend, just to show it back to the user, is a lot of moving parts.
ReoGrid Web does the whole pipeline in the browser: file picker or drag-and-drop, in-browser parse, canvas render. The user never sees a network request. Cell styles, merges, borders, freeze panes, formulas, conditional formatting, and outline grouping all come through.
This post walks through how to wire it up, what survives the round trip, and how to handle big files without freezing the tab.
The 30-second version
<input type="file" accept=".xlsx" id="file" />
<div id="grid" style="width: 100%; height: 600px;"></div>
import { createReogrid } from '@reogrid/lite';
const { worksheet } = createReogrid('#grid');
document.querySelector<HTMLInputElement>('#file')!
.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
await worksheet.loadFromFile(file);
});
That is the whole thing. loadFromFile accepts a File object directly — it reads the bytes via FileReader, parses the OOXML zip, materializes cells/styles/merges/borders, and resolves once the grid is ready to show.
Note: xlsx import works in both Lite and Pro. Only export is Pro-only. So this code runs on the free package.
React, with drag-and-drop
For a real app the file input is usually nicer as a drop zone. The full pattern, with both file input and drag-drop fallback:
import { Reogrid, type ReogridInstance } from '@reogrid/lite/react';
import { useRef, useState, type DragEvent } from 'react';
export default function XlsxViewer() {
const gridRef = useRef<ReogridInstance>(null);
const [info, setInfo] = useState('No file loaded');
const [dragging, setDragging] = useState(false);
async function loadFile(file: File) {
if (!file.name.endsWith('.xlsx')) {
setInfo('Please drop an .xlsx file');
return;
}
await gridRef.current?.worksheet.loadFromFile(file);
setInfo(`${file.name} · ${(file.size / 1024).toFixed(1)} KB`);
}
function onDrop(e: DragEvent) {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
}
return (
<div
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setDragging(false); }}
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
style={{ position: 'relative' }}
>
<input
type="file"
accept=".xlsx"
onChange={(e) => e.target.files?.[0] && loadFile(e.target.files[0])}
/>
<div style={{ marginTop: 4, fontSize: 13, color: '#64748b' }}>{info}</div>
<Reogrid ref={gridRef} style={{ width: '100%', height: 600 }} />
{dragging && (
<div style={{
position: 'absolute', inset: 0, background: 'rgba(59,130,246,0.1)',
border: '2px dashed #3b82f6', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, fontWeight: 600, color: '#1d4ed8', pointerEvents: 'none',
}}>
Drop to load
</div>
)}
</div>
);
}
The live XLSX Viewer demo on this site uses essentially this code. Drop your own file in to test it — nothing leaves your machine.
What survives the round-trip
Real-world .xlsx files are messier than they look. The format is technically OOXML but in practice it is “whatever Excel happens to write,” with theme colors, shared strings, dxf overrides, rich-text runs, and conditional sections in number formats. ReoGrid Web’s importer has been hardened against the realistic surface over v1.1 and v1.2:
| Feature | Status |
|---|---|
| Cell values, formulas, shared strings | ✅ |
| Cell styles (font, color, alignment, fill) | ✅ |
| Borders (per-side, color, style) | ✅ |
| Merged cells | ✅ |
| Number formats (currency, date, custom patterns) | ✅ — including conditional sections like [=0]"-";m/d/yy |
| Frozen panes | ✅ — round-trips through export |
| Row/column outline (grouping) | ✅ — level, collapsed state, summary direction |
| Conditional formatting | ✅ — incl. theme colors with tint and <bgColor> fills |
| Images | ✅ |
| Rich text runs | ✅ |
| Pivot tables | ❌ — read as flat cell values |
| Charts | ❌ |
| Macros (VBA) | ❌ — ignored |
Two fixes from v1.2 worth calling out, because they bite real files:
- Excel serial date off-by-one corrected. Serial 45658 now resolves to 2025-01-01. Files saved by older Excel versions and round-tripped through libraries that mis-treated the 1900 leap-year bug would land on the wrong day.
- Conditional sections in number formats (
[=0],[<>N],[<N],[<=N],[>N],[>=N]) now evaluate independent of value type. Format codes like[=0]"-";m/d/yyno longer always pick the first section.
If you have a file that doesn’t render right, we want to see it.
Big files: don’t freeze the tab
Loading a 440,000-cell file synchronously will hold the main thread for several seconds. The user sees a frozen tab. You can avoid this entirely with the chunked loader:
await worksheet.loadFromFile(file, { chunked: true });
What this does:
- Parses the OOXML zip in one pass (fast — that part is bounded by file size, not cell count).
- Yields the main thread between cell-ingest batches via
requestAnimationFrame. - The user sees the first frame render at ~40ms instead of a multi-second freeze, then watches additional rows fill in as the loader progresses.
Optionally tune the batch size:
await worksheet.loadFromFile(file, { chunked: { batchSize: 2000 } });
End-to-end load time on our benchmark file (1945 × 503, ~440k cells):
| Synchronous | Chunked | |
|---|---|---|
| First paint | ~3.7s | ~40ms |
| Total load | 3.7s | ~4.0s |
| Main-thread freeze | 3.7s | none — 16–30ms slices |
There is a small overhead from the chunking (the total is ~10% slower), but the user experience swap is worth it on anything bigger than ~50k cells.
v1.1 also cut sync load time itself by ~40% on big files (6.2s → 3.7s on the same benchmark), with improvements spread across the parser, the cell-extraction walk, the worksheet bulk-load path, and the formula engine’s initial rebuild.
Alternative sources
loadFromFile is the friendly path. The lower-level APIs are useful when the file is not coming from a File object:
// From a URL on your origin
await worksheet.loadFromUrl('/templates/budget.xlsx');
// From a raw ArrayBuffer (e.g., fetched, streamed from IndexedDB)
const buf = await fetch('/api/report').then(r => r.arrayBuffer());
await worksheet.loadFromBuffer(buf);
// Pick a specific sheet
await worksheet.loadXlsx(buf, { sheetName: 'Q2 Forecast' });
// Chunked variant works the same
await worksheet.loadFromUrl('/large.xlsx', { chunked: true });
Lite-tier limits
The Lite edition silently truncates anything past 100 rows × 26 columns. If you are building a generic xlsx viewer for unknown user files, the Pro edition removes the limit (and adds the 109-function formula library + xlsx export). See pricing for the matrix.
Why this matters
A backend-mediated upload-parse-render loop forces you into multi-megabyte HTTP requests, server-side parsing CPU, temp-file lifecycle, and a UI that can’t show anything until the round trip completes. For a viewer or a quick-import wizard, none of that is doing work the user cares about.
Pushing the parser to the browser means the file never leaves the device, the load is bounded only by the CPU on the user’s machine, and the UI can start rendering progressively. The user clicks a file and sees their spreadsheet. That is the whole interaction.
Try it: drop your own .xlsx into the XLSX Viewer demo. The next post in this series builds on the formula engine — a complete React invoice with editable quantities, automatic line subtotals, tax, and total, all driven by formulas.