# React Excel Viewer: Show .xlsx Files With Their Real Styles

> A React Excel viewer that keeps .xlsx fonts, fills, number formats, frozen panes and sheet tabs, reads the file in the browser and scrolls big sheets smoothly.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-excel-viewer

## TL;DR

A React Excel viewer has to read the workbook's styles, number formats, frozen panes and sheet tabs from the .xlsx itself, then draw only the rows and columns in view so a big sheet stays smooth. Converting a sheet to an HTML table keeps the data and loses most of the look. The Wingo UI Excel Viewer does this in the browser from one src prop, shows the values saved in the file without recalculating, and opens CSV too.

Someone uploads a quarterly report, or your app stores invoices as spreadsheets, and you want people to read the file inside your product instead of downloading it and opening Excel. A React Excel viewer for that job has to do more than put values in a table: the fonts, fills, borders, merged cells, number formats and frozen header rows are what make a report readable. Below: what to check in any viewer, why sheet-to-HTML loses the formatting, how dates and frozen panes really work in the file, and a preview-then-import screen. The code uses the [Excel Viewer](https://wingo-ui.com/components/excel-viewer) from Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.

## What should a React Excel viewer show?

It should show the workbook the way its author saw it in Excel, at scrolling speed. A spreadsheet is a layout as much as a dataset: drop the layout and the reader gets a wall of numbers nobody designed. Here is the list we built against, and a fair test for any react xlsx viewer you evaluate:

- **Cell styles.** Fonts, fills, borders, alignment, wrapping, merged cells, column widths and row heights, hidden rows and columns, theme colors.
- **Number formats.** Currency, percent, dates and custom codes, formatted for the reader's locale.
- **Frozen panes.** The header rows and key columns the author froze stay put while the rest scrolls.
- **Sheet tabs.** Every visible sheet as a tab, hidden sheets left out.
- **Formulas.** The saved value in the cell and the formula in a formula bar.
- **Conditional formats, tables, images and charts.** Color scales, data bars, Excel table styles, an embedded logo.
- **Speed.** Smooth scrolling on a sheet with thousands of rows, on a phone too.

## Why does converting a sheet to HTML lose the formatting?

Because the library most tutorials reach for is built for data first. The usual answer is SheetJS: read the file, call `sheet_to_html`, inject the result. Its official [React demo](https://docs.sheetjs.com/docs/demos/frontend/react) does exactly that with `dangerouslySetInnerHTML`. You get the values and their formatted text. You do not get the look: the [parse options page](https://docs.sheetjs.com/docs/api/parse-options) says "By default, SheetJS CE parsers focus on data extraction." A `cellStyles` flag reads some styling metadata such as row and column properties, and the docs list cell and text styling and conditional formatting as SheetJS Pro features. The [HTML utility page](https://docs.sheetjs.com/docs/api/utilities/html) says the same for CSS styles in the generated table (all as of October 2026).

That is the right tool when you need the data, or when you must read many formats (.xls, .ods, Numbers). For an Excel viewer component that customers read reports in, it is a poor fit: you would rebuild fills, borders, number formats and frozen panes by hand. At the other end, commercial spreadsheet controls such as [SpreadJS](https://developer.mescius.com/spreadjs) and the [Infragistics React Spreadsheet](https://www.infragistics.com/products/ignite-ui-react/react/components/spreadsheet-overview) let people edit cells and ship their own calculation engines; SpreadJS lists more than 500 built-in functions (per their product pages, as of October 2026). If people need to change the workbook, choose one of those. A viewer is for reading.

## How do you view an Excel file in React?

With a component that reads the format, it is one line of JSX. Install the viewer:

```bash
npx wingo-ui@latest add excel-viewer
```

```tsx
import { ExcelViewer } from "@/components/ui/excel-viewer";

export default function ReportPage() {
  return <ExcelViewer src="/reports/q3-sales-report.xlsx" fileName="Q3 2026 sales report.xlsx" height="80dvh" />;
}
```

The component file starts with `"use client"`, so a server component page can render it with plain props like these. `src` can be a same-origin path, a CORS-enabled URL (a presigned S3 link needs a CORS rule on the bucket) or a `blob:` URL. Leave it empty and the viewer shows an open-a-workbook state that accepts a picked or dropped `.xlsx` or `.csv`.

Under the hood it fetches the bytes, unzips them with `fflate` (about 10 KB, loaded with the first workbook instead of with the page), reads the workbook, style, theme, table and drawing XML with the browser's `DOMParser`, and scans the cell data itself with a row pattern. Nothing is sent to a conversion service.

> Live demo (Excel Viewer): Click B6 on the Summary sheet to see its SUMIFS formula in the formula bar, then open Transactions and scroll: row 1 and columns A and B stay frozen. Try it at [Excel Viewer](https://wingo-ui.com/components/excel-viewer) and install it with `npx wingo-ui@latest add excel-viewer`.

## How are Excel number formats and dates rendered?

From the format code saved with each cell, not from the raw value. Excel stores a number and a format code, and the cell shows the result. The sample report's "vs Q2" column uses `+0.0%;[Red]-0.0%;0.0%`: three sections for positive, negative and zero, with negatives in red. The viewer parses those sections, the color codes and the built-in formats, then formats the number with `Intl.NumberFormat` for the `locale` prop. Unset, it follows the `UIProvider` locale, then the browser's.

Dates need care because Excel stores them as serial day numbers: 45930 is a date only because its format code says so, which is why naive parsers print five-digit numbers in a date column. The viewer converts them in UTC so a time zone never shifts a date by a day, handles workbooks saved with the 1904 date system, and accounts for the fake February 29, 1900 that Microsoft documents as [kept for Lotus 1-2-3 compatibility](https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/excel/wrongly-assumes-1900-is-leap-year). Month and day names come from the locale too.

> Live demo (Excel Viewer): The same Transactions sheet in de-DE: decimal commas, period thousands separators and German month names, with the frozen columns still in place. Try it at [Excel Viewer](https://wingo-ui.com/components/excel-viewer) and install it with `npx wingo-ui@latest add excel-viewer`.

## How do frozen panes and sheet tabs work?

Frozen panes come from the file. Each sheet's view records a pane with `xSplit` and `ySplit`, and the viewer keeps that many columns and rows fixed, together with the A B C and 1 2 3 headings. In the sample, the Summary sheet freezes its first five rows and Transactions freezes row 1 plus columns A and B, so the date and order number stay visible while you scroll through 2,000 orders.

Sheet tabs sit at the bottom and scroll sideways. The sample has a hidden "Lists" sheet: it gets no tab, and the sheets menu lists everything. The viewer opens on the sheet the workbook was saved on unless you pass `defaultSheet`, or control it with `sheet` and `onSheetChange`. The ref adds `goTo`, which takes an address, a range on any sheet or a defined name. The sample workbook defines `Q3_totals` for the Summary table:

```tsx
"use client";

import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ExcelViewer, type ExcelViewerHandle } from "@/components/ui/excel-viewer";

export function ReportWithJumps() {
  const viewer = useRef<ExcelViewerHandle>(null);
  const [sheet, setSheet] = useState("Summary");

  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-2">
        <Button size="sm" variant="soft" onClick={() => viewer.current?.goTo("Q3_totals", { behavior: "smooth" })}>
          Q3 totals
        </Button>
        <Button size="sm" variant="soft" onClick={() => viewer.current?.goTo("Team!G2:G11", { behavior: "smooth" })}>
          Team attainment
        </Button>
        <span className="ms-auto text-sm text-muted-foreground">Viewing {sheet}</span>
      </div>
      <ExcelViewer ref={viewer} src="/reports/q3-sales-report.xlsx" sheet={sheet} onSheetChange={setSheet} height={560} />
    </div>
  );
}
```

`goTo` returns `false` when the reference does not exist in the open file. To hide a button before anyone clicks it, check the `sheets` and `definedNames` that `onWorkbookLoadSuccess` reports.

## How do you show a large Excel file in React without freezing the page?

Split the parsing into short tasks and draw only what is in view. Two parts can freeze a page: parsing and drawing. For parsing, `fflate` inflates the large parts of the zip in a worker. A Content Security Policy that blocks `blob:` workers stops that worker from starting, so the viewer catches the failure and inflates on the main thread instead of showing an error. The XML still has to be read on the main thread, because `DOMParser` does not exist in workers. So the viewer skips `DOMParser` for the cell data, which is most of a big sheet, and scans the rows and shared strings in slices of about 12 ms, handing the browser a turn between slices so input and paint keep going. Files above `maxFileSize` (50 MB by default) are not parsed at all; the viewer shows a "file too large" state with a download link.

For drawing, the grid is virtualized in both directions with `@tanstack/react-virtual`: only the rows and columns in view, plus a few on each side, exist in the DOM. Frozen rows and columns are drawn in their own sticky layers above the scrolling body. Sorting from a column menu reorders the view only, and the download button always saves the original file.

Two limits to know before you ship. Formulas are not recalculated; the cells show the values Excel saved. And old binary `.xls` files are refused with a message asking the person to save them as `.xlsx`.

## How do you show a large CSV file in React?

Use a viewer built for CSV, because the problems differ. The Excel Viewer opens a `.csv` as a single sheet with the first row frozen as a header, which is fine for small exports. For large ones, a react csv viewer has different problems: there is no style information, the delimiter may be a semicolon, the encoding may be Windows-1252, and the file can have hundreds of thousands of rows.

The [CSV Viewer](https://wingo-ui.com/components/csv-viewer) handles that case. It reads the file in slices so the first rows appear while the rest loads, detects the delimiter, encoding and header row, recognizes number, date and boolean columns, and adds filters and frozen columns. Defaults are a 100 MB file limit and 1,000,000 rows.

```tsx
import { CsvViewer } from "@/components/ui/csv-viewer";

export function OrdersPreview() {
  return <CsvViewer src="/exports/orders.csv" height="80dvh" defaultFrozenColumns={1} />;
}
```

> Live demo (CSV Viewer): Open a column header's panel to filter the 5,000 sample orders by a value, then select a range of numbers and read the sum in the status bar. Try it at [CSV Viewer](https://wingo-ui.com/components/csv-viewer) and install it with `npx wingo-ui@latest add csv-viewer`.

## How do you read Excel data in React for an import?

Use a parser that returns rows, not a viewer. A viewer is for people. When your code needs the data, for an import or a validation step, use the [Spreadsheet helpers](https://wingo-ui.com/components/spreadsheet): `readSpreadsheet` turns a picked CSV or `.xlsx` into sheets with headers and rows, and `matchColumns` pairs those headers with your fields. The full upload, map and fix flow is the [CSV Import](https://wingo-ui.com/components/csv-import) block. For the upload field itself, the [React file upload guide](https://wingo-ui.com/blog/react-file-upload) covers drag and drop, paste and validation.

## Is the viewer accessible and usable on a phone?

Yes for keyboard, screen reader basics and touch, with one limit worth planning for. The keyboard works like Excel's: arrows, Tab, Enter, Home and End, Page Up and Down, Ctrl or Cmd plus arrows to jump to the edge of the data, Shift to extend, Ctrl or Cmd plus C to copy as tab-separated text. The scrolling area is a focusable region labeled with the file name, and a polite live region announces the active cell's address and shown text as the selection moves. The sheet tabs are a real tablist.

The limit: cells off screen are not in the DOM, so a screen reader user moves cell by cell and cannot browse the whole sheet as a table. If the data must be fully navigable that way, also offer the download or show the rows in a [Data Table](https://wingo-ui.com/components/data-table).

On phones the grid stays a grid. A spreadsheet's layout carries meaning, so we do not turn rows into cards here the way we do for app data in the [responsive table guide](https://wingo-ui.com/blog/responsive-table-mobile-cards). Tap selects a cell and shows it in the formula bar, two fingers pinch to zoom, and when the viewer itself is narrower than 720px, the toolbar switches fold into the ⋯ menu, which opens as a bottom sheet on phones.

## A complete example: preview a workbook before importing it

This screen lets someone open or drop a workbook, read it with its formatting, then import the sheet they are looking at. The file never leaves the browser until your import code sends it.

```tsx
"use client";

import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ExcelViewer, type ExcelViewerHandle } from "@/components/ui/excel-viewer";
import { readSpreadsheet, SpreadsheetError, type SpreadsheetErrorCode } from "@/lib/spreadsheet";

const IMPORT_ERRORS: Record<SpreadsheetErrorCode, string> = {
  unsupported: "Save the file as .xlsx or .csv and try again.",
  "too-large": "This file is over the 20 MB import limit.",
  empty: "This file has no rows to import.",
  parse: "This file can't be imported.",
};

export function WorkbookImport() {
  const viewer = useRef<ExcelViewerHandle>(null);
  const [file, setFile] = useState<File | null>(null);
  const [status, setStatus] = useState("Open an .xlsx or .csv, or drop it on the viewer.");

  async function importRows() {
    if (!file) return;
    try {
      const { sheets } = await readSpreadsheet(file);
      const active = viewer.current?.getSelection()?.sheet;
      const target = sheets.find((sheet) => sheet.name === active) ?? sheets[0];
      const skipped = target.truncated ? `, ${target.truncated.toLocaleString("en-US")} over the row limit` : "";
      setStatus(`Ready to import ${target.rows.length.toLocaleString("en-US")} rows from ${target.name}${skipped}.`);
    } catch (error) {
      if (!(error instanceof SpreadsheetError)) throw error;
      setStatus(IMPORT_ERRORS[error.code]);
    }
  }

  return (
    <div className="flex flex-col gap-3">
      <ExcelViewer
        ref={viewer}
        height="70dvh"
        onWorkbookUpload={setFile}
        onWorkbookLoadSuccess={(workbook) => setStatus(`${workbook.fileName} is open.`)}
        onWorkbookLoadError={() => {
          setFile(null);
          setStatus("That workbook could not be opened.");
        }}
      />
      <div className="flex items-center justify-between gap-3">
        <p className="text-sm text-muted-foreground" aria-live="polite">
          {status}
        </p>
        <Button tone="brand" disabled={!file} onClick={importRows}>
          Import rows
        </Button>
      </div>
    </div>
  );
}
```

Two details in there come from real files. `readSpreadsheet` takes the first row with any text as the header, so a report sheet with a title block above its table gets the title as its headers. The sample's Summary sheet starts its table on row 5: pass `header: 4` (0-based) for that layout, or point people at a plain data sheet like Transactions. The helper also has its own limits, 20 MB per file (`maxBytes`) against the viewer's 50 MB and 50,000 rows per sheet (`maxRows`), and reports the rows it cut in each sheet's `truncated`. Check it before you tell someone the import is complete.

## Next step

Install the viewer with `npx wingo-ui@latest add excel-viewer`; it brings the spreadsheet helpers and its other dependencies along, and `csv-viewer` installs the same way. All three are part of Wingo UI Pro ([pricing](https://wingo-ui.com/pricing)). The [Excel Viewer docs](https://wingo-ui.com/components/excel-viewer) have the playground with the sample report and every prop. For more component walkthroughs, see the [component guides category](https://wingo-ui.com/blog/category/components) and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide).

## Components in this post

- [Excel Viewer](https://wingo-ui.com/components/excel-viewer) (Pro): A React Excel viewer for .xlsx and CSV files with real cell styles, number formats, frozen panes, sheet tabs, a formula bar, sort and search. Install: `npx wingo-ui@latest add excel-viewer`
- [CSV Viewer](https://wingo-ui.com/components/csv-viewer) (Pro): A React CSV viewer for large CSV and TSV files: typed columns, sort, filters, search, selection with copy, frozen columns and zoom. Install: `npx wingo-ui@latest add csv-viewer`
- [Spreadsheet](https://wingo-ui.com/components/spreadsheet) (Pro): Read CSV and Excel files in the browser, match their columns to your fields, and write CSV that Excel opens with diacritics intact. Install: `npx wingo-ui@latest add spreadsheet`

## FAQ

### How do I display an Excel file in React?

Fetch the .xlsx as bytes, unzip it, read the sheet, style and shared string XML, and render the cells with a virtualized grid. A component like the Wingo UI Excel Viewer does all of that from one src prop: a same-origin URL, a CORS-enabled URL or a blob: URL.

### Can a React Excel viewer open old .xls files?

The Excel Viewer opens .xlsx, .xlsm, .csv and .tsv. An old .xls is a different binary format, so the viewer shows a message asking to save it as .xlsx in Excel and calls onWorkbookLoadError.

### Does the viewer recalculate formulas?

No. It shows the values Excel saved in the file and the formula of the active cell in the formula bar. If a file was written by a tool that never calculated it, those cells show what the file stores.

### Is the Excel file uploaded to a server?

No. A file someone opens or drops onto the viewer is read in the browser, and onWorkbookUpload hands you the File object if you want to store it. A src URL is fetched by the browser like any other request.

### How do I show a large CSV file in React?

Use a viewer that reads the file in slices and virtualizes the rows. The Wingo UI CSV Viewer detects the delimiter, encoding and header row, accepts files up to 100 MB by default and keeps up to 1,000,000 rows.

---

Source: https://wingo-ui.com/blog/react-excel-viewer
