Grid Clipboard

Clipboard

Cmd+C copy with TSV and HTML clipboard flavors, per-column formatting, grid-level onCopy override.

Cmd/Ctrl+C copies the current selection in two flavors: text/plain (TSV) and text/html (a real <table>). Both are written in a single clipboard entry, and the receiving application picks. Plain-text targets — a terminal, a code editor — take the TSV; Excel and Google Sheets both prefer the HTML. The synthetic row-select column is filtered out of both, so a copy from a checkbox-enabled grid produces the same output as the same selection from an unchecked grid.

onCopy reusing serializeRanges — the pattern the rest of this page builds up to — as a full, typechecked grid rather than an isolated fence:

CSV onCopy override

Code-only: onCopy reuses serializeRanges for range/column/header handling, rewrites the TSV delimiter to a comma, and returns only text to opt out of the HTML flavor.

.md
"use client";

import { PretableSurface, serializeRanges } from "@pretable/react";

import { columns } from "./columns";
import { orders, type Order } from "./data";

// Code-only on purpose — see docs/grid/clipboard's intro. The interesting
// artifact of a copy lands on the OS clipboard, not the DOM, so a live demo
// would either fake navigator.clipboard.read() or prove nothing. This file
// is real and typechecked; it just isn't rendered anywhere.
export function CsvClipboardGrid() {
  return (
    <PretableSurface<Order>
      ariaLabel="Orders"
      columns={columns}
      getRowId={(row) => row.id}
      rows={orders}
      viewportHeight={320}
      onCopy={(args) => {
        // serializeRanges keeps the built-in range/column/header handling —
        // including filtering out the synthetic row-select column — so a
        // custom onCopy only has to post-process its output, not
        // reimplement it.
        const tsv = serializeRanges(args);
        if (!tsv) return null; // empty selection — cancel the copy

        // Returning only `text` also opts out of the HTML flavor (see
        // "Opting out" on docs/grid/clipboard): Excel and Sheets would
        // otherwise prefer text/html over this CSV rewrite.
        return { text: tsv.text.replace(/\t/g, ",") };
      }}
    />
  );
}

That grid's onCopy keeps the built-in range/column/header handling (including filtering out the synthetic row-select column) and rewrites the TSV's tabs to commas; returning only { text } also opts out of the HTML flavor, the same trade Opting out below makes.

Default TSV format

The default serializer emits tab-separated cells, newline-separated rows, with a blank line between blocks for multi-range selections. A column format callback wins first; otherwise native dateFormat handles canonical dates, then numberFormat handles compatible number and bigint values with the surface locale. With none of those, clipboard keeps its channel-specific default coercion:

  • null / undefined""
  • application-owned Date object → value.toISOString() (generic fallback, not the built-in calendar-date contract)
  • string / number / boolean / bigintString(value)
  • plain object → JSON.stringify(value) (best-effort fallback)

Group rows

A range that spans group rows serializes them too. The group's label goes in the leftmost column of the copied range — of the range, not of the grid, so a range starting at the third column puts it there — with each aggregate column's formatAggregate output or inherited native dateFormat / numberFormat in its own column, and an empty cell everywhere else:

Technology<TAB><TAB><TAB>1240000

This is the shape Excel's Subtotal and Google Sheets' pivot tables produce, so a pasted block reads as native rather than as a grid with an extra column. It carries one accepted cost: when that leftmost column is numeric, a text label lands in it. It is a header row, spreadsheets tolerate it, and it is what the incumbents do.

The derived group column is never serialized — not here, not in CSV, and it is never a paste target. The clipboard is a spreadsheet interchange format: Excel and Sheets hand over exactly as many values as you have real columns, so a synthetic column holding a slot would put the first pasted value somewhere unwritable and shift every other value one column right. Copy and paste span the same column space in opposite directions, which is what makes a round trip land where it started.

Escaping

Cell text and header text are escaped with the RFC 4180 quoting convention, using TAB as the delimiter — the same rule Excel and Google Sheets emit and accept on their plain-text clipboard flavor:

  • A field is quoted iff it contains a tab, CR, LF, or a double quote.
  • Quoting wraps the field in " and doubles every embedded "he said "no" becomes "he said ""no""".
  • Every other field is emitted bare, so ordinary values carry no quotes at all.

