diff --git a/.changeset/ten-pandas-swim.md b/.changeset/ten-pandas-swim.md
new file mode 100644
index 00000000..6e8c9407
--- /dev/null
+++ b/.changeset/ten-pandas-swim.md
@@ -0,0 +1,7 @@
+---
+"streamdown": minor
+---
+
+- Add `controls.table.csvSeparator` (`"," | ";" | "\t" | "auto"`) for table copy and download CSV
+- Reuse `tableDataToCSV` separator handling, including locale-aware `"auto"` mode
+- Improve CSV escaping to respect the selected separator for Excel compatibility
diff --git a/apps/website/content/docs/components.mdx b/apps/website/content/docs/components.mdx
index 50ffee35..f92b05a8 100644
--- a/apps/website/content/docs/components.mdx
+++ b/apps/website/content/docs/components.mdx
@@ -224,6 +224,14 @@ import { TableDownloadButton } from "streamdown";
```
+You can control the CSV delimiter for table copy and download with `controls.table.csvSeparator`. Supported values are `","`, `";"`, `"\t"`, and `"auto"` (locale-aware selection). The default is `","`.
+
+```tsx title="app/page.tsx"
+
+ {markdown}
+
+```
+
### Lower-level utilities
For fully custom implementations, use the extraction and conversion utilities directly:
@@ -231,6 +239,7 @@ For fully custom implementations, use the extraction and conversion utilities di
```tsx title="app/page.tsx"
import {
extractTableDataFromElement,
+ type CSVSeparator,
tableDataToCSV,
tableDataToTSV,
tableDataToMarkdown,
@@ -240,11 +249,13 @@ import {
const data = extractTableDataFromElement(tableElement);
// Convert to various formats
-const csv = tableDataToCSV(data);
+const csv = tableDataToCSV(data, "auto");
const tsv = tableDataToTSV(data);
const markdown = tableDataToMarkdown(data);
```
+The `tableDataToCSV` helper accepts an optional `CSVSeparator` argument (`"," | ";" | "\t" | "auto"`) so you can choose the delimiter explicitly or let `"auto"` pick a locale-friendly separator.
+
## Custom HTML Tags
You can render custom HTML tags from AI responses (like ``, ``, etc.) using the `allowedTags` prop alongside `components`. This is useful when you instruct the AI to output structured data that renders as interactive components.
diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx
index 78d7c2fb..fa45b730 100644
--- a/apps/website/content/docs/configuration.mdx
+++ b/apps/website/content/docs/configuration.mdx
@@ -346,6 +346,7 @@ The `controls` prop can be configured granularly:
copy: true, // Show table copy button
download: true, // Show table download button
fullscreen: true, // Show table fullscreen button
+ csvSeparator: ",", // "," | ";" | "\t" | "auto"
},
code: {
copy: true, // Show code copy button
diff --git a/apps/website/content/docs/gfm.mdx b/apps/website/content/docs/gfm.mdx
index 6d0b1443..b45df23e 100644
--- a/apps/website/content/docs/gfm.mdx
+++ b/apps/website/content/docs/gfm.mdx
@@ -118,6 +118,16 @@ You can disable the table download button:
```
+### Custom CSV Separator
+
+By default, copied and downloaded CSV uses a comma. Set `controls.table.csvSeparator` to `";"`, `"\t"`, or `"auto"` (picks `;` in comma-decimal locales):
+
+```tsx
+
+ {markdown}
+
+```
+
## Task Lists
Create interactive todo lists:
diff --git a/apps/website/content/docs/interactivity.mdx b/apps/website/content/docs/interactivity.mdx
index dc17fe97..88d745ba 100644
--- a/apps/website/content/docs/interactivity.mdx
+++ b/apps/website/content/docs/interactivity.mdx
@@ -41,6 +41,14 @@ Tables include a copy button that opens a dropdown menu allowing users to copy t
Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation.
+CSV copy and download use a comma by default. Customize the delimiter with `controls.table.csvSeparator` (`","`, `";"`, `"\t"`, or `"auto"`):
+
+```tsx
+
+ {markdown}
+
+```
+
## Code Block Buttons
### Copy Code
diff --git a/packages/streamdown/__tests__/table-dropdowns.test.tsx b/packages/streamdown/__tests__/table-dropdowns.test.tsx
index 8bea6d4e..cddb356a 100644
--- a/packages/streamdown/__tests__/table-dropdowns.test.tsx
+++ b/packages/streamdown/__tests__/table-dropdowns.test.tsx
@@ -79,7 +79,7 @@ describe("TableDownloadDropdown", () => {
expect(save).toHaveBeenCalledWith(
"table.csv",
- expect.any(String),
+ "Name,Age\nAlice,30",
"text/csv"
);
expect(onDownload).toHaveBeenCalledWith("csv");
@@ -114,6 +114,57 @@ describe("TableDownloadDropdown", () => {
expect(onDownload).toHaveBeenCalledWith("markdown");
});
+ it("should use csvSeparator from controls for CSV downloads", async () => {
+ const { save } = await import("../lib/utils");
+ const onDownload = vi.fn();
+
+ const { container } = render(
+
+
+
+
+
+ Name
+ Age
+
+
+
+
+ Alice
+ 30
+
+
+
+
+
+
+ );
+
+ const toggleBtn = container.querySelector('button[title="Download table"]');
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(toggleBtn!);
+
+ const csvBtn = container.querySelector(
+ 'button[title="Download table as CSV"]'
+ );
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(csvBtn!);
+
+ expect(save).toHaveBeenCalledWith(
+ "table.csv",
+ "Name;Age\nAlice;30",
+ "text/csv"
+ );
+ expect(onDownload).toHaveBeenCalledWith("csv");
+ });
+
it("should download when inside table-fullscreen", async () => {
const { save } = await import("../lib/utils");
const onDownload = vi.fn();
@@ -263,6 +314,53 @@ describe("TableDownloadButton with format='markdown'", () => {
expect(onDownload).toHaveBeenCalled();
});
+ it("should use csvSeparator from controls for CSV button downloads", async () => {
+ const { save } = await import("../lib/utils");
+ const onDownload = vi.fn();
+
+ const { container } = render(
+
+
+
+
+
+ Name
+ Age
+
+
+
+
+ Alice
+ 30
+
+
+
+
+
+
+ );
+
+ const btn = container.querySelector(
+ 'button[title="Download table as CSV"]'
+ );
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(btn!);
+
+ expect(save).toHaveBeenCalledWith(
+ "table.csv",
+ "Name;Age\nAlice;30",
+ "text/csv"
+ );
+ expect(onDownload).toHaveBeenCalled();
+ });
+
it("should handle default format (fallback to csv)", () => {
const { container } = renderInTableWrapper(
@@ -406,6 +504,20 @@ describe("TableCopyDropdown", () => {
it("should copy as CSV when csv button clicked", async () => {
const onCopy = vi.fn();
+ const OriginalBlob = globalThis.Blob;
+ const blobPartsByType: Record = {};
+ const blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function (
+ this: Blob,
+ parts?: BlobPart[],
+ opts?: BlobPropertyBag
+ ) {
+ const type = opts?.type ?? "";
+ if (parts) {
+ blobPartsByType[type] = parts;
+ }
+ return new OriginalBlob(parts, opts);
+ } as unknown as typeof Blob);
+
const { container } = renderInTableWrapper(
);
@@ -421,7 +533,76 @@ describe("TableCopyDropdown", () => {
fireEvent.click(csvBtn!);
});
+ expect(navigator.clipboard.write).toHaveBeenCalled();
+ expect(String(blobPartsByType["text/plain"]?.[0] ?? "")).toBe(
+ "Name,Age\nAlice,30"
+ );
+ expect(onCopy).toHaveBeenCalledWith("csv");
+ blobSpy.mockRestore();
+ });
+
+ it("should copy CSV when csvSeparator is configured", async () => {
+ const onCopy = vi.fn();
+ const OriginalBlob = globalThis.Blob;
+ const blobPartsByType: Record = {};
+ const blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function (
+ this: Blob,
+ parts?: BlobPart[],
+ opts?: BlobPropertyBag
+ ) {
+ const type = opts?.type ?? "";
+ if (parts) {
+ blobPartsByType[type] = parts;
+ }
+ return new OriginalBlob(parts, opts);
+ } as unknown as typeof Blob);
+
+ const { container } = render(
+
+
+
+
+
+ Name
+ Age
+
+
+
+
+ Alice
+ 30
+
+
+
+
+
+
+ );
+
+ const toggleBtn = container.querySelector('button[title="Copy table"]');
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(toggleBtn!);
+
+ const csvBtn = container.querySelector('button[title="Copy table as CSV"]');
+ // biome-ignore lint/suspicious/useAwait: act needs async to flush clipboard promises
+ await act(async () => {
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(csvBtn!);
+ });
+
+ expect(navigator.clipboard.write).toHaveBeenCalled();
+ expect(String(blobPartsByType["text/plain"]?.[0] ?? "")).toBe(
+ "Name;Age\nAlice;30"
+ );
expect(onCopy).toHaveBeenCalledWith("csv");
+ blobSpy.mockRestore();
});
it("should copy as TSV when tsv button clicked", async () => {
diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts
index 5ad2409f..b0c1e454 100644
--- a/packages/streamdown/__tests__/table-utils.test.ts
+++ b/packages/streamdown/__tests__/table-utils.test.ts
@@ -1,7 +1,8 @@
-import { beforeEach, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import {
escapeMarkdownTableCell,
extractTableDataFromElement,
+ getTableCsvSeparator,
type TableData,
tableDataToCSV,
tableDataToMarkdown,
@@ -205,6 +206,59 @@ describe("Table Utils", () => {
expect(result).toBe('Name,Location\nJohn,"New York, USA"');
});
+ it("should escape quotes and separator together", () => {
+ const data: TableData = {
+ headers: ["Message"],
+ rows: [['Hello, "World"']],
+ };
+
+ const result = tableDataToCSV(data);
+
+ expect(result).toBe('Message\n"Hello, ""World"""');
+ });
+
+ it("should support semicolon separators", () => {
+ const data: TableData = {
+ headers: ["Name", "Location"],
+ rows: [["Aradhya", "New York; USA"]],
+ };
+
+ const result = tableDataToCSV(data, ";");
+
+ expect(result).toBe('Name;Location\nAradhya;"New York; USA"');
+ });
+
+ it("should use semicolon separator in auto mode for comma-decimal locales", () => {
+ const numberFormatSpy = vi.spyOn(Intl, "NumberFormat").mockImplementation(
+ () =>
+ ({
+ format: () => "1,1",
+ }) as Intl.NumberFormat
+ );
+
+ const data: TableData = {
+ headers: ["Name", "City"],
+ rows: [["John", "Paris; France"]],
+ };
+
+ const result = tableDataToCSV(data, "auto");
+
+ expect(result).toBe('Name;City\nJohn;"Paris; France"');
+
+ numberFormatSpy.mockRestore();
+ });
+
+ it("should escape carriage returns", () => {
+ const data: TableData = {
+ headers: ["Text"],
+ rows: [["line1\rline2"]],
+ };
+
+ const result = tableDataToCSV(data);
+
+ expect(result).toBe('Text\n"line1\rline2"');
+ });
+
it("should escape quotes in values", () => {
const data: TableData = {
headers: ["Quote"],
@@ -227,6 +281,28 @@ describe("Table Utils", () => {
expect(result).toBe('Text\n"Line 1\nLine 2"');
});
+ it("should support tab separators", () => {
+ const data: TableData = {
+ headers: ["Name", "Note"],
+ rows: [["Mike", "A\tB"]],
+ };
+
+ const result = tableDataToCSV(data, "\t");
+
+ expect(result).toBe('Name\tNote\nMike\t"A\tB"');
+ });
+
+ it("should not use separators as its single column", () => {
+ const data: TableData = {
+ headers: ["Name"],
+ rows: [["John"]],
+ };
+
+ const result = tableDataToCSV(data, ";");
+
+ expect(result).toBe("Name\nJohn");
+ });
+
it("should handle empty headers", () => {
const data: TableData = {
headers: [],
@@ -248,6 +324,55 @@ describe("Table Utils", () => {
expect(result).toBe("Header1,Header2");
});
+
+ it("should handle empty values", () => {
+ const data: TableData = {
+ headers: ["Name", "Age"],
+ rows: [["", ""]],
+ };
+
+ const result = tableDataToCSV(data);
+
+ expect(result).toBe("Name,Age\n,");
+ });
+
+ it("should handle empty tables", () => {
+ const data: TableData = {
+ headers: [],
+ rows: [],
+ };
+
+ const result = tableDataToCSV(data);
+
+ expect(result).toBe("");
+ });
+ });
+
+ describe("getTableCsvSeparator", () => {
+ it("returns comma when controls is a boolean", () => {
+ expect(getTableCsvSeparator(true)).toBe(",");
+ expect(getTableCsvSeparator(false)).toBe(",");
+ });
+
+ it("returns comma when table is missing or a boolean", () => {
+ expect(getTableCsvSeparator({})).toBe(",");
+ expect(getTableCsvSeparator({ table: true })).toBe(",");
+ expect(getTableCsvSeparator({ table: false })).toBe(",");
+ });
+
+ it("returns comma when csvSeparator is not set", () => {
+ expect(getTableCsvSeparator({ table: {} })).toBe(",");
+ });
+
+ it("returns the configured separator", () => {
+ expect(getTableCsvSeparator({ table: { csvSeparator: ";" } })).toBe(";");
+ expect(getTableCsvSeparator({ table: { csvSeparator: "\t" } })).toBe(
+ "\t"
+ );
+ expect(getTableCsvSeparator({ table: { csvSeparator: "auto" } })).toBe(
+ "auto"
+ );
+ });
});
describe("tableDataToTSV", () => {
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index cc3983b2..9ce7df88 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -44,6 +44,7 @@ import { preprocessLiteralTagContent } from "./lib/preprocess-literal-tag-conten
import { rehypeBlockDirection } from "./lib/rehype/block-direction";
import { rehypeLiteralTagContent } from "./lib/rehype/literal-tag-content";
import { remarkCodeMeta } from "./lib/remark/code-meta";
+import type { CSVSeparator } from "./lib/table/utils";
import {
defaultTranslations,
type StreamdownTranslations,
@@ -97,6 +98,7 @@ export {
type TableDownloadDropdownProps,
} from "./lib/table/download-dropdown";
export {
+ type CSVSeparator,
escapeMarkdownTableCell,
extractTableDataFromElement,
type TableData,
@@ -142,6 +144,7 @@ export type ControlsConfig =
| boolean
| {
copy?: boolean;
+ csvSeparator?: CSVSeparator;
download?: boolean;
fullscreen?: boolean;
};
diff --git a/packages/streamdown/lib/table/copy-dropdown.tsx b/packages/streamdown/lib/table/copy-dropdown.tsx
index f019a9fd..5b795bca 100644
--- a/packages/streamdown/lib/table/copy-dropdown.tsx
+++ b/packages/streamdown/lib/table/copy-dropdown.tsx
@@ -5,6 +5,7 @@ import { useCn } from "../prefix-context";
import { useTranslations } from "../translations-context";
import {
extractTableDataFromElement,
+ getTableCsvSeparator,
tableDataToCSV,
tableDataToMarkdown,
tableDataToTSV,
@@ -30,8 +31,9 @@ export const TableCopyDropdown = ({
const [isCopied, setIsCopied] = useState(false);
const dropdownRef = useRef(null);
const timeoutRef = useRef(0);
- const { isAnimating } = useContext(StreamdownContext);
+ const { isAnimating, controls } = useContext(StreamdownContext);
const t = useTranslations();
+ const csvSeparator = getTableCsvSeparator(controls);
const copyTableData = async (format: "csv" | "tsv" | "md") => {
if (typeof window === "undefined" || !navigator?.clipboard?.write) {
@@ -53,14 +55,15 @@ export const TableCopyDropdown = ({
}
const tableData = extractTableDataFromElement(tableElement);
+ let content = "";
- const formatters = {
- csv: tableDataToCSV,
- tsv: tableDataToTSV,
- md: tableDataToMarkdown,
- };
- const formatter = formatters[format] || tableDataToMarkdown;
- const content = formatter(tableData);
+ if (format === "csv") {
+ content = tableDataToCSV(tableData, csvSeparator);
+ } else if (format === "tsv") {
+ content = tableDataToTSV(tableData);
+ } else {
+ content = tableDataToMarkdown(tableData);
+ }
const clipboardItemData = new ClipboardItem({
"text/plain": new Blob([content], { type: "text/plain" }),
diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx
index ead50cf8..39e18ccd 100644
--- a/packages/streamdown/lib/table/download-dropdown.tsx
+++ b/packages/streamdown/lib/table/download-dropdown.tsx
@@ -6,6 +6,7 @@ import { useTranslations } from "../translations-context";
import { save } from "../utils";
import {
extractTableDataFromElement,
+ getTableCsvSeparator,
tableDataToCSV,
tableDataToMarkdown,
} from "./utils";
@@ -28,10 +29,10 @@ export const TableDownloadButton = ({
filename,
}: TableDownloadButtonProps) => {
const cn = useCn();
- const { isAnimating } = useContext(StreamdownContext);
+ const { isAnimating, controls } = useContext(StreamdownContext);
const t = useTranslations();
const icons = useIcons();
-
+ const csvSeparator = getTableCsvSeparator(controls);
const downloadTableData = (event: React.MouseEvent) => {
try {
// Find the closest table element
@@ -53,7 +54,7 @@ export const TableDownloadButton = ({
switch (format) {
case "csv":
- content = tableDataToCSV(tableData);
+ content = tableDataToCSV(tableData, csvSeparator);
mimeType = "text/csv";
extension = "csv";
break;
@@ -63,7 +64,7 @@ export const TableDownloadButton = ({
extension = "md";
break;
default:
- content = tableDataToCSV(tableData);
+ content = tableDataToCSV(tableData, csvSeparator);
mimeType = "text/csv";
extension = "csv";
}
@@ -110,9 +111,10 @@ export const TableDownloadDropdown = ({
const cn = useCn();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
- const { isAnimating } = useContext(StreamdownContext);
+ const { isAnimating, controls } = useContext(StreamdownContext);
const t = useTranslations();
const icons = useIcons();
+ const csvSeparator = getTableCsvSeparator(controls);
const downloadTableData = (format: "csv" | "markdown") => {
try {
@@ -131,7 +133,7 @@ export const TableDownloadDropdown = ({
const tableData = extractTableDataFromElement(tableElement);
const content =
format === "csv"
- ? tableDataToCSV(tableData)
+ ? tableDataToCSV(tableData, csvSeparator)
: tableDataToMarkdown(tableData);
const extension = format === "csv" ? "csv" : "md";
const filename = `table.${extension}`;
diff --git a/packages/streamdown/lib/table/utils.ts b/packages/streamdown/lib/table/utils.ts
index 81fc6d25..aece0df5 100644
--- a/packages/streamdown/lib/table/utils.ts
+++ b/packages/streamdown/lib/table/utils.ts
@@ -47,35 +47,62 @@ export const extractTableDataFromElement = (
return { headers, rows };
};
-export const tableDataToCSV = (data: TableData): string => {
+export type CSVSeparator = "," | ";" | "\t" | "auto";
+
+export const getTableCsvSeparator = (
+ config: boolean | { table?: boolean | { csvSeparator?: CSVSeparator } }
+): CSVSeparator => {
+ if (typeof config !== "object") {
+ return ",";
+ }
+
+ const tableConfig = config.table;
+ if (typeof tableConfig !== "object") {
+ return ",";
+ }
+
+ return tableConfig.csvSeparator ?? ",";
+};
+
+export const tableDataToCSV = (
+ data: TableData,
+ separator: CSVSeparator = ","
+): string => {
+ let resolvedSeparator: string;
+
+ if (separator === "auto") {
+ const formatter = Intl.NumberFormat().format(1.1);
+
+ if (formatter.includes(",")) {
+ resolvedSeparator = ";";
+ } else {
+ resolvedSeparator = ",";
+ }
+ } else {
+ resolvedSeparator = separator;
+ }
const { headers, rows } = data;
const escapeCSV = (value: string): string => {
- // OPTIMIZATION: Fast path for values that don't need escaping
- // Check characters directly to avoid multiple string scans
let needsEscaping = false;
- let hasQuote = false;
for (const char of value) {
- if (char === '"') {
+ if (
+ char === resolvedSeparator ||
+ char === '"' ||
+ char === "\n" ||
+ char === "\r"
+ ) {
needsEscaping = true;
- hasQuote = true;
break;
}
- if (char === "," || char === "\n") {
- needsEscaping = true;
- }
}
if (!needsEscaping) {
return value;
}
-
- // If the value contains comma, quote, or newline, wrap in quotes and escape internal quotes
- if (hasQuote) {
- return `"${value.replace(/"/g, '""')}"`;
- }
- return `"${value}"`;
+ // Escape internal quotes by doubling them
+ return `"${value.replace(/"/g, '""')}"`;
};
// Pre-allocate array with known size
@@ -85,13 +112,13 @@ export const tableDataToCSV = (data: TableData): string => {
// Add headers
if (headers.length > 0) {
- csvRows[rowIndex] = headers.map(escapeCSV).join(",");
+ csvRows[rowIndex] = headers.map(escapeCSV).join(resolvedSeparator);
rowIndex += 1;
}
// Add data rows
for (const row of rows) {
- csvRows[rowIndex] = row.map(escapeCSV).join(",");
+ csvRows[rowIndex] = row.map(escapeCSV).join(resolvedSeparator);
rowIndex += 1;
}
diff --git a/skills/streamdown/references/api.md b/skills/streamdown/references/api.md
index bd897036..99e2fbe1 100644
--- a/skills/streamdown/references/api.md
+++ b/skills/streamdown/references/api.md
@@ -147,9 +147,19 @@ interface RemendOptions {
## ControlsConfig
```tsx
+type CSVSeparator = "," | ";" | "\t" | "auto";
+
type ControlsConfig = boolean | {
- table?: boolean;
- code?: boolean;
+ table?: boolean | {
+ copy?: boolean;
+ download?: boolean;
+ fullscreen?: boolean;
+ csvSeparator?: CSVSeparator; // default: ","
+ };
+ code?: boolean | {
+ copy?: boolean;
+ download?: boolean;
+ };
mermaid?: boolean | {
download?: boolean;
copy?: boolean;
diff --git a/skills/streamdown/references/features.md b/skills/streamdown/references/features.md
index 99ee7b47..85f8576a 100644
--- a/skills/streamdown/references/features.md
+++ b/skills/streamdown/references/features.md
@@ -118,7 +118,11 @@ Auto-added buttons for images, tables, code, and Mermaid.
```tsx