CSV to HTML Table Converter

A CSV to HTML converter turns a spreadsheet into markup you can paste into a page. This one writes a real semantic table, with a thead and tbody, scoped header cells and an optional caption, so a screen reader can read it and a printer repeats the header. Every cell is escaped, without an option to turn that off. Nothing is uploaded.

Need to sort, filter or drop columns first? Open the app

Semantic is not a style choice

The minimum HTML that renders as a table is a <table> full of <tr> and <td>. It looks right in a browser. It is also, to anything that is not a pair of eyes, a grid of undifferentiated text.

A <thead> with <th scope="col"> cells changes three concrete things. A screen reader announces "Price, 4.25" instead of just "4.25", because it knows which cell heads that column. A browser printing the table repeats the header on every page automatically, with no CSS. And a sticky header, a sortable column or a scrollable body all need the sections to exist before any stylesheet or script can target them.

That is two extra tags and one attribute, and none of it is visible. It is also the difference between a table somebody can use and one they can only look at, which is why it is on by default here rather than an option.

The <caption> is the other piece. A heading in an <h3> above the table is not attached to it: a screen reader listing the tables on a page announces yours as unlabelled. A caption is the table's accessible name, travels with it, and survives any restyling.

A worked example

Three rows, including a supplier name with an ampersand in it:

sku,name,price,supplier
00412,Bracket 40mm,4.25,Nordwerk GmbH
01730,Bronze bushing,2.15,Fenwick & Sons
04510,Spur gear,31.5,Ashby Supplies

With a caption and striped rows, the fragment is:

<table class="striped">
  <caption>Component prices</caption>
  <thead>
    <tr>
      <th scope="col">sku</th>
      <th scope="col">name</th>
      <th scope="col" class="num">price</th>
      <th scope="col">supplier</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>00412</td>
      <td>Bracket 40mm</td>
      <td class="num">4.25</td>
      <td>Nordwerk GmbH</td>
    </tr>
    ...
    <tr>
      <td>01730</td>
      <td>Bronze bushing</td>
      <td class="num">2.15</td>
      <td>Fenwick &amp; Sons</td>
    </tr>
  </tbody>
</table>

The ampersand became &amp;. The price column carries a num class because it typed as numeric, and the SKU column does not because a leading zero makes it text. And the SKU still reads 00412, which it would not if the value had gone through anything that treats a digit string as a number.

Escaping has no switch

Every cell is escaped, every time. There is no option to pass markup through, and that is deliberate rather than an omission.

A converter with a "my data contains HTML, do not escape it" switch is a cross-site scripting vector for whoever pastes the output into a page. The person converting the file is rarely the person deploying it, the data often came from somewhere neither of them controls, and one <img onerror> in a supplier name is all it takes. The safe default has to be the only default.

What that means in practice: &, <, > and " become entities in every cell and in the caption. A cell containing <b>bold</b> shows those characters as text, because that is what was in your spreadsheet.

The one thing that is preserved rather than flattened is a line break inside a cell, which becomes a <br>. HTML collapses whitespace, so a raw newline would silently join two lines into one; a break keeps the shape the cell had. It is the only markup this converter ever emits from cell content, and it cannot carry an attribute or a script.

A whole page, and sorting without a library

The standalone option wraps the table in a real document: a doctype, a UTF-8 charset, a viewport meta so it is readable on a phone, and a small stylesheet. The styles give you collapsed borders, a shaded and sticky header, right-aligned numeric columns with tabular figures, optional zebra striping, and a dark-mode variant behind a prefers-color-scheme query. It opens in a browser as it is, which is the quickest way to hand a colleague a table without attaching a spreadsheet they will open in the wrong locale.

Turning on sorting adds about fourteen lines of vanilla JavaScript to that page. Click a header and the rows reorder, numerically when both values parse as numbers and alphabetically otherwise, toggling direction on a second click. There is no library, no CDN link, no build step and nothing to keep patched. It is not a data grid, and it is not trying to be; for filtering, grouping and charting there is the app.

Asking for sorting on a fragment gives you the class and a warning that it is inert, because the script only ships with the full document. Silently adding a class that does nothing is the kind of small lie that wastes twenty minutes later.

Where an HTML table stops being the answer

Two limits are worth knowing before you paste.

Width. Past about fifteen columns a table needs horizontal scrolling on a phone, and a table that scrolls sideways inside a page that also scrolls sideways is unusable. Wrap it in a container with overflow-x: auto so only the table moves, and consider whether every column earns its place.

Height. Ten thousand <tr> elements is several megabytes of DOM. The browser will render it, slowly, and scrolling will stutter on anything but a fast machine. Past a few thousand rows you want pagination, virtual scrolling, or a link to the CSV rather than the CSV inlined into the page.

If the goal is a printed or shared document rather than a web page, CSV to PDF goes there directly with page size and orientation handled.

Questions

Why does the table have thead and tbody rather than just rows?

Three reasons, all practical. A screen reader announces the column heading before each cell only when it can tell which row is the header. A printed table repeats the thead on every page automatically. And a sticky header, a sortable column or a scrollable body all need the two sections to exist before any CSS or script can target them. It costs two tags and it is the difference between a table and a grid of text.

Is the content escaped?

Always, and it is not configurable. A cell holding a script tag, an ampersand, or a stray quote comes out as visible text every time. There is no option to trust your data, because a converter that will inject markup on request is a cross-site scripting vector for whoever pastes the output into a page. The one thing that is preserved rather than flattened is an embedded newline, which becomes a br so the cell keeps its shape.

What is the caption for?

It is the table's accessible name, and it is the correct place to put a title. A heading above the table in a separate element is not associated with it, so a screen reader listing the page's tables announces yours as unlabelled. A caption is announced with the table and stays attached to it however the page is restyled. It also becomes the title element when you generate a full page.

Can I get a whole page rather than a fragment?

Yes. The standalone option wraps the table in a document with a doctype, a charset, a viewport meta and a stylesheet that gives you borders, a sticky header, right-aligned numeric columns and a dark-mode variant. It opens in a browser as it stands, which is the quickest way to hand someone a table without attaching a spreadsheet.

How does the sortable option work?

It adds a class and, on a standalone page, about fourteen lines of vanilla JavaScript that sorts on a header click, numerically when both cells parse as numbers and alphabetically otherwise. No library, no build step, no dependency to keep current. On a fragment the class is inert and you are told so, since the script only ships with the full document.

Why are only some columns right aligned?

Columns typed as numbers get a class that right-aligns them and uses tabular figures, so the digits line up and a column can be read down. Everything else stays left. Types are decided once per column by reading the whole column, so a column mixing 9.99 and 12.50 stays text and left aligned rather than half one and half the other. The header row is always left aligned, because a heading is a label rather than a value.

Is anything uploaded?

No. The file is read and converted in your browser tab. Nothing is sent to a server, nothing is stored between visits, and there is no row cap. A very wide table is worth a thought before you paste it into a page, though: past about fifteen columns it will need horizontal scrolling on a phone.

Convert your CSV to an HTML table

No sign-up, no upload, no row cap. Semantic markup, everything escaped, a whole styled page if you want one.

Back to the converter