This matters for wrapped/multi-line cells: a cell holding a newline would otherwise be indistinguishable from a row break, and the paste would split into extra rows. With the rule above, it pastes back into a single cell.

This matches what Excel and Sheets accept on paste, so a copy out of a Pretable grid pastes cleanly into a spreadsheet even when only the plain-text flavor survives — though in practice a spreadsheet takes the HTML flavor instead.

Escaping is applied after your per-column format or native dateFormat / numberFormat runs, so output containing a tab or newline is still safe — don't pre-quote in format, or the quotes get escaped as literal content. If you write a fully custom serializer, you own the escaping.

HTML flavor

Alongside the TSV, every copy writes a text/html flavor: one <table> per selected range, concatenated behind a single <meta charset="utf-8">.

html
<meta charset="utf-8"><table style="white-space:pre-wrap"><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>a1</td><td>b1</td></tr></tbody></table>

Excel and Sheets both prefer text/html when both flavors are present, so in practice this is what lands when someone pastes into a spreadsheet. It buys three things the TSV cannot:

  • Structure instead of delimiters. Cell boundaries are <td> elements, so there is no quoting rule for a receiving app to get wrong.
  • Real line breaks. A wrapped, multi-line cell emits <br> rather than a quoted newline the other application may or may not unquote.
  • No block-separator ambiguity. Discontiguous ranges become separate <table> elements, so nothing has to encode "new block" as a \n\n that a cell could legally contain.

<thead> appears only when copyWithHeaders is on. The blank line the TSV puts between headers and body has no HTML analogue and is not reproduced.

Writing two flavors at once needs ClipboardItem. Where that is missing — an older browser, a non-DOM test environment — the surface falls back to navigator.clipboard.writeText and only the TSV reaches the clipboard. The same fallback runs when ClipboardItem is present but the two-flavor write is rejected, as it can be in a restricted embedding context or against a polyfill: rather than report a failed copy, the surface retries with writeText so the TSV still lands. The serializer always produces both; the write is what degrades.

The white-space:pre-wrap on the table is not cosmetic. HTML collapses runs of whitespace, so without it a cell holding a b would paste as a b. It sits on the <table> because white-space inherits, covering every cell with one declaration.

Escaping and markup

Cell and header text is escaped — &, <, >, and " become entities — and then line breaks become <br>. Text is never interpreted as markup:

  • A cell value of <b>x</b> copies those literal characters. It does not paste as bold text.
  • The same holds for format and native dateFormat / numberFormat output. A format callback returns text, not HTML. Returning "<b>x</b>" from it gets escaped exactly like any other value.
  • render is not consulted at all. It returns a ReactNode for on-screen display; the clipboard uses format, native dateFormat, native numberFormat, or its fallback coercion for both flavors.

Type hints

A cell from a column declared type: "text" or type: "enum" carries Excel's force-as-text format:

html
<td style="mso-number-format:'\@'">1-2</td>

This is what stops Excel from silently reading 1-2 as a date, or 007 as the number 7. Declare type on columns whose values are text that merely looks numeric — SKUs, part numbers, version strings, zero-padded ids:

tsx
{ id: "sku", header: "SKU", type: "text" }

Columns typed number, date, or boolean emit a bare <td> so the spreadsheet can parse them as their real type. Untyped columns also emit a bare <td> — the grid does not guess, because force-formatting an untyped column would turn genuine numbers into left-aligned text. type is the lever.

Google Sheets ignores mso-number-format. Sheets users get the structure, escaping, and <br> benefits, but not the type hint; its equivalent is a proprietary, version-fragile attribute that Pretable deliberately does not emit.

Opting out

To write TSV only, drop the html field in onCopy — the demo at the top of this page does exactly this:

tsx
<PretableSurface
  onCopy={(args) => {
    const payload = serializeRanges(args);
    return payload && { text: payload.text };
  }}
  /* ... */
/>

Per-column format

For domain-specific formatting, such as a status enum that should copy as a label, supply format on the column. Canonical calendar dates should normally use dateFormat:

tsx
const columns: PretableColumn<Event>[] = [
  {
    id: "status",
    header: "Status",
    value: (row) => row.status,
    format: ({ value }) => (value === "open" ? "Open" : String(value)),
  },
  // ...
];

