Every file-upload dialog in a business app has a shortcut next to it that nobody built: the user opens the spreadsheet they already have, selects the block they care about, and hits Ctrl+V on your page. No upload, no column mapping screen, no waiting. It is the fastest import path in the product, and in most web apps it lands in a single text input as one long line of tab-separated text.
This article is about what is actually on the clipboard when that happens, how much of it survives into ReoGrid Web, and the two values that arrive looking wrong even when everything works.
Two payloads, not one
A spreadsheet copy writes the same selection twice.
text/plain is the lowest common denominator: TSV, tab between cells, newline between rows, quoted when a value contains a tab, a newline or a quote. Every spreadsheet reads it, every text editor shows it, and it carries values only.
text/html is the rich half — a real <table> with the cell text and, inline on each <td>, the visual attributes: font, weight, colours, alignment. It is what makes a paste into Word or Gmail keep its formatting, and it is the de-facto rich clipboard standard shared by Excel, Google Sheets and LibreOffice.
ReoGrid Web writes both on copy and prefers HTML on paste, falling back to TSV when the source offers no HTML.
Ctrl+V, and the permission prompt you don’t get
There are two ways to read the clipboard in a browser, and they are not equivalent.
navigator.clipboard.read() can be called at any time — and in Chrome it shows the user a permission dialog before handing anything over. A paste event, by contrast, arrives with the data already attached in event.clipboardData, no prompt, because the user just pressed the key that asked for it.
So ReoGrid Web deliberately does not preventDefault() on Ctrl/Cmd+V. The keystroke falls through to the browser, the native paste event fires on the grid container, and the handler reads the payload from the event:
// What the grid does internally on every paste event.
const html = event.clipboardData?.getData('text/html');
if (html) { /* parse the <table> */ }
else { /* fall back to text/plain TSV */ }
The consequence for your UI: a paste toolbar button is not the same feature as Ctrl+V. It has no paste event to read, so it has to go through the Clipboard API and may prompt:
const clipboard = grid.keyboardController.clipboardService;
document.querySelector('#paste')!.addEventListener('click', () => {
clipboard.pasteFromClipboardApi(); // navigator.clipboard.read() — may prompt
});
// Copy and cut have no such problem — they write, they don't read.
document.querySelector('#copy')!.addEventListener('click', () => clipboard.copy());
document.querySelector('#cut')!.addEventListener('click', () => clipboard.cut());
Keep the button if your users expect one, but the keyboard path is the one that always works.
What survives a paste from another app
When the incoming HTML carries no ReoGrid markers, the parser treats it as foreign and takes two things from each cell: the text, and the inline style attributes it recognises — font-family, font-size, font-weight (bold at 700+), font-style, text-decoration: underline, color, background-color, text-align and vertical-align.
Here is a payload in the shape a spreadsheet puts on the clipboard, pasted at A1 exactly as Ctrl+V would:
import { createReogrid } from '@reogrid/lite';
const grid = createReogrid({ workspace: '#grid' });
const ws = grid.worksheet;
const html = `<table>
<tr>
<td style="font-weight:700;background:#dbeafe;text-align:center">Order #</td>
<td style="font-weight:700;background:#dbeafe;text-align:center">Date</td>
<td style="font-weight:700;background:#dbeafe;text-align:center">Product</td>
<td style="font-weight:700;background:#dbeafe;text-align:center">Qty</td>
<td style="font-weight:700;background:#dbeafe;text-align:center">Unit price</td>
</tr>
<tr><td>SO-1042</td><td>2026/09/01</td><td>Laptop Stand</td><td>3</td><td>¥4,800</td></tr>
<tr><td>SO-1043</td><td>2026/09/02</td><td>Wireless Mouse</td><td>8</td><td>¥2,600</td></tr>
<tr><td>SO-1044</td><td>2026/09/03</td>
<td style="color:#b91c1c">License (1 yr)</td><td>1</td><td>¥18,000</td></tr>
</table>`;
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', 'Order #\tDate\tProduct\tQty\tUnit price\n…');
ws.selection.moveTo('A1'); // the paste lands on the active cell
grid.keyboardController.clipboardService.pasteFromEvent(
new ClipboardEvent('paste', { clipboardData: dt }),
);
That last snippet is also how you write a test for a paste, which is otherwise awkward to drive: build a DataTransfer, hand it to pasteFromEvent, assert on cells.
The clipboard is a visual channel, though, and two things follow from that.
Styling that lives in a CSS class rather than on the cell does not come across — the parser reads td.style, not a <style> block. And the text in each <td> is what the source displayed, not what it stored. Which brings us to the two columns above.
The date that arrives as 46266
2026/09/01 is text on the clipboard. ReoGrid Web recognises date-shaped input and converts it to the underlying serial number, applying a date format so it still reads as a date — that is what happens when a user types it into a cell.
On paste, the write is followed by the rest of the pasted cell state, and a cell that arrives without a number format clears the target’s format. The auto-applied date format is one of the casualties, so the serial is left showing as itself: 46266.
The value is correct — it is a real date, it will sort and compare and feed DATEDIF correctly. Only its format is missing.
¥4,800 is the mirror image. The source stored 4800 and drew ¥4,800; the clipboard carries the drawing. Number('¥4,800') is NaN, so the cell holds text — left-aligned, invisible to SUM.
Cleaning up the block
Both are one pass over the pasted range, and you always know that range: a paste leaves its own block selected.
function normalizePastedBlock(ws) {
const b = ws.selection.bounds!; // exactly the block that was just pasted
const firstDataRow = b.topRow + 1; // row 1 of it is the pasted header
// Column B is a date column in our schema — give it back a format
ws.range(firstDataRow, 1, b.bottomRow, 1).setFormat('yyyy/mm/dd');
// Column E arrived as text: strip what the source drew, keep the number
for (let r = firstDataRow; r <= b.bottomRow; r++) {
const raw = ws.getCellInput(r, 4) ?? '';
const n = Number(raw.replace(/[^0-9.-]/g, ''));
if (raw && Number.isFinite(n)) ws.setCellInput(r, 4, String(n));
}
ws.range(firstDataRow, 4, b.bottomRow, 4).setFormat('¥#,##0');
}
Wire it to the container’s own paste event and it runs on every paste, after the grid has finished with it:
document.querySelector('#grid')!.addEventListener('paste', () => {
// The grid's handler runs first (it sits deeper in the tree); by the time
// this fires the cells are written and the selection is the pasted block.
normalizePastedBlock(ws);
});
If the file matters more than the convenience — if you need real types, borders, merges and live formulas — that is not a clipboard job. Loading the .xlsx itself is lossless and still never leaves the browser.
Copying the other way
Select a range, press Ctrl/Cmd+C, and paste into Excel: the header formatting, colours and merges arrive with it, because Excel reads the text/html half. Paste into a terminal or a text editor and you get the TSV.
What the TSV contains is worth stating exactly: the displayed text of each cell. A date cell yields 2026/09/01 rather than 46266. A formula cell yields its computed value — never its =… source, and an empty result yields an empty field. That is Excel’s own behaviour, and it is what makes a copy out of the grid paste sensibly into anything.
The corollary is that formulas do not travel through the clipboard into Excel. If the recipient needs live formulas, export the sheet instead — saveAsXlsx() writes real SUMIFS and XLOOKUP that Excel recalculates. There is a whole article on reading and writing xlsx in the browser.
One Excel behaviour is reproduced deliberately: copying a Ctrl+click selection whose rectangles do not line up is refused, with Excel’s own wording — This operation won’t work on multiple selections. — shown on the active cell.
Grid to grid
Between two ReoGrid Web instances — two tabs, two panels of the same app — the copy carries an extra layer. Alongside the visible HTML, each cell gets data-rg-* attributes holding the raw state: the input string (=D2*1.1, not 5280), the number format, the cell type, rich-text runs. The parser sees those markers and restores the raw layer instead of scraping the visual one.
So a formula copied between grids is still a formula. The date keeps its format. A dropdown cell arrives as a dropdown cell.
One thing it does not do: relative references are pasted verbatim. Copy =D2*E2 to the row below and it stays =D2*E2 — clipboard paste does not shift references the way Excel’s does. Filling a formula down a column is the fill handle’s job, which does relative-shift what it copies. Reference translation on paste is a known gap, not a design choice.
The rest of the edges
Worth knowing before your users find them:
- Merges are not re-created. A horizontally merged source cell fills its first cell and pads the rest with blanks; a vertically merged one (
rowspan) is read as a single cell, so rows underneath it can shift left. Structure survives; the merge does not. - Borders don’t cross. Fills and fonts do. Borders are on the list, not in the build.
- Data validation still applies. A pasted value that violates a
stop-style rule is rejected cell by cell, quietly — the old value stays. That is the right behaviour for an import path, and a reason to validate the column rather than trust the paste. - Protection wins. If any cell in the target block is locked on a protected sheet, the whole paste is vetoed and a protected-cell event fires instead. Nothing is half-written.
- Undo is one step. A paste is a single undoable action; a cut-and-paste is one group, so Ctrl+Z restores both the destination and the origin. Switch sheets between the cut and the paste, though, and the pending move is forgotten — the paste then behaves as a copy and the origin keeps its data.
- No paste special. Values-only, formats-only and transpose are not there yet, and neither is the marching-ants animation around a copied range.
Wrapping up
Ctrl+V is not a fallback for the import you meant to build — for a few hundred rows it is the import, and the amount of ceremony it saves is the reason people paste into a browser tab in the first place. What makes it work is having somewhere for the paste to land that understands cells: a grid that keeps the header’s fill, right-aligns what is numeric, hands you the pasted range as a selection, and lets one undo take it all back.
The clipboard documentation has the API surface; undo/redo covers the action model a paste goes through, and cell protection covers what stops one. When the paste is not enough, the xlsx path is right there — same grid, same page, nothing uploaded.