format({ value, row, column }) is called per cell before native formatting or default coercion runs. Return a string. The same callback drives display rendering and copy serialization, so a single definition keeps both channels in sync.

For native calendar-date, decimal, money, or accounting display, put dateFormat or numberFormat on the column and pass locale to the surface. The built-in serializer uses the same configuration, cache, precedence, and locale as display and CSV. If neither callback nor native configuration applies, the unformatted fallback remains channel-specific: clipboard keeps the coercion listed above, which is not promised to equal the renderer fallback for every object or array. See Date formatting and Number formatting.

A localized copied date is presentation text, not strict paste input. For example, Aug 18, 2026 may be useful in a spreadsheet, while built-in date paste trims user-entered text and then accepts only 2026-08-18 or an empty result. Stored row values remain strict. Use custom copy and paste hooks when those channels must round-trip symmetrically.

Grid-level onCopy override

For full control — a custom delimiter, a JSON payload, HTML markup other than the built-in <table> — pass onCopy on the surface. It receives the same SerializeRangesArgs the default serializer gets and returns a CopyPayload ({ text, html? }) or null to cancel the copy. This is the exact shape the demo at the top of this page runs — reuse serializeRanges for the built-in row/column/range handling, then post-process its output:

tsx
import { serializeRanges } from "@pretable/react";
 
<PretableSurface
  ariaLabel="Inspection grid"
  columns={columns}
  rows={rows}
  getRowId={(row) => row.id}
  onCopy={(args) => {
    // args includes ranges, an indexed rowModelSnapshot, columns, locale,
    // copyWithHeaders, and aggregate scope.
    const tsv = serializeRanges(args);
    if (!tsv) return null; // empty selection → cancel the copy
    // Reuse the built-in TSV, but write CSV instead. Returning only `text`
    // drops the HTML flavor — see "Opting out" above.
    return { text: tsv.text.replace(/\t/g, ",") };
  }}
/>;

onCopy is synchronous — it runs and its return value is used immediately, unlike copyToClipboard, which may return a promise.

onCopy returns a CopyPayload or null:

  • { text, html? }text is written as text/plain; when html is present, the surface also writes text/html via the Clipboard API. Excel and Sheets prefer text/html when both are present. The built-in serializer always populates both — see HTML flavor.
  • null — skip the clipboard write entirely (suppress copy in this mode).

serializeRanges is the same helper the default path uses, so calling it inside onCopy (as above) keeps the built-in row/column/range handling — including filtering out the synthetic row-select column — and lets you post-process its output.

Building your own serializer

serializeRanges, defaultCoerceForCopy, and the SerializeRangesArgs / CopyPayload types are exported from @pretable/react, so you can build a serializer from the same primitives the default uses. A custom serializer owns both flavors: return { text } alone and only text/plain reaches the clipboard.

SerializeRangesArgs<TRow> is the argument both onCopy and serializeRanges receive:

FieldTypeNotes
rangesreadonly { start: CellAddress; end: CellAddress }[]selected ranges, in the order they were added
rowModelSnapshotPretableRowModelSnapshot<TRow, TRowId, TColumns>immutable indexed source used for bounded range reads
columnsreadonly PretableColumn<TRow>[]column definitions (including the row-select column)
copyWithHeadersbooleanmirror of the copyWithHeaders prop
locale?PretableLocale | undefinedpublic presentation locale used by native date and number formatting
scope"all" | "loaded"whether copied group aggregates fold every matching record or only the loaded window

scope only matters when the copied selection contains group rows. It is the same scope the grid shows on screen, forwarded so a custom serializer cannot label a partial figure as a whole one: "loaded" means the aggregates fold only the records currently loaded, which is what a server-filtered grid holding a window onto a larger result set has. serializeRanges passes it to each column's formatAggregate, so the copied text matches the rendered group row. It defaults to "all" — a serializer you call by hand, outside the grid, is copying everything it was given.

locale is optional and public on both SerializeRangesArgs and serializeRanges. The surface supplies its own locale to onCopy; pass an explicit value yourself when calling serializeRanges outside a surface if the output must be deterministic across runtimes.

defaultCoerceForCopy(value) is the fallback value→string coercion (the null / application-owned Date / primitive / object rules listed under Default TSV format). Reach for it after any custom formatting branches to preserve the built-in fallback. A hand-written serializer owns native formatting itself; call serializeRanges(args) when you want Pretable to compile and apply each column's dateFormat / numberFormat with args.locale:

tsx
import { defaultCoerceForCopy } from "@pretable/react";
 
const fallbackText = defaultCoerceForCopy(value);

copyToClipboard override

By default the surface writes the CopyPayload to the system clipboard via the async Clipboard API. Pass copyToClipboard to intercept that write — to route copies through your own clipboard shim, log them, or target a non-DOM environment:

tsx
<PretableSurface
  copyToClipboard={async ({ text, html }) => {
    await myClipboard.write({ text, html });
  }}
  /* ... */
/>

It receives the CopyPayload produced by the default serializer or your onCopy, and may return a promise.

copyWithHeaders

When copyWithHeaders is true, each block in the TSV is prefixed with a row of column headers, separated from the body by a blank line, and each table in the HTML flavor gets a <thead>:

tsx
<PretableSurface copyWithHeaders /* ... */ />

Use this when consumers paste into a spreadsheet that needs labeled columns, or into a doc where the data wants a built-in legend. Off by default — most copies want the body only.

Multi-range serialization

A discontiguous selection (Cmd/Ctrl+click, multiple drags) serializes as one block per range, blocks separated by a blank line:

A1\tB1\nA2\tB2
 
D5\tE5\nD6\tE6

Each block is its own TSV grid; range order matches the order the ranges were added. With copyWithHeaders, the header row is repeated at the top of each block.

On the HTML flavor the same selection becomes one <table> per range, in the same order. That is the structural version of the same idea — and it is why the HTML flavor has no block-separator ambiguity: with copyWithHeaders, each table gets its own <thead>.

aria-live announcements

The surface renders an off-screen aria-live="polite" region that announces copy, paste and select-all events for assistive technology. Defaults are English; pass a messages?: PretableSurfaceMessages prop to override:

tsx
<PretableSurface
  messages={{
    selectAllAnnouncement: ({ rowCount, columnCount, isAll }) =>
      isAll
        ? "Tutto selezionato"
        : `${rowCount} righe × ${columnCount} colonne selezionate`,
    copyAnnouncement: ({ rowCount, columnCount }) =>
      `Copiato ${rowCount} × ${columnCount}`,
    copyFailedAnnouncement: () => "Copia non riuscita",
    pasteAnnouncement: ({ cellCount, rejectedCount }) =>
      `Incollate ${cellCount} celle, ${rejectedCount} rifiutate`,
    pasteFailedAnnouncement: () => "Incolla non riuscito",
  }}
/>
EventDefault announcement
Cmd/Ctrl+A (or header-checkbox select-all)"All rows selected" (or {n} rows × {m} columns selected for partial coverage)
Cmd/Ctrl+C success{n} rows × {m} columns copied
Cmd/Ctrl+C failure (clipboard rejects)"Copy failed"
Cmd/Ctrl+V, applied{n} cells pasted (see paste announcements)
Cmd/Ctrl+V, part refused{n} cells pasted, {m} rejected
Cmd/Ctrl+V, all refusedNo cells pasted, {m} rejected
Cmd/Ctrl+V failure (onPaste threw)"Paste failed"

Announcements are debounced (~500ms) to avoid screen-reader thrashing on held shift+arrow extends. Programmatic mutations via state.selection do not announce — only user-triggered events do.

Paste

Cmd/Ctrl+V is the other half, and it has its own page: Paste. In short, it inverts everything above — parseTsv is the exact inverse of the escaping rule, so a wrapped, multi-line cell copied out of a grid pastes back into a single cell — and it hands the whole block to one onPaste callback for you to apply, the way onRowChange hands you one edit. Paste reads the text/plain flavor, so the HTML flavor never affects a grid-to-grid round-trip.

See also

  • PasteCmd/Ctrl+V, the inverse of this page.
  • Selection — what gets serialized when you copy.
  • Keyboard — the Cmd/Ctrl+C binding lives here too.
  • Row grouping — what a copied group row contains.
  • API referenceCopyPayload, SerializeRangesArgs, PretableSurfaceMessages